Files
gree-controller/src/engine/temporary_thermostat.rs
T
2026-09-01 13:33:16 +02:00

384 lines
19 KiB
Rust

pub fn temporary_quick_thermostat_is_active(zone: &Zone, now: DateTime<Utc>) -> 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<DateTime<Utc>> {
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<DateTime<Utc>> {
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<Utc>) -> Option<DateTime<Utc>> {
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<Vec<String>, 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<Utc>) -> 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,
house_power_enabled: bool,
) -> 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 !house_power_enabled {
set_temporary_wait_state(state, zone, "waiting_master", 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<Utc>,
sample_at: Option<DateTime<Utc>>,
max_gap_seconds: u64,
) -> Option<String> {
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(())
}