This commit is contained in:
Mateusz Gruszczyński
2026-08-28 10:04:35 +02:00
parent d159795267
commit eb325cc0c8
15 changed files with 392 additions and 653 deletions
+135 -16
View File
@@ -665,6 +665,12 @@ fn temporary_quick_thermostat_next_deadline(session: &TemporaryQuickThermostat)
}
}
fn temporary_quick_thermostat_wakeup_at(zone: &Zone, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
let session = zone.temporary_quick_thermostat.as_ref()?;
let pending = session.activated_at.is_none() && zone.local_thermostat_power != Some(true) && session.started_at > now;
if pending { Some(session.started_at.clone()) } else { temporary_quick_thermostat_next_deadline(session) }
}
fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result<Vec<String>, AppError> {
let now = Utc::now();
let mut restored_disabled_zones = Vec::new();
@@ -673,20 +679,62 @@ fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone], sche
let Some(deadline) = temporary_quick_thermostat_hard_deadline(session) else { continue; };
if deadline > now { continue; }
let finish_kind = session.finish_kind.clone();
let restores_disabled = session.restore_zone_enabled == Some(false);
reset_local_thermostat_override(zone);
refresh_zone_runtime_target(zone, schedules, house_mode);
let was_activated = session.activated_at.is_some() || zone.local_thermostat_power == Some(true);
let restores_disabled = was_activated && session.restore_zone_enabled == Some(false);
if was_activated {
reset_local_thermostat_override(zone);
refresh_zone_runtime_target(zone, schedules, house_mode);
} else {
// A delayed session that expired before it ever acquired ownership must not
// clear unrelated manual/schedule state that was active while it was waiting.
zone.temporary_quick_thermostat = None;
}
zone.updated_at = now.clone();
state.db.save_zone(zone)?;
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": "deadline"
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind,
"reason": if was_activated { "deadline" } else { "expired_before_activation" }
}));
if restores_disabled { restored_disabled_zones.push(zone.id.clone()); }
}
Ok(restored_disabled_zones)
}
fn activate_due_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone]) -> Result<(), AppError> {
let now = Utc::now();
for zone in zones.iter_mut() {
let Some(session) = zone.temporary_quick_thermostat.as_ref() else { continue; };
let already_active = session.activated_at.is_some() || zone.local_thermostat_power == Some(true);
if already_active || session.started_at > now { continue; }
if temporary_quick_thermostat_hard_deadline(session).map(|deadline| deadline <= now).unwrap_or(false) {
continue;
}
let target = session.temperature_target
.or(zone.manual_setpoint)
.or(zone.effective_setpoint)
.unwrap_or(zone.setpoint);
set_local_thermostat_power(zone, true, now.clone());
zone.enabled = true;
zone.setpoint = target;
zone.manual_setpoint = Some(target);
zone.effective_setpoint = Some(target);
zone.manual_override_until = None;
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
session.activated_at = Some(now.clone());
session.condition_started_at = None;
}
zone.updated_at = now.clone();
state.db.save_zone(zone)?;
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
state.log("info", "zone.temporary_quick_thermostat_started", &format!("Temporary Quick Thermostat started for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "scheduled_start": true, "target_temperature": target
}));
}
Ok(())
}
async fn ensure_device_off_after_temporary_disabled_restore(state: &AppState, zone: &Zone, device: &Device) {
if zone.enabled || !device.enabled || !device.online || device.communication_failures > 0 || !device.power { return; }
let _device_guard = state.lock_device_operation(&zone.device_id).await;
@@ -714,6 +762,7 @@ fn temporary_temperature_condition_met(zone: &Zone, session: &TemporaryQuickTher
/// Update a temperature-based temporary session from the freshly selected room sensor.
/// Returns a completion reason when ownership should be handed back immediately.
fn evaluate_temporary_quick_thermostat_condition(zone: &mut Zone, now: DateTime<Utc>) -> Option<String> {
if zone.local_thermostat_power != Some(true) { return None; }
let met = zone.temporary_quick_thermostat.as_ref()
.filter(|session| matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable"))
.map(|session| temporary_temperature_condition_met(zone, session))?;
@@ -778,7 +827,7 @@ fn next_zone_control_deadline_delay(state: &AppState) -> Result<Option<Duration>
Ok(state.db.list_zones()?.into_iter()
.filter_map(|zone| {
let local = if local_thermostat_handback_is_active(&zone) { zone.local_thermostat_resume_at.clone() } else { None };
let temporary = zone.temporary_quick_thermostat.as_ref().and_then(temporary_quick_thermostat_next_deadline);
let temporary = temporary_quick_thermostat_wakeup_at(&zone, now.clone());
match (local, temporary) {
(Some(a), Some(b)) => Some(a.min(b)),
(Some(a), None) => Some(a),
@@ -1072,7 +1121,8 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; };
let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await;
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
if let Some(mode) = patch.mode.as_deref() {
let temporary_owns_zone = zone.local_thermostat_power == Some(true) && zone.temporary_quick_thermostat.is_some();
if let Some(mode) = patch.mode.as_deref().filter(|_| !temporary_owns_zone) {
match mode {
"house" | "auto" => zone.inherit_house_mode = true,
"cool" | "heat" => {
@@ -1082,7 +1132,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
_ => {}
}
}
if let Some(preset) = patch.preset.as_deref() {
if let Some(preset) = patch.preset.as_deref().filter(|_| !temporary_owns_zone) {
if preset == "auto" {
zone.manual_preset = None;
zone.manual_setpoint = None;
@@ -1093,6 +1143,11 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
}
}
if temporary_owns_zone && (patch.mode.is_some() || patch.preset.is_some()) {
state.log("info", "group.control_deferred_by_temporary_thermostat", &format!("Group climate change deferred for {} while Temporary Quick Thermostat owns the zone", zone.name), json!({
"zone_id": zone.id, "group_id": group.id, "source": source
}));
}
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
@@ -1269,6 +1324,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
// is off; no physical state is restored here, only automation ownership.
expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode)?;
let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode)?;
activate_due_temporary_quick_thermostats(state, &mut zone_snapshot)?;
// Outdoor temperature is deliberately optional. Prefer the configured Home
// Assistant entity, but keep the dashboard/assist useful by falling back to the
@@ -1359,11 +1415,11 @@ async fn control_zones(state: &AppState) -> Result<()> {
// House "off" means the smart thermostat does not control inherited zones.
// A zone explicitly switched to heat/cool remains independent and may still run.
let effective_mode_owned = if zone.inherit_house_mode {
settings.house_mode.clone()
} else {
zone.mode.clone()
};
// Local Quick Thermostat is an explicit per-zone request. If the inherited house
// climate mode is "off" (no automatic climate control), use the zone's last local
// heat/cool mode while local ownership is ON. The separate whole-house master power
// remains authoritative and is checked before this loop.
let effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
zone.effective_mode = effective_mode_owned.clone();
let ownership_blocked_by_group = zone.local_thermostat_power != Some(true)
&& groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
@@ -2055,9 +2111,9 @@ fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) ->
}
pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) {
let configured_mode = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() };
zone.effective_mode = configured_mode.to_string();
let target_mode = if configured_mode == "off" { zone.mode.as_str() } else { configured_mode };
let configured_mode = effective_zone_mode(zone, house_mode);
zone.effective_mode = configured_mode.clone();
let target_mode = if configured_mode == "off" { zone.mode.as_str() } else { configured_mode.as_str() };
let schedule = active_schedule_for_zone(zone, schedules, Local::now());
let (preset, target) = resolve_zone_target(zone, schedule, target_mode);
zone.active_preset = preset;
@@ -2066,6 +2122,15 @@ pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], hous
}
}
fn effective_zone_mode(zone: &Zone, house_mode: &str) -> String {
let configured = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() };
if zone.local_thermostat_power == Some(true) && configured == "off" {
zone.mode.clone()
} else {
configured.to_string()
}
}
fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> {
schedules.iter()
.filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now))
@@ -2749,8 +2814,10 @@ mod tests {
fn temporary_session(now: DateTime<Utc>) -> TemporaryQuickThermostat {
TemporaryQuickThermostat {
start_kind: "now".into(),
finish_kind: "temperature_stable".into(),
started_at: now,
started_at: now.clone(),
activated_at: Some(now),
restore_zone_enabled: Some(true),
expires_at: None,
temperature_target: Some(23.0),
@@ -2806,6 +2873,58 @@ mod tests {
assert!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at.is_none());
}
#[test]
fn delayed_temporary_session_does_not_block_automation_before_start() {
let now = Utc::now();
let mut zone = test_zone("device");
let mut session = temporary_session(now.clone() + chrono::Duration::hours(1));
session.start_kind = "delay".into();
session.activated_at = None;
session.expires_at = Some(now.clone() + chrono::Duration::hours(3));
zone.temporary_quick_thermostat = Some(session);
assert!(!device_blocked_by_local_thermostat(&zone.device_id, std::slice::from_ref(&zone)));
assert_eq!(temporary_quick_thermostat_wakeup_at(&zone, now.clone()), Some(now + chrono::Duration::hours(1)));
}
#[test]
fn delayed_temperature_condition_cannot_finish_before_activation() {
let now = Utc::now();
let mut zone = test_zone("device");
zone.current_temperature = Some(23.0);
let mut session = temporary_session(now.clone() + chrono::Duration::hours(1));
session.start_kind = "at".into();
session.activated_at = None;
zone.temporary_quick_thermostat = Some(session);
assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now).is_none());
assert!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at.is_none());
}
#[test]
fn temporary_setpoint_keeps_priority_over_active_schedule() {
let mut zone = test_zone("device");
zone.local_thermostat_power = Some(true);
zone.manual_setpoint = Some(23.0);
zone.temporary_quick_thermostat = Some(temporary_session(Utc::now()));
let mut schedule = test_schedule("night", vec![1,2,3,4,5,6,7], "00:00", "00:00");
schedule.preset = "custom".into();
schedule.setpoint = 19.0;
let (_preset, target) = resolve_zone_target(&zone, Some(&schedule), "cool");
assert_eq!(target, 23.0);
}
#[test]
fn local_quick_thermostat_can_run_when_inherited_house_mode_is_off() {
let mut zone = test_zone("device");
zone.inherit_house_mode = true;
zone.mode = "cool".into();
assert_eq!(effective_zone_mode(&zone, "off"), "off");
zone.local_thermostat_power = Some(true);
assert_eq!(effective_zone_mode(&zone, "off"), "cool");
}
#[test]
fn local_thermostat_off_restarts_backend_handback_deadline() {
let mut zone = test_zone("device");