pub fn temporary_quick_thermostat_is_active(zone: &Zone, now: DateTime) -> bool { zone.temporary_quick_thermostat .as_ref() .and_then(|session| session.activated_at.as_ref()) .map(|activated_at| activated_at <= &now) .unwrap_or(false) } fn temporary_quick_thermostat_hard_deadline( session: &TemporaryQuickThermostat, ) -> Option> { if session.state == "paused_manual" { return None; } match (session.expires_at, session.safety_expires_at) { (Some(a), Some(b)) => Some(a.min(b)), (Some(a), None) => Some(a), (None, Some(b)) => Some(b), (None, None) => None, } } fn temporary_quick_thermostat_next_deadline( session: &TemporaryQuickThermostat, ) -> Option> { let hard = temporary_quick_thermostat_hard_deadline(session); let hold = if session.finish_kind == "temperature_stable" && session.hold_seconds > 0 { session .condition_started_at .map(|started| started + chrono::Duration::seconds(session.hold_seconds as i64)) } else { None }; match (hard, hold) { (Some(a), Some(b)) => Some(a.min(b)), (Some(a), None) => Some(a), (None, Some(b)) => Some(b), (None, None) => None, } } fn temporary_quick_thermostat_wakeup_at(zone: &Zone, now: DateTime) -> Option> { let session = zone.temporary_quick_thermostat.as_ref()?; if temporary_quick_thermostat_is_active(zone, now) { return temporary_quick_thermostat_next_deadline(session); } // Once a due session is waiting for master/manual ownership, normal wakeups or an // explicit state-change notification will retry it. Returning a past start would spin. (session.started_at > now).then_some(session.started_at) } /// Finish an active temporary session and apply climate changes that were deferred while /// it owned the zone. Pending-session cancellation should simply remove the session instead. pub fn finish_temporary_quick_thermostat( zone: &mut Zone, schedules: &[Schedule], house_mode: &str, ) -> bool { let now = Utc::now(); let was_active = temporary_quick_thermostat_is_active(zone, now); let Some(session) = zone.temporary_quick_thermostat.take() else { return false; }; if !was_active { return false; } zone.local_thermostat_power = session.restore_local_thermostat_power; zone.local_thermostat_resume_at = session.restore_local_thermostat_resume_at; zone.local_thermostat_restore_zone_enabled = session.restore_local_thermostat_zone_enabled; zone.manual_preset = session.restore_manual_preset; zone.manual_setpoint = session.restore_manual_setpoint; zone.manual_override_until = session.restore_manual_override_until; if let Some(enabled) = session.restore_zone_enabled { zone.enabled = enabled; } if let Some(mode) = session.deferred_mode.as_deref() { match mode { "house" | "auto" => zone.inherit_house_mode = true, "cool" | "heat" => { zone.inherit_house_mode = false; zone.mode = mode.to_string(); } _ => {} } } if let Some(preset) = session.deferred_preset.as_deref() { if preset == "auto" { zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; } else if matches!(preset, "comfort" | "sleep" | "away" | "custom") { zone.manual_preset = Some(preset.to_string()); if preset != "custom" { zone.manual_setpoint = None; } zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now()); } } // A deferred custom temperature is valid only while the final deferred preset is custom. // This makes the hand-back robust even if an older persisted session contains a stale // deferred_setpoint from a previously selected Custom action. if session.deferred_preset.as_deref() == Some("custom") { if let Some(setpoint) = session.deferred_setpoint { zone.setpoint = setpoint; zone.manual_preset = Some("custom".into()); zone.manual_setpoint = Some(setpoint); zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now()); } } refresh_zone_runtime_target(zone, schedules, house_mode); // The temporary session has ended and ownership/intent changes again. A cancellation // that belonged to the temporary intent must not suppress the restored thermostat state. rearm_compressor_queue(zone); true } async fn expire_temporary_quick_thermostats( state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str, ) -> Result, AppError> { let now = Utc::now(); let mut restored_disabled_zones = Vec::new(); for zone in zones.iter_mut() { let zone_id = zone.id.clone(); let _zone_guard = state.lock_zone_operation(&zone_id).await; let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; }; let _device_guard = state.lock_device_operation(&snapshot.device_id).await; let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; *zone = latest; let active_under_manual = zone.device_manual_override && zone .temporary_quick_thermostat .as_ref() .and_then(|session| session.activated_at) .is_some(); if active_under_manual { set_temporary_wait_state(state, zone, "paused_manual", now)?; continue; } let Some((deadline, finish_kind, restore_zone_enabled)) = zone .temporary_quick_thermostat .as_ref() .and_then(|session| { temporary_quick_thermostat_hard_deadline(session).map(|deadline| { ( deadline, session.finish_kind.clone(), session.restore_zone_enabled, ) }) }) else { continue; }; if deadline > now { continue; } let was_activated = temporary_quick_thermostat_is_active(zone, now); let restores_disabled = was_activated && restore_zone_enabled == Some(false); if was_activated { finish_temporary_quick_thermostat(zone, schedules, house_mode); } else { // A delayed session that expires before it acquires ownership must not clear // unrelated local/manual/schedule state that was active while it was waiting. zone.temporary_quick_thermostat = None; } zone.updated_at = now; 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": if was_activated { "deadline" } else { "expired_before_activation" } }), ); if restores_disabled { restored_disabled_zones.push(zone.id.clone()); } } Ok(restored_disabled_zones) } fn set_temporary_wait_state( state: &AppState, zone: &mut Zone, value: &str, now: DateTime, ) -> Result<(), AppError> { let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return Ok(()); }; let mut changed = false; if session.state != value { session.state = value.to_string(); changed = true; } if value == "paused_manual" && session.paused_at.is_none() { session.paused_at = Some(now); changed = true; } if !changed { return Ok(()); } session.condition_started_at = None; session.condition_last_observed_at = None; zone.updated_at = now; state.db.save_zone(zone)?; state.broadcast("zone.updated", serde_json::to_value(&*zone)?); Ok(()) } async fn activate_due_temporary_quick_thermostats( state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str, ) -> Result<(), AppError> { let now = Utc::now(); for zone in zones.iter_mut() { let zone_id = zone.id.clone(); let _zone_guard = state.lock_zone_operation(&zone_id).await; let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; }; let _device_guard = state.lock_device_operation(&snapshot.device_id).await; let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; *zone = latest; let Some((session_state, started_at)) = zone .temporary_quick_thermostat .as_ref() .map(|session| (session.state.clone(), session.started_at)) else { continue; }; if temporary_quick_thermostat_is_active(zone, now) { if session_state != "active" && !zone.device_manual_override { set_temporary_wait_state(state, zone, "active", now)?; } continue; } if started_at > now { continue; } if zone.device_manual_override { set_temporary_wait_state(state, zone, "paused_manual", now)?; continue; } let (temperature_target, duration_seconds, safety_duration_seconds, finish_kind) = { let session = zone .temporary_quick_thermostat .as_ref() .expect("temporary session checked above"); ( session.temperature_target, session.duration_seconds, session.safety_duration_seconds, session.finish_kind.clone(), ) }; let target = temperature_target .or(zone.manual_setpoint) .or(zone.effective_setpoint) .unwrap_or(zone.setpoint); let restore_enabled = zone.enabled; let restore_local_power = zone.local_thermostat_power; let restore_local_resume_at = zone.local_thermostat_resume_at; let restore_local_zone_enabled = zone.local_thermostat_restore_zone_enabled; let restore_manual_preset = zone.manual_preset.clone(); let restore_manual_setpoint = zone.manual_setpoint; let restore_manual_override_until = zone.manual_override_until; let configured_mode = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() }; let active_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() }; let schedule_boundary = if finish_kind == "schedule_boundary" { next_schedule_boundary_utc(&zone.id, schedules, Local::now()) } else { None }; if finish_kind == "schedule_boundary" && schedule_boundary.is_none() { zone.temporary_quick_thermostat = None; zone.updated_at = now; state.db.save_zone(zone)?; state.broadcast("zone.updated", serde_json::to_value(&*zone)?); state.log("warn", "zone.temporary_quick_thermostat_cancelled", &format!("Temporary Quick Thermostat cancelled for {} because no future schedule boundary exists", zone.name), json!({ "zone_id": zone.id, "device_id": zone.device_id })); continue; } // A scheduled temporary session becoming active is a new explicit thermostat intent. // Replace any older cancelled/pending compressor task so this session can queue its // own start or mode change against the current protection window. rearm_compressor_queue(zone); // Temporary ownership is independent from the ordinary local hand-back state. zone.local_thermostat_power = Some(true); zone.local_thermostat_resume_at = None; zone.local_thermostat_restore_zone_enabled = None; zone.enabled = true; 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); session.state = "active".into(); session.active_mode = Some(active_mode); session.restore_zone_enabled = Some(restore_enabled); session.restore_local_thermostat_power = restore_local_power; session.restore_local_thermostat_resume_at = restore_local_resume_at; session.restore_local_thermostat_zone_enabled = restore_local_zone_enabled; session.restore_manual_preset = restore_manual_preset; session.restore_manual_setpoint = restore_manual_setpoint; session.restore_manual_override_until = restore_manual_override_until; session.condition_started_at = None; session.condition_last_observed_at = None; session.paused_at = None; if session.finish_kind == "duration" { session.expires_at = duration_seconds.map(|seconds| now + chrono::Duration::seconds(seconds as i64)); } else if session.finish_kind == "schedule_boundary" { session.expires_at = schedule_boundary; } if matches!( session.finish_kind.as_str(), "temperature_reached" | "temperature_stable" ) { session.safety_expires_at = safety_duration_seconds .map(|seconds| now + chrono::Duration::seconds(seconds as i64)); } } zone.updated_at = now; 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 || zone.device_manual_override || !device.enabled || !device.online || device.communication_failures > 0 || !device.power { return; } let _device_guard = state.lock_device_operation(&zone.device_id).await; let should_stop = state .db .get_zone(&zone.id) .ok() .flatten() .map(|latest| { !latest.enabled && !latest.device_manual_override && latest.temporary_quick_thermostat.is_none() && latest.local_thermostat_power.is_none() }) .unwrap_or(false); if !should_stop { return; } if let Err(err) = send_command_locked( state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }, ) .await { state.log( "error", "zone.temporary_quick_thermostat_poweroff_error", &err.to_string(), json!({ "zone_id": zone.id, "device_id": zone.device_id }), ); } } fn temporary_temperature_condition_met(zone: &Zone, session: &TemporaryQuickThermostat) -> bool { let (Some(current), Some(target)) = (zone.current_temperature, session.temperature_target) else { return false; }; let tolerance = session.tolerance_c.max(0.0); match session.temperature_operator.as_deref().unwrap_or("within") { "at_or_below" => current <= target + tolerance, "at_or_above" => current >= target - tolerance, _ => (current - target).abs() <= tolerance, } } /// Update a temperature-based temporary session only from a fresh sensor observation. /// Cached samples and long controller gaps cannot count as continuous hold time. fn evaluate_temporary_quick_thermostat_condition( zone: &mut Zone, now: DateTime, sample_at: Option>, max_gap_seconds: u64, ) -> Option { if !temporary_quick_thermostat_is_active(zone, now) || zone.device_manual_override { return None; } let is_condition = zone .temporary_quick_thermostat .as_ref() .map(|session| { matches!( session.finish_kind.as_str(), "temperature_reached" | "temperature_stable" ) }) .unwrap_or(false); if !is_condition { return None; } let Some(sample_at) = sample_at else { if let Some(session) = zone.temporary_quick_thermostat.as_mut() { session.condition_started_at = None; session.condition_last_observed_at = None; } return None; }; let last_observed = zone .temporary_quick_thermostat .as_ref() .and_then(|session| session.condition_last_observed_at); if last_observed.map(|last| sample_at <= last).unwrap_or(false) { return None; } let gap_broken = last_observed .map(|last| { sample_at.signed_duration_since(last).num_seconds() > max_gap_seconds.max(1) as i64 }) .unwrap_or(false); let met = zone .temporary_quick_thermostat .as_ref() .map(|session| temporary_temperature_condition_met(zone, session))?; let session = zone.temporary_quick_thermostat.as_mut()?; session.condition_last_observed_at = Some(sample_at); if gap_broken { session.condition_started_at = None; } match session.finish_kind.as_str() { "temperature_reached" => { if met { return Some("temperature_reached".into()); } session.condition_started_at = None; } "temperature_stable" => { if !met { session.condition_started_at = None; return None; } let started = session.condition_started_at.get_or_insert(sample_at); if session.hold_seconds == 0 || sample_at.signed_duration_since(*started).num_seconds() >= session.hold_seconds as i64 { return Some("temperature_stable".into()); } } _ => {} } None } async 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() { let zone_id = zone.id.clone(); let _zone_guard = state.lock_zone_operation(&zone_id).await; let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; }; let _device_guard = state.lock_device_operation(&snapshot.device_id).await; let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; *zone = latest; // 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. // 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; } 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); 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_resumed", &format!("Local thermostat hand-back completed for {}", zone.name), json!({ "zone_id": zone.id, "device_id": zone.device_id, "delay_minutes": LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES })); } Ok(()) }