v0.7.12-hotfix
This commit is contained in:
+76
-7
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user