v0.9.2
This commit is contained in:
+10
-5
@@ -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
|
||||
}));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user