This commit is contained in:
Mateusz Gruszczyński
2026-09-01 13:33:16 +02:00
parent dc20267787
commit 97c26c67b6
10 changed files with 67 additions and 46 deletions
+10 -5
View File
@@ -257,6 +257,11 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
// lock before the per-zone lock so a concurrent schedule edit cannot leave an override
// pointing at an obsolete boundary (and so lock order stays schedule -> zone -> device).
let _schedule_guard = state.lock_schedule_operation().await;
// A local thermostat click is an ownership transition, not merely a field update.
// Serialize it with the complete thermostat cycle so an already-running arbitration
// pass cannot reach an automatic ON after the user has committed local OFF. Lock order
// stays schedule -> cycle -> zone -> device, matching schedule mutation paths.
let _cycle_guard = state.lock_zone_control_cycle().await;
// Serialize quick-thermostat changes with the same device lock used by GREE polling and
// manual-takeover detection. Without this, a poll that started just before a Web/HA
// thermostat action could save an older zone snapshot afterwards and resurrect a false
@@ -642,11 +647,11 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
if was_enabled && !zone.enabled {
power_off_zone_device(state, &zone, "zone.quick_disabled").await;
} else if patch.power == Some(false) {
if let Err(err) = engine::send_command(
state,
&zone.device_id,
DeviceCommand { power: Some(false), ..Default::default() },
).await {
// Local OFF is an explicit safety/user intent. Force one OFF frame instead of
// trusting the cached device.power flag; a stale cache must never let a running
// unit survive a thermostat OFF click. The cycle lock held by this handler keeps
// automatic arbitration out until the OFF marker and physical command are complete.
if let Err(err) = engine::force_power_off_device(state, &zone.device_id).await {
state.log("error", "zone.local_power_error", &err.to_string(), json!({
"zone_id": zone.id, "device_id": zone.device_id, "power": false
}));
+8 -5
View File
@@ -23,16 +23,19 @@ pub fn set_local_thermostat_power(zone: &mut Zone, power: bool, now: DateTime<Ut
changed
}
/// Re-arm a local-OFF hand-back after a temporary direct/manual device takeover.
/// The countdown must start from the moment that manual control ends, not from the
/// older OFF action that happened before the takeover.
/// Re-arm a timed local-OFF hand-back after a temporary direct/manual device takeover.
/// An OFF with no resume_at is intentionally indefinite (for example a group OFF that
/// physically powers members down while releasing group ownership) and must never gain
/// a 15-minute timer merely because another ownership mode ended.
fn rearm_local_thermostat_resume(zone: &mut Zone, now: DateTime<Utc>) -> bool {
if zone.local_thermostat_power != Some(false) { return false; }
if zone.local_thermostat_power != Some(false) || zone.local_thermostat_resume_at.is_none() { return false; }
set_local_thermostat_power(zone, false, now)
}
fn local_thermostat_handback_is_active(zone: &Zone) -> bool {
zone.local_thermostat_power == Some(false) && !zone.device_manual_override
zone.local_thermostat_power == Some(false)
&& zone.local_thermostat_resume_at.is_some()
&& !zone.device_manual_override
}
/// Clear only the ordinary local Quick Thermostat. Temporary Quick Thermostat state is
+4 -12
View File
@@ -362,19 +362,11 @@ async fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone],
// Do not let the old timer expire underneath someone who is actively controlling
// the unit. When that takeover ends and the device returns to OFF, the deadline is
// re-armed from that moment.
// Only an explicit timed local-OFF participates in automatic hand-back.
// local_thermostat_power=false with resume_at=None is a deliberate indefinite OFF
// (notably the state produced by group OFF) and must stay off until the user/group
// explicitly turns it back on or resumes automation.
if !local_thermostat_handback_is_active(zone) { continue; }
if zone.local_thermostat_resume_at.is_none() {
// Upgrade safety for a persisted 0.7.10/0.7.11 local-OFF state: old releases
// had no hand-back deadline, so start one from the first cycle after upgrade.
set_local_thermostat_power(zone, false, now.clone());
zone.updated_at = now.clone();
state.db.save_zone(zone)?;
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
state.log("info", "zone.local_thermostat_resume_scheduled", &format!("Local thermostat hand-back scheduled for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "delay_minutes": LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES
}));
continue;
}
let expired = zone.local_thermostat_resume_at.as_ref().map(|at| at <= &now).unwrap_or(false);
if !expired { continue; }
reset_local_thermostat_override(zone);
+21
View File
@@ -439,6 +439,27 @@ mod tests {
assert_eq!(effective_zone_mode(&zone, "off"), "cool");
}
#[test]
fn indefinite_local_off_does_not_create_or_rearm_handback() {
let mut zone = test_zone("device");
zone.local_thermostat_power = Some(false);
zone.local_thermostat_resume_at = None;
assert!(!local_thermostat_handback_is_active(&zone));
assert!(!rearm_local_thermostat_resume(&mut zone, Utc::now()));
assert_eq!(zone.local_thermostat_power, Some(false));
assert!(zone.local_thermostat_resume_at.is_none());
}
#[test]
fn timed_local_off_remains_blocking_until_explicit_deadline() {
let mut zone = test_zone("device");
let now = Utc::now();
set_local_thermostat_power(&mut zone, false, now);
assert!(local_thermostat_handback_is_active(&zone));
assert_eq!(zone.local_thermostat_power, Some(false));
assert!(zone.local_thermostat_resume_at.is_some());
}
#[test]
fn local_thermostat_off_restarts_backend_handback_deadline() {
let mut zone = test_zone("device");