v0.7.12-hotfix

This commit is contained in:
Mateusz Gruszczyński
2026-08-27 18:41:18 +02:00
parent 62514258d3
commit 8141838ec1
8 changed files with 107 additions and 25 deletions
+76 -7
View File
@@ -573,6 +573,34 @@ fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zon
pub const LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES: i64 = 15;
/// Apply local quick-thermostat power ownership and keep the automatic hand-back
/// deadline in one backend-owned place. Every fresh OFF action receives a fresh
/// deadline; ON cancels any pending hand-back.
pub fn set_local_thermostat_power(zone: &mut Zone, power: bool, now: DateTime<Utc>) -> bool {
let resume_at = if power {
None
} else {
Some(now + chrono::Duration::minutes(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES))
};
let changed = zone.local_thermostat_power != Some(power)
|| zone.local_thermostat_resume_at != resume_at;
zone.local_thermostat_power = Some(power);
zone.local_thermostat_resume_at = resume_at;
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.
fn rearm_local_thermostat_resume(zone: &mut Zone, now: DateTime<Utc>) -> bool {
if zone.local_thermostat_power != Some(false) { 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
}
pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool {
let changed = zone.local_thermostat_power.is_some()
|| zone.local_thermostat_resume_at.is_some()
@@ -590,10 +618,15 @@ pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool {
fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result<(), AppError> {
let now = Utc::now();
for zone in zones.iter_mut() {
if zone.local_thermostat_power == Some(false) && zone.local_thermostat_resume_at.is_none() {
// A direct device/pilot takeover has higher priority than the local-OFF hand-back.
// 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.
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.
zone.local_thermostat_resume_at = Some(now.clone() + chrono::Duration::minutes(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES));
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)?);
@@ -602,8 +635,7 @@ fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], sched
}));
continue;
}
let expired = zone.local_thermostat_power == Some(false)
&& zone.local_thermostat_resume_at.as_ref().map(|at| at <= &now).unwrap_or(false);
let expired = zone.local_thermostat_resume_at.as_ref().map(|at| at <= &now).unwrap_or(false);
if !expired { continue; }
reset_local_thermostat_override(zone);
refresh_zone_runtime_target(zone, schedules, house_mode);
@@ -620,7 +652,7 @@ fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], sched
fn next_local_thermostat_resume_delay(state: &AppState) -> Result<Option<Duration>, AppError> {
let now = Utc::now();
Ok(state.db.list_zones()?.into_iter()
.filter(|zone| zone.local_thermostat_power == Some(false))
.filter(local_thermostat_handback_is_active)
.filter_map(|zone| zone.local_thermostat_resume_at)
.map(|at| (at - now.clone()).to_std().unwrap_or(Duration::ZERO))
.min())
@@ -660,7 +692,9 @@ fn manual_override_matches_baseline(zone: &Zone, device: &Device) -> bool {
fn persist_manual_override_clear(state: &AppState, zone: &mut Zone, source: &str, restored: bool) -> Result<bool, AppError> {
if !reset_device_manual_override(zone) { return Ok(false); }
zone.updated_at = Utc::now();
let now = Utc::now();
let local_resume_rearmed = restored && rearm_local_thermostat_resume(zone, now.clone());
zone.updated_at = now;
state.db.save_zone(zone)?;
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
let (kind, message) = if restored {
@@ -669,7 +703,9 @@ fn persist_manual_override_clear(state: &AppState, zone: &mut Zone, source: &str
("zone.device_manual_override_cleared", format!("Manual device control ended for {}", zone.name))
};
state.log("info", kind, &message, json!({
"zone_id": zone.id, "device_id": zone.device_id, "source": source
"zone_id": zone.id, "device_id": zone.device_id, "source": source,
"local_thermostat_resume_rearmed": local_resume_rearmed,
"local_thermostat_resume_at": zone.local_thermostat_resume_at,
}));
Ok(true)
}
@@ -2407,6 +2443,39 @@ mod tests {
assert_eq!(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES, 15);
}
#[test]
fn local_thermostat_off_restarts_backend_handback_deadline() {
let mut zone = test_zone("device");
let first = Utc::now();
set_local_thermostat_power(&mut zone, false, first.clone());
let first_deadline = zone.local_thermostat_resume_at.clone().unwrap();
assert_eq!(first_deadline, first.clone() + chrono::Duration::minutes(15));
let second = first + chrono::Duration::minutes(4);
set_local_thermostat_power(&mut zone, true, second.clone());
assert!(zone.local_thermostat_resume_at.is_none());
set_local_thermostat_power(&mut zone, false, second.clone());
assert_eq!(zone.local_thermostat_resume_at, Some(second + chrono::Duration::minutes(15)));
assert!(zone.local_thermostat_resume_at.clone().unwrap() > first_deadline);
}
#[test]
fn manual_device_takeover_suspends_local_handback_until_control_returns() {
let mut zone = test_zone("device");
let now = Utc::now();
set_local_thermostat_power(&mut zone, false, now.clone());
assert!(local_thermostat_handback_is_active(&zone));
zone.device_manual_override = true;
assert!(!local_thermostat_handback_is_active(&zone));
zone.device_manual_override = false;
let returned = now + chrono::Duration::minutes(7);
assert!(rearm_local_thermostat_resume(&mut zone, returned.clone()));
assert_eq!(zone.local_thermostat_resume_at, Some(returned + chrono::Duration::minutes(15)));
assert!(local_thermostat_handback_is_active(&zone));
}
#[test]
fn rounded_gree_setpoint_does_not_create_manual_override() {
let zone = test_zone("device");