use std::{collections::HashMap, sync::atomic::Ordering, time::{Duration, Instant}}; use anyhow::Result; use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday}; use serde_json::{json, Value}; use tokio::time::sleep; use crate::{ error::AppError, home_assistant, influxdb, models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading}, state::{AppState, PendingControllerCommand}, }; pub fn start(state: AppState) { let poll_state = state.clone(); tokio::spawn(async move { sleep(Duration::from_millis(500)).await; loop { match poll_all(&poll_state).await { Ok(()) => { if !poll_state.initial_device_sync_complete.swap(true, Ordering::AcqRel) { tracing::info!("initial device state synchronized; thermostat control enabled"); } } Err(err) => tracing::error!(error=?err, "device poll cycle failed"), } let seconds = poll_state.settings.read().await.poll_interval_seconds.max(2); sleep(Duration::from_secs(seconds)).await; } }); let control_state = state.clone(); tokio::spawn(async move { sleep(Duration::from_secs(2)).await; loop { // A restart must never make decisions from the persisted, potentially stale // device snapshot. Wait for one full live poll before thermostat/schedule/automation // ownership can emit commands. Manual API/remote control remains available. if !control_state.initial_device_sync_complete.load(Ordering::Acquire) { sleep(Duration::from_millis(250)).await; continue; } if let Err(err) = control_zones(&control_state).await { tracing::error!(error=?err, "zone cycle failed"); } if let Err(err) = run_automations(&control_state).await { tracing::error!(error=?err, "automation cycle failed"); } let seconds = control_state.settings.read().await.zone_interval_seconds.max(2); let normal_delay = Duration::from_secs(seconds); let resume_delay = match next_zone_control_deadline_delay(&control_state) { Ok(value) => value, Err(err) => { tracing::warn!(error=?err, "cannot calculate thermostat control deadline"); None } }; let sleep_for = resume_delay.map(|delay| delay.min(normal_delay)).unwrap_or(normal_delay); tokio::select! { _ = sleep(sleep_for) => {}, _ = control_state.zone_control_wakeup.notified() => {}, } } }); let maintenance_state = state; tokio::spawn(async move { sleep(Duration::from_secs(60)).await; loop { let settings = maintenance_state.settings.read().await.clone(); // When InfluxDB is enabled, compact all locally retained legacy history before // transferring old buckets. Without Influx, compact only the configured retention window. let compaction_days = if settings.influxdb.enabled { 3650 } else { settings.history_retention_days.max(1) } as i64; if settings.history_compaction_enabled { match maintenance_state.db.compact_history(compaction_days) { Ok(count) if count > 0 => tracing::info!(count, "history samples compacted"), Ok(_) => {} Err(err) => tracing::warn!(error=?err, "cannot compact history"), } } if settings.influxdb.enabled { match archive_old_history(&maintenance_state, settings.influxdb.history_threshold_days.max(1)).await { Ok(count) if count > 0 => tracing::info!(count, "old local readings archived to InfluxDB and removed from SQLite"), Ok(_) => {} Err(err) => tracing::warn!(error=?err, "cannot archive old history to InfluxDB; SQLite copies were kept"), } } else { let retention_days = settings.history_retention_days.max(1) as i64; match maintenance_state.db.prune_readings(retention_days) { Ok(count) if count > 0 => tracing::info!(count, retention_days, "old local readings pruned"), Ok(_) => {} Err(err) => tracing::warn!(error=?err, "cannot prune readings"), } } let event_retention_days = settings.event_log_retention_days.max(1) as i64; match maintenance_state.db.prune_events(event_retention_days) { Ok(count) if count > 0 => tracing::info!(count, event_retention_days, "old event log rows pruned"), Ok(_) => {} Err(err) => tracing::warn!(error=?err, "cannot prune event log"), } sleep(Duration::from_secs(6 * 60 * 60)).await; } }); } async fn archive_old_history(state: &AppState, threshold_days: u32) -> Result { let cutoff = Utc::now() - chrono::Duration::days(threshold_days.max(1) as i64); let settings = state.settings.read().await.influxdb.clone(); let mut moved = 0_u64; // Bound one maintenance pass so a very large legacy database never monopolizes the runtime. // Successful batches are deleted from SQLite, so the next pass naturally continues forward. for _ in 0..50 { let (devices, zones, ha) = state.db.history_before(cutoff, 1_000)?; if devices.is_empty() && zones.is_empty() && ha.is_empty() { break; } influxdb::write_batch(&state.http, &settings, &devices, &zones, &ha).await?; let deleted = state.db.delete_history_batch(&devices, &zones, &ha)?; moved += deleted; if deleted == 0 { break; } } Ok(moved) } pub async fn send_command(state: &AppState, device_id: &str, command: DeviceCommand) -> Result { let _device_guard = state.lock_device_operation(device_id).await; send_command_locked(state, device_id, command).await } async fn send_command_locked(state: &AppState, device_id: &str, command: DeviceCommand) -> Result { send_command_locked_inner(state, device_id, command, true, true).await } async fn send_command_locked_forced(state: &AppState, device_id: &str, command: DeviceCommand) -> Result { send_command_locked_inner(state, device_id, command, false, true).await } async fn send_command_locked_inner( state: &AppState, device_id: &str, command: DeviceCommand, dedupe_against_cache: bool, track_controller_command: bool, ) -> Result { validate_command(&command)?; let mut device = state.db.get_device(device_id)? .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); } // Routine control avoids redundant frames. Explicit safety transitions (global/group OFF, // detach) may bypass cache de-duplication so stale state cannot leave a unit powered. let command = if dedupe_against_cache && device.online && device.communication_failures == 0 { command.changed_from(&device) } else { command }; if command.is_empty() { return Ok(device); } let controller_command_baseline = device.clone(); let suppress_beep = state.settings.read().await.suppress_device_beep; let response_started = Instant::now(); let mut applied_command = command.clone(); let mut confirmed_state = false; let mut confirmed_requested_state = true; if device.simulated { applied_command.apply(&mut device); confirmed_state = true; device.online = true; device.response_time_ms = Some(0); device.last_seen = Some(Utc::now()); device.last_error = None; state.db.save_device(&device)?; } else { if device.key.as_deref().unwrap_or_default().is_empty() { match state.gree.bind(&device).await { Ok(bound) => { device.key = Some(bound.key); device.protocol_version = bound.protocol_version; device.communication_failures = 0; state.db.save_device(&device)?; state.log("info", "device.bound", &format!("Bound {} using protocol V{}", device.name, device.protocol_version), json!({"device_id": device.id, "protocol_version": device.protocol_version})); } Err(err) => { register_device_failure(state, &mut device, &err.to_string())?; return Err(AppError::Device(err.to_string())); } } } match state.gree.command(&device, &command, suppress_beep).await { Ok(result) => applied_command = result, Err(first_err) => { // A lost command ACK does not mean the command was lost. Read the device // first and avoid sending the same frame (and another beep) when the requested // state is already present. Only rebind when the verification read also fails. let mut observed = device.clone(); let retry_result = match state.gree.poll(&mut observed).await { Ok(()) if command.changed_from(&observed).is_empty() => { device = observed; confirmed_state = true; tracing::debug!(device=%device.id, "GREE command ACK was uncertain, but status confirms the requested state"); Ok(command.clone()) } Ok(()) => { device = observed; let remaining = command.changed_from(&device); if remaining.is_empty() { Ok(command.clone()) } else { state.gree.command(&device, &remaining, suppress_beep).await } } Err(_) => { match state.gree.bind(&device).await { Ok(bound) => { device.key = Some(bound.key); device.protocol_version = bound.protocol_version; state.db.save_device(&device)?; state.gree.command(&device, &command, suppress_beep).await } Err(_) => Err(first_err), } } }; match retry_result { Ok(result) => applied_command = result, Err(err) => { register_device_failure(state, &mut device, &err.to_string())?; return Err(AppError::Device(err.to_string())); } } } } if command.quiet.is_some() && applied_command.quiet.is_none() { device.supports_quiet = Some(false); } if command.sleep.is_some() && applied_command.sleep.is_none() { device.supports_sleep = Some(false); } // A command ACK confirms transport/acceptance, but several GREE firmwares keep // returning the pre-command status for a short settling window. Publishing that first // stale read makes Home Assistant visibly bounce ON -> OFF -> ON. Verify a few times // with bounded backoff and only publish a differing state after the settling window. if !confirmed_state { let verification_delays_ms = [0_u64, 150, 350, 650]; let mut last_verification_error: Option = None; for delay_ms in verification_delays_ms { if delay_ms > 0 { sleep(Duration::from_millis(delay_ms)).await; } let mut observed = device.clone(); match state.gree.poll(&mut observed).await { Ok(()) => { let requested_matches = applied_command.changed_from(&observed).is_empty(); device = observed; confirmed_state = true; confirmed_requested_state = requested_matches; last_verification_error = None; if requested_matches { break; } } Err(err) => { last_verification_error = Some(err.to_string()); } } } if confirmed_state && !confirmed_requested_state { tracing::debug!(device=%device.id, command=?applied_command, "GREE command acknowledged but status still differs after settling window"); } else if !confirmed_state { let error = last_verification_error.unwrap_or_else(|| "status verification failed".into()); record_poll_failure(&mut device, &format!("command accepted but status verification failed: {error}")); state.log("warn", "device.command_unconfirmed", &format!("Command accepted by {}, but resulting state could not be verified", device.name), json!({ "device_id": device.id, "error": error })); } } if confirmed_state { device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64); } state.db.save_device(&device)?; if !dedupe_against_cache && confirmed_state && !confirmed_requested_state { if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() { remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await; } state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); return Err(AppError::Device("device did not confirm the requested forced state change".into())); } } if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() { // Keep a bounded settling history even after the requested state has already been // observed once. Several GREE modules can briefly publish an older snapshot again // and then return to the controller-requested state. Without this guard that normal // firmware bounce can be misclassified as a physical/pilot takeover. remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await; } record_device_transition_timestamps(state, &controller_command_baseline, &device)?; state.log("info", "device.command", &format!("Updated {}", device.name), json!({ "device_id": device.id, "command": applied_command, "confirmed": confirmed_state, })); state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); Ok(device) } fn record_device_transition_timestamps(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> { if before.power == after.power && before.mode == after.mode { return Ok(()); } let now = Utc::now(); for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) { if before.power != after.power { zone.last_power_change_at = Some(now); } if before.mode != after.mode { zone.last_mode_change_at = Some(now); } // Do not bump zone.updated_at here: an in-flight thermostat cycle uses that field // as its optimistic snapshot guard. The cycle mirrors these timestamps into its own // computed Zone after a successful automatic command. state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); } Ok(()) } pub async fn poll_one(state: &AppState, device_id: &str) -> Result { let _device_guard = state.lock_device_operation(device_id).await; poll_one_locked(state, device_id).await } async fn poll_one_locked(state: &AppState, device_id: &str) -> Result { let mut device = state.db.get_device(device_id)? .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; let before = device.clone(); poll_device(state, &mut device).await; if poll_completed_successfully(&device) { if state.initial_device_sync_complete.load(Ordering::Acquire) { record_device_transition_timestamps(state, &before, &device)?; } detect_external_device_control(state, &before, &device).await?; } state.db.save_device(&device)?; record_reading(state, &device)?; state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); Ok(device) } async fn poll_all(state: &AppState) -> Result<()> { let device_ids: Vec = state.db.list_devices()?.into_iter() .filter(|device| device.enabled) .map(|device| device.id) .collect(); for device_id in device_ids { let _device_guard = state.lock_device_operation(&device_id).await; let _ = poll_one_locked(state, &device_id).await?; } Ok(()) } async fn poll_device(state: &AppState, device: &mut Device) { if device.simulated { simulate_tick(device); return; } let previous_failures = device.communication_failures; let response_started = Instant::now(); if device.key.as_deref().unwrap_or_default().is_empty() { match state.gree.bind(device).await { Ok(bound) => { device.key = Some(bound.key); device.protocol_version = bound.protocol_version; device.communication_failures = 0; } Err(err) => { record_poll_failure(device, &err.to_string()); log_poll_health_transition(state, device, previous_failures).await; return; } } } if let Err(first_err) = state.gree.poll(device).await { // One lost UDP response is common on Wi-Fi and must not trigger a bind storm. // Rebind only after at least one consecutive failed poll; a successful retry clears // the counter in GreeClient::poll. if previous_failures == 0 { record_poll_failure(device, &first_err.to_string()); } else { match state.gree.bind(device).await { Ok(bound) => { device.key = Some(bound.key); device.protocol_version = bound.protocol_version; if let Err(err) = state.gree.poll(device).await { record_poll_failure(device, &err.to_string()); } } Err(_) => record_poll_failure(device, &first_err.to_string()), } } } if device.communication_failures == 0 && device.online { device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64); } log_poll_health_transition(state, device, previous_failures).await; } async fn log_poll_health_transition(state: &AppState, device: &Device, previous_failures: u8) { let threshold = state.settings.read().await.notifications.communication_failure_threshold.max(2); let current_failures = u32::from(device.communication_failures); let previous_failures = u32::from(previous_failures); if current_failures >= threshold && previous_failures < threshold { state.log("warn", "device.offline", &format!("{} did not respond {} times in a row", device.name, device.communication_failures), json!({ "device_id": device.id, "consecutive_failures": device.communication_failures, "threshold": threshold })); } else if current_failures == 0 && previous_failures >= threshold { state.log("info", "device.recovered", &format!("{} is responding again", device.name), json!({"device_id": device.id})); } } fn simulate_tick(device: &mut Device) { let mut current = device.current_temperature.unwrap_or(25.0); let minute_wave = ((Utc::now().timestamp() % 3600) as f64 / 3600.0 * std::f64::consts::TAU).sin(); let ambient = 25.5 + minute_wave * 0.35; if device.power { match device.mode.as_str() { "cool" => { let floor = device.target_temperature - 0.2; if current > floor { current -= if device.turbo { 0.25 } else { 0.12 }; } } "heat" => { let ceiling = device.target_temperature + 0.2; if current < ceiling { current += if device.turbo { 0.25 } else { 0.12 }; } } "dry" => current -= 0.04, _ => current += (ambient - current) * 0.02, } } else { current += (ambient - current) * 0.04; } device.current_temperature = Some((current * 10.0).round() / 10.0); device.outdoor_temperature = Some((30.0 + minute_wave * 1.2) * 10.0_f64.round() / 10.0); // Correct rounding for outdoor temperature without accumulating precision noise. device.outdoor_temperature = device.outdoor_temperature.map(|v| (v * 10.0).round() / 10.0); device.online = true; device.response_time_ms = Some(0); device.last_seen = Some(Utc::now()); device.last_error = None; device.updated_at = Utc::now(); } fn record_reading(state: &AppState, device: &Device) -> Result<()> { let reading = Reading { id: 0, device_id: device.id.clone(), timestamp: Utc::now(), indoor_temperature: device.current_temperature, outdoor_temperature: device.outdoor_temperature, target_temperature: device.target_temperature, power: device.power, source: if device.simulated { "simulator".into() } else { "gree".into() }, }; state.db.add_reading(&reading)?; queue_influx_device(state, reading); Ok(()) } fn record_poll_failure(device: &mut Device, error: &str) { device.communication_failures = device.communication_failures.saturating_add(1); // A single dropped UDP response is not enough to declare an AC offline. if device.communication_failures >= 3 { device.online = false; } device.last_error = Some(error.to_string()); device.updated_at = Utc::now(); } fn register_device_failure(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> { record_poll_failure(device, error); state.db.save_device(device)?; state.log("warn", "device.communication_error", &format!("{}: {error}", device.name), json!({ "device_id": device.id, "consecutive_failures": device.communication_failures, "offline": !device.online, })); Ok(()) } pub(crate) fn validate_command(command: &DeviceCommand) -> Result<(), AppError> { if let Some(value) = command.target_temperature { if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("target temperature must be between 8 and 30 C".into())); } } if let Some(value) = command.fan_speed { if value > 5 { return Err(AppError::BadRequest("fan speed must be between 0 and 5".into())); } } if let Some(value) = &command.mode { if !matches!(value.as_str(), "auto" | "cool" | "dry" | "fan" | "heat") { return Err(AppError::BadRequest("unsupported HVAC mode".into())); } } Ok(()) } fn poll_completed_successfully(device: &Device) -> bool { device.online && device.communication_failures == 0 && device.last_error.is_none() } fn command_manual_control_fields(command: &DeviceCommand) -> Vec { let mut fields = Vec::new(); if command.power.is_some() { fields.push("power".to_string()); } if command.mode.is_some() { fields.push("mode".to_string()); } if command.target_temperature.is_some() { fields.push("target_temperature".to_string()); } if command.fan_speed.is_some() { fields.push("fan_speed".to_string()); } if command.quiet.is_some() { fields.push("quiet".to_string()); } if command.sleep.is_some() { fields.push("sleep".to_string()); } fields } fn command_baseline_from_device(command: &DeviceCommand, device: &Device) -> DeviceCommand { DeviceCommand { power: command.power.map(|_| device.power), mode: command.mode.as_ref().map(|_| device.mode.clone()), target_temperature: command.target_temperature.map(|_| device.target_temperature), fan_speed: command.fan_speed.map(|_| device.fan_speed), quiet: command.quiet.map(|_| device.quiet), sleep: command.sleep.map(|_| device.sleep), ..Default::default() } } async fn remember_controller_command(state: &AppState, device_id: &str, command: &DeviceCommand, baseline_device: &Device) { let poll_seconds = state.settings.read().await.poll_interval_seconds.max(2); let ttl = Duration::from_secs(poll_seconds.saturating_mul(2).saturating_add(5).min(120)); let mut pending = state.pending_controller_commands.lock().await; let expires_at = Instant::now() + ttl; let baseline = command_baseline_from_device(command, baseline_device); if let Some(existing) = pending.get_mut(device_id) { existing.commands.push(command.clone()); existing.baselines.push(baseline); // The history only spans one settling window; cap it defensively so a noisy device // cannot grow this allocation without bound. if existing.commands.len() > 8 { existing.commands.remove(0); } if existing.baselines.len() > 8 { existing.baselines.remove(0); } existing.expires_at = expires_at; } else { pending.insert(device_id.to_string(), PendingControllerCommand { commands: vec![command.clone()], baselines: vec![baseline], expires_at, }); } } fn command_field_matches_device(command: &DeviceCommand, field: &str, device: &Device) -> bool { match field { "power" => command.power.map(|value| value == device.power).unwrap_or(false), "mode" => command.mode.as_deref().map(|value| value == device.mode.as_str()).unwrap_or(false), "target_temperature" => command.target_temperature .map(|value| value.clamp(8.0, 30.0).round() == device.target_temperature.clamp(8.0, 30.0).round()) .unwrap_or(false), "fan_speed" => command.fan_speed.map(|value| value.min(5) == device.fan_speed).unwrap_or(false), "quiet" => command.quiet.map(|value| value == device.quiet).unwrap_or(false), "sleep" => command.sleep.map(|value| value == device.sleep).unwrap_or(false), _ => false, } } async fn suppress_expected_controller_changes( state: &AppState, device: &Device, fields: Vec, ) -> Vec { if fields.is_empty() { return fields; } let mut pending = state.pending_controller_commands.lock().await; let expired = pending.get(&device.id) .map(|expected| Instant::now() > expected.expires_at) .unwrap_or(false); if expired { pending.remove(&device.id); return fields; } let Some(expected) = pending.get(&device.id).cloned() else { return fields; }; let filtered = fields.into_iter() .filter(|field| { let matches_recent_controller_state = expected.commands.iter() .chain(expected.baselines.iter()) .any(|command| command_field_matches_device(command, field, device)); !matches_recent_controller_state }) .collect(); // Do not clear the settling guard merely because one poll matched the requested state. // A later status packet can still briefly roll back to the pre-command snapshot. The // bounded TTL is what ends this ambiguity window. filtered } fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zone) -> Vec { let mut fields = Vec::new(); if before.power != after.power { fields.push("power".to_string()); } if before.mode != after.mode { fields.push("mode".to_string()); } if before.target_temperature.round() != after.target_temperature.round() { fields.push("target_temperature".to_string()); } // Some GREE units accept the controller's standby Low fan hint and later report Auto // again without user interaction. Treat that one known normalization as firmware drift, // not as a remote-control takeover. Other fan changes remain meaningful manual input. let standby_low_to_auto = zone.smart_fan && !zone.demand && before.fan_speed == 1 && after.fan_speed == 0; if before.fan_speed != after.fan_speed && !standby_low_to_auto { fields.push("fan_speed".to_string()); } fields } 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) -> 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) -> 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 restore_zone_enabled = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.restore_zone_enabled); let changed = zone.local_thermostat_power.is_some() || zone.local_thermostat_resume_at.is_some() || zone.temporary_quick_thermostat.is_some() || zone.manual_preset.is_some() || zone.manual_setpoint.is_some() || zone.manual_override_until.is_some(); zone.local_thermostat_power = None; zone.local_thermostat_resume_at = None; zone.temporary_quick_thermostat = None; zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; if let Some(enabled) = restore_zone_enabled { zone.enabled = enabled; } changed } fn temporary_quick_thermostat_hard_deadline(session: &TemporaryQuickThermostat) -> Option> { match (session.expires_at.clone(), session.safety_expires_at.clone()) { (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.clone().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()?; 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, AppError> { let now = Utc::now(); let mut restored_disabled_zones = Vec::new(); for zone in zones.iter_mut() { let Some(session) = zone.temporary_quick_thermostat.as_ref() else { continue; }; let Some(deadline) = temporary_quick_thermostat_hard_deadline(session) else { continue; }; if deadline > now { continue; } let finish_kind = session.finish_kind.clone(); 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": 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; let should_stop = state.db.get_zone(&zone.id).ok().flatten() .map(|latest| !latest.enabled && 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 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) -> Option { 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))?; let session = zone.temporary_quick_thermostat.as_mut()?; 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(now.clone()).clone(); if session.hold_seconds == 0 || now.signed_duration_since(started).num_seconds() >= session.hold_seconds as i64 { return Some("temperature_stable".into()); } } _ => {} } None } 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() { // 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. 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)?); state.log("info", "zone.local_thermostat_resume_scheduled", &format!("Local thermostat hand-back scheduled for {}", zone.name), json!({ "zone_id": zone.id, "device_id": zone.device_id, "delay_minutes": LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES })); 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(()) } fn next_zone_control_deadline_delay(state: &AppState) -> Result, AppError> { let now = Utc::now(); 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 = temporary_quick_thermostat_wakeup_at(&zone, now.clone()); match (local, temporary) { (Some(a), Some(b)) => Some(a.min(b)), (Some(a), None) => Some(a), (None, Some(b)) => Some(b), (None, None) => None, } }) .map(|at| (at - now.clone()).to_std().unwrap_or(Duration::ZERO)) .min()) } pub fn refresh_control_ownership(zone: &mut Zone, house_power_enabled: bool, blocked_by_group: bool) { let now = Utc::now(); let (owner, source, resume_at, reason) = if !house_power_enabled { ("global_off", "global".to_string(), None, "Whole-house power is disabled".to_string()) } else if zone.device_manual_override { let source = match zone.control_source.as_str() { "home_assistant_direct" | "web_direct" | "external" => zone.control_source.clone(), _ => "external".into(), }; ("direct_manual", source, zone.device_manual_override_until, "Direct/manual device control has priority".to_string()) } else if zone.local_thermostat_power.is_some() { let source = match zone.control_source.as_str() { "home_assistant_thermostat" | "web_thermostat" => zone.control_source.clone(), _ => "local_thermostat".into(), }; let resume_at = zone.temporary_quick_thermostat.as_ref() .and_then(temporary_quick_thermostat_next_deadline) .or(zone.local_thermostat_resume_at.clone()); let reason = if zone.local_thermostat_power == Some(false) { "Local thermostat is explicitly off".into() } else if zone.temporary_quick_thermostat.is_some() { "Temporary Quick Thermostat owns the zone".into() } else { "Local thermostat owns the zone".into() }; ("local_thermostat", source, resume_at, reason) } else if blocked_by_group { ("automation", "group".to_string(), None, "Zone is blocked by a disabled group".to_string()) } else { ("automation", "automation".to_string(), zone.manual_override_until, "Automatic thermostat/schedule control".to_string()) }; if zone.control_owner != owner || zone.control_source != source { zone.control_since = Some(now); } else if zone.control_since.is_none() { zone.control_since = Some(now); } zone.control_owner = owner.into(); zone.control_source = source; zone.control_resume_at = resume_at; zone.control_reason = reason; } fn normalized_direct_source(source: &str) -> &'static str { if source.contains("home_assistant") { "home_assistant_direct" } else if source == "device.manual_control" { "web_direct" } else { "external" } } pub fn reset_device_manual_override(zone: &mut Zone) -> bool { let changed = zone.device_manual_override || zone.device_manual_override_since.is_some() || zone.device_manual_override_until.is_some() || !zone.device_manual_override_fields.is_empty() || zone.device_manual_override_baseline.is_some(); zone.device_manual_override = false; zone.device_manual_override_since = None; zone.device_manual_override_until = None; zone.device_manual_override_fields.clear(); zone.device_manual_override_baseline = None; if zone.control_owner == "direct_manual" { zone.control_owner = "automation".into(); zone.control_source = "automation".into(); zone.control_since = Some(Utc::now()); zone.control_resume_at = None; zone.control_reason = "Manual takeover cleared; automation may resume".into(); } changed } fn manual_override_matches_baseline(zone: &Zone, device: &Device) -> bool { let Some(baseline) = zone.device_manual_override_baseline.as_ref() else { return false; }; if zone.device_manual_override_fields.is_empty() { return false; } // If the unit was OFF before takeover, returning it to OFF is operationally the same // controller state even if the remote retained a different mode/target internally. // Those dormant values will be set explicitly if automation later powers the unit. if !baseline.power { return !device.power; } zone.device_manual_override_fields.iter().all(|field| match field.as_str() { "power" => device.power == baseline.power, "mode" => device.mode == baseline.mode, "target_temperature" => device.target_temperature.round() == baseline.target_temperature.round(), "fan_speed" => device.fan_speed == baseline.fan_speed, "quiet" => device.quiet == baseline.quiet, "sleep" => device.sleep == baseline.sleep, _ => false, }) } fn persist_manual_override_clear(state: &AppState, zone: &mut Zone, source: &str, restored: bool) -> Result { if !reset_device_manual_override(zone) { return Ok(false); } 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 { ("zone.device_manual_override_restored", format!("Manual device control returned {} to its previous state", zone.name)) } else { ("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, "local_thermostat_resume_rearmed": local_resume_rearmed, "local_thermostat_resume_at": zone.local_thermostat_resume_at, })); Ok(true) } fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec, source: &str, baseline: &Device) -> Result<(), AppError> { if fields.is_empty() { return Ok(()); } let now = Utc::now(); if !zone.device_manual_override { zone.device_manual_override_since = Some(now); zone.device_manual_override_baseline = Some(baseline.into()); zone.control_since = Some(now); } zone.device_manual_override = true; zone.control_owner = "direct_manual".into(); zone.control_source = normalized_direct_source(source).into(); zone.control_reason = "Direct/manual device control has priority".into(); zone.device_manual_override_until = if zone.enabled { next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now()) } else { None }; zone.control_resume_at = zone.device_manual_override_until; for field in fields { if !zone.device_manual_override_fields.iter().any(|existing| existing == &field) { zone.device_manual_override_fields.push(field); } } zone.demand = false; zone.demand_since = None; zone.updated_at = now; state.db.save_zone(zone)?; state.broadcast("zone.updated", serde_json::to_value(&*zone)?); state.log("info", "zone.device_manual_override", &format!("Manual device control detected for {}", zone.name), json!({ "zone_id": zone.id, "device_id": zone.device_id, "fields": zone.device_manual_override_fields, "source": source, "override_until": zone.device_manual_override_until, })); Ok(()) } async fn detect_external_device_control(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> { if before.id != after.id { return Ok(()); } for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) { let fields = suppress_expected_controller_changes( state, after, externally_changed_control_fields(before, after, &zone), ).await; if zone.device_manual_override && manual_override_matches_baseline(&zone, after) { persist_manual_override_clear(state, &mut zone, "gree_poll", true)?; continue; } if fields.is_empty() { continue; } // A disabled zone is outside controller ownership. When its manually operated unit is // switched off there is no takeover left to display or remember. if !zone.enabled && !after.power { persist_manual_override_clear(state, &mut zone, "gree_poll", false)?; continue; } set_device_manual_override(state, &mut zone, fields, "gree_poll", before)?; } Ok(()) } pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result { // Keep zone -> device lock ordering consistent with Quick Thermostat/full-zone edits. // A device belongs to at most one thermostat zone, but keep this generic for legacy data. let zone_ids: Vec = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).map(|zone| zone.id).collect(); let mut _zone_guards = Vec::new(); for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); } // Keep the device lock until the zone takeover marker is persisted. Otherwise a poll // could observe our own just-sent command before the controller records manual ownership. let _device_guard = state.lock_device_operation(device_id).await; let before = state.db.get_device(device_id)? .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; let effective_command = if before.online && before.communication_failures == 0 { command.changed_from(&before) } else { command.clone() }; let fields = command_manual_control_fields(&effective_command); let updated = send_command_locked_inner(state, device_id, command, true, false).await?; if !fields.is_empty() { for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) { if zone.device_manual_override && manual_override_matches_baseline(&zone, &updated) { persist_manual_override_clear(state, &mut zone, source, true)?; continue; } if !zone.enabled && !updated.power { persist_manual_override_clear(state, &mut zone, source, false)?; continue; } set_device_manual_override(state, &mut zone, fields.clone(), source, &before)?; } } Ok(updated) } pub async fn force_house_power_off_device(state: &AppState, device_id: &str, source: &str) -> Result { // Global OFF is a one-shot authority transition. Clear takeover and send OFF while // polling for this unit is excluded; a later remote change happens after the lock and // is therefore preserved as a new manual takeover. let _device_guard = state.lock_device_operation(device_id).await; for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) { if !reset_device_manual_override(&mut zone) { continue; } zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); state.log("info", "zone.device_manual_override_cleared", &format!("Automation resumed for {}", zone.name), json!({ "zone_id": zone.id, "device_id": zone.device_id, "source": source })); } send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await } pub async fn force_power_off_device(state: &AppState, device_id: &str) -> Result { let _device_guard = state.lock_device_operation(device_id).await; send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await } pub fn clear_all_device_manual_overrides(state: &AppState, source: &str) -> Result { let mut cleared = 0usize; for mut zone in state.db.list_zones()? { if !reset_device_manual_override(&mut zone) { continue; } zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); state.log("info", "zone.device_manual_override_cleared", &format!("Automation resumed for {}", zone.name), json!({ "zone_id": zone.id, "device_id": zone.device_id, "source": source })); cleared += 1; } Ok(cleared) } pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControlPatch, source: &str) -> Result { if let Some(mode) = patch.mode.as_deref() { if !matches!(mode, "house" | "auto" | "cool" | "heat") { return Err(AppError::BadRequest("group mode must be house, cool or heat".into())); } } if let Some(preset) = patch.preset.as_deref() { if !matches!(preset, "auto" | "comfort" | "sleep" | "away") { return Err(AppError::BadRequest("group preset must be auto, comfort, sleep or away".into())); } } let mut group = state.db.get_group(group_id)? .ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?; let schedules = state.db.list_schedules()?; let activates_group = patch.mode.is_some() || patch.preset.is_some(); if let Some(power) = patch.power { group.power_enabled = power; } else if activates_group { group.power_enabled = true; } group.updated_at = Utc::now(); state.db.save_group(&group)?; state.broadcast("group.updated", serde_json::to_value(&group)?); // Explicit group ON is a conscious request to run this group. Resume the global // master without changing the gates of any other groups. This makes group ON work // even after a previous whole-house OFF while preserving multi-group OFF priority. if patch.power == Some(true) { let mut settings = state.settings.write().await; if !settings.house_power_enabled { settings.house_power_enabled = true; state.db.save_runtime_settings(&settings)?; state.broadcast("house.power_changed", json!({"house_power_enabled": true})); state.log("info", "house.power_resumed_by_group", &format!("Whole-house master resumed by group {}", group.name), json!({ "group_id": group.id, "source": source })); } } let mut zones = Vec::new(); for zone_id in &group.zone_ids { let _zone_guard = state.lock_zone_operation(zone_id).await; 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; }; 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" => { zone.inherit_house_mode = false; zone.mode = mode.to_string(); } _ => {} } } if let Some(preset) = patch.preset.as_deref().filter(|_| !temporary_owns_zone) { if preset == "auto" { zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; } else { zone.manual_preset = Some(preset.to_string()); zone.manual_setpoint = None; 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)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); zones.push(zone); } let runtime = state.settings.read().await.clone(); let master_power_enabled = runtime.house_power_enabled; let should_command_power = patch.power.is_some() || activates_group; let desired_power = group.power_enabled; // A zone may intentionally belong to more than one group. Power-off is authoritative: // turning one group on must never briefly wake a member that is still blocked by another group. let mut failed = Vec::new(); if should_command_power && (!desired_power || master_power_enabled) { let mut seen = std::collections::HashSet::new(); for zone in &zones { if !seen.insert(zone.device_id.clone()) { continue; } // Re-check manual takeover and all group gates only after acquiring the // per-device lock, so a pilot event detected by polling cannot be overwritten. let Some(device) = state.db.get_device(&zone.device_id)? else { continue; }; if !device.enabled { continue; } match send_group_power_if_current(state, &group.id, &zone.id, &device.id, desired_power).await { Ok(_) => {} Err(err) => { state.log("error", "group.power_error", &err.to_string(), json!({ "group_id": group.id, "device_id": device.id, "device_name": device.name, "power": desired_power, "source": source, })); failed.push(json!({"device_id": device.id, "device_name": device.name, "error": err.to_string()})); } } } } if desired_power && should_command_power { state.wake_zone_control(); } state.log("info", source, &format!("Updated group {}", group.name), json!({ "group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset, "zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled, })); Ok(json!({ "group": group, "zones": zones, "devices": state.db.list_devices()?, "failed": failed, "master_power_enabled": master_power_enabled, })) } fn persist_zone_cycle(state: &AppState, computed: &Zone, cycle_started_at: DateTime) -> Result { let Some(mut latest) = state.db.get_zone(&computed.id)? else { return Ok(computed.clone()); }; if latest.updated_at <= cycle_started_at { state.db.save_zone(computed)?; return Ok(computed.clone()); } // Another actor changed this zone while the regulator was doing network I/O. Never // write the old controller snapshot over fresh configuration or manual takeover state. // Sensor observations are safe to carry forward only while the device assignment matches. if latest.device_id == computed.device_id { latest.device_temperature = computed.device_temperature; latest.external_temperature = computed.external_temperature; latest.current_temperature = computed.current_temperature; latest.control_temperature_source = computed.control_temperature_source.clone(); latest.updated_at = Utc::now(); state.db.save_zone(&latest)?; } Ok(latest) } async fn thermostat_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result { if !state.settings.read().await.house_power_enabled { return Ok(false); } let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); }; if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power == Some(false) { return Ok(false); } if zone.local_thermostat_power == Some(true) { return Ok(true); } let blocked = state.db.list_groups()?.iter().any(|group| { !group.power_enabled && group.zone_ids.iter().any(|member| member == zone_id) }); Ok(!blocked) } async fn group_off_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result { if !state.settings.read().await.house_power_enabled { return Ok(false); } let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); }; if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { return Ok(false); } Ok(state.db.list_groups()?.iter().any(|group| { !group.power_enabled && group.zone_ids.iter().any(|member| member == zone_id) })) } async fn send_zone_command_if_owned( state: &AppState, zone_id: &str, device_id: &str, command: DeviceCommand, require_group_block: bool, ) -> Result, AppError> { // Ownership must be checked after acquiring the same per-device lock used by polling. // Otherwise polling could detect a remote takeover while this task is waiting for the lock, // and a stale thermostat decision would still be sent immediately afterwards. let _device_guard = state.lock_device_operation(device_id).await; let owned = if require_group_block { group_off_ownership_is_current(state, zone_id, device_id).await? } else { thermostat_ownership_is_current(state, zone_id, device_id).await? }; if !owned { return Ok(None); } send_command_locked(state, device_id, command).await.map(Some) } async fn send_group_power_if_current( state: &AppState, group_id: &str, zone_id: &str, device_id: &str, desired_power: bool, ) -> Result, AppError> { let _device_guard = state.lock_device_operation(device_id).await; let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(None); }; if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { return Ok(None); } let groups = state.db.list_groups()?; let Some(group) = groups.iter().find(|group| group.id == group_id) else { return Ok(None); }; if group.power_enabled != desired_power || !group.zone_ids.iter().any(|member| member == zone_id) { return Ok(None); } if desired_power { let settings = state.settings.read().await; if !settings.house_power_enabled { return Ok(None); } let effective_mode = if zone.inherit_house_mode { settings.house_mode.as_str() } else { zone.mode.as_str() }; if effective_mode == "off" { return Ok(None); } if groups.iter().any(|other| { other.id != group_id && !other.power_enabled && other.zone_ids.iter().any(|member| member == zone_id) }) { return Ok(None); } } let Some(device) = state.db.get_device(device_id)? else { return Ok(None); }; if !device.enabled { return Ok(None); } let command = DeviceCommand { power: Some(desired_power), ..Default::default() }; if desired_power { send_command_locked(state, device_id, command).await.map(Some) } else { // A deliberate group OFF is a one-shot safety transition. Send it even if the // cached state already says OFF; the regulator itself will not keep repeating it. send_command_locked_forced(state, device_id, command).await.map(Some) } } async fn send_automatic_device_command_if_owned( state: &AppState, device_id: &str, command: DeviceCommand, ) -> Result, AppError> { let _device_guard = state.lock_device_operation(device_id).await; if !state.settings.read().await.house_power_enabled { return Ok(None); } let zones = state.db.list_zones()?; if device_blocked_by_disabled_zone(device_id, &zones) || device_blocked_by_manual_override(device_id, &zones) || device_blocked_by_local_thermostat(device_id, &zones) || device_blocked_by_disabled_group(device_id, &zones, &state.db.list_groups()?) { return Ok(None); } send_command_locked(state, device_id, command).await.map(Some) } async fn control_zones(state: &AppState) -> Result<()> { let schedules = state.db.list_schedules()?; let groups = state.db.list_groups()?; let settings = state.settings.read().await.clone(); let mut zone_snapshot = state.db.list_zones()?; // Local quick-thermostat OFF is intentionally temporary. Expire the ownership marker // before the house-power early return so the hand-back still happens while the master // 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 // outdoor sensors reported by GREE units when HA is temporarily unavailable. let device_snapshot = state.db.list_devices()?; let configured_outdoor = settings.home_assistant.outdoor_entity_id.trim(); let resolved_outdoor = if configured_outdoor.is_empty() { None } else { home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured_outdoor)) }; let ha_outdoor_temperature = if let Some(entity_id) = resolved_outdoor.as_deref() { match home_assistant::read_temperature(&state.http, &settings.home_assistant, Some(entity_id), Some(settings.home_assistant.sensor_stale_after_seconds)).await { Ok(value) => { record_ha_history( state, entity_id, None, "outdoor", value, settings.poll_interval_seconds, ); Some(value) } Err(err) => { tracing::debug!(configured_entity=%configured_outdoor, resolved_entity=%entity_id, error=?err, "outdoor Home Assistant sensor unavailable; trying GREE fallback"); None } } } else { None }; let outdoor_temperature = ha_outdoor_temperature.or_else(|| gree_outdoor_temperature(&device_snapshot)); { let mut current = state.outdoor_temperature.write().await; if *current != outdoor_temperature { *current = outdoor_temperature; state.broadcast("outdoor.updated", json!({"temperature": outdoor_temperature})); } } let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None }; let night_active = night_mode_active(&settings.night_mode, Local::now().time()); if !settings.house_power_enabled { // Whole-house OFF is a one-shot action performed by the API endpoint. While the // master remains off the regulator stays passive. A later physical/remote change // is therefore detected as manual takeover and is not erased or forced OFF again. return Ok(()); } // Read all per-zone Home Assistant sensors concurrently. A down HA instance should cost // one request timeout per cycle, not one timeout multiplied by the number of zones. let room_sensor_reads = futures_util::future::join_all(zone_snapshot.iter().filter_map(|zone| { if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { return None; } let zone_id = zone.id.clone(); let resolved_entity = home_assistant::resolve_entity_id(&settings.home_assistant, zone.ha_entity_id.as_deref()); let http = &state.http; let ha_settings = &settings.home_assistant; let stale_after_seconds = effective_sensor_stale_after_seconds(zone.sensor_stale_after_seconds, ha_settings.sensor_stale_after_seconds); Some(async move { let result = home_assistant::read_temperature(http, ha_settings, resolved_entity.as_deref(), Some(stale_after_seconds)).await .map_err(|err| err.to_string()); (zone_id, resolved_entity, result) }) })).await; let mut room_sensor_results: HashMap, Result)> = room_sensor_reads.into_iter() .map(|(zone_id, entity_id, result)| (zone_id, (entity_id, result))) .collect(); for mut zone in zone_snapshot { let cycle_started_at = zone.updated_at; if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) { zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; } if zone.device_manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) { reset_device_manual_override(&mut zone); state.log("info", "zone.device_manual_override_expired", &format!("Manual device control expired for {} at schedule transition", zone.name), json!({ "zone_id": zone.id, "device_id": zone.device_id })); } let Some(device) = state.db.get_device(&zone.device_id)? else { state.log("error", "zone.device_missing", &format!("Zone {} has no device", zone.name), json!({"zone_id": zone.id})); continue; }; // House "off" means the smart thermostat does not control inherited zones. // A zone explicitly switched to heat/cool remains independent and may still run. // 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)); refresh_control_ownership(&mut zone, settings.house_power_enabled, ownership_blocked_by_group); let effective_mode = effective_mode_owned.as_str(); let previous_source = zone.control_temperature_source.clone(); // Never feed the thermostat a cached GREE temperature after any communication // failure. External HA sensors may still keep a zone operational when configured. let device_temperature = if device.enabled && device.online && device.communication_failures == 0 { device.current_temperature } else { None }; let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { match room_sensor_results.remove(&zone.id) { Some((resolved_entity, Ok(value))) => { if let Some(entity_id) = resolved_entity.as_deref() { record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds); } Some(value) } Some((resolved_entity, Err(err))) => { if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") { state.log("warn", "ha.sensor_error", &err, json!({ "zone_id": zone.id, "configured_entity_id": zone.ha_entity_id.as_deref(), "resolved_entity_id": resolved_entity, })); } None } None => None, } } else { None }; let (temperature, control_source, discrepancy) = select_zone_temperature(&zone, device_temperature, external_temperature); zone.device_temperature = device_temperature; zone.external_temperature = external_temperature; zone.current_temperature = temperature; zone.control_temperature_source = control_source; zone.updated_at = Utc::now(); let condition_now = zone.updated_at.clone(); if let Some(reason) = evaluate_temporary_quick_thermostat_condition(&mut zone, condition_now) { let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default(); reset_local_thermostat_override(&mut zone); refresh_zone_runtime_target(&mut zone, &schedules, &settings.house_mode); record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); if persisted_zone.temporary_quick_thermostat.is_none() { ensure_device_off_after_temporary_disabled_restore(state, &persisted_zone, &device).await; 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": reason })); } state.wake_zone_control(); continue; } // A disabled thermostat zone is completely outside normal controller ownership. // Keep its sensors fresh, but do not let group state, schedules or thermostat // modulation touch the unit. Manual control from the technical Devices view may // therefore remain active until the zone is explicitly enabled again. if !zone.enabled { if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) { ensure_device_off_after_temporary_disabled_restore(state, &zone, &device).await; } zone.demand = false; zone.demand_since = None; record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; } // A technically disabled device is outside thermostat ownership. Do not create // repeated command errors while keeping any available external sensor data visible. if !device.enabled { zone.demand = false; zone.demand_since = None; zone.device_setpoint = None; record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; } // A physical/manual takeover has higher priority than thermostat, schedule, group and // automation control. Continue sensor/history updates, but reflect the unit's real state // instead of sending corrective frames that would fight the person holding the remote. if zone.device_manual_override { // Manual/remote takeover pauses commands, but it must not erase the thermostat's // selected profile/target. Keep the intended target visible and report the physical // unit target separately through device_setpoint. This makes Resume/Profile actions // deterministic and avoids a standby device target (for example 25 C) masquerading // as the zone's Sleep/Comfort target. let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode }; let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now()); let (preset, target) = resolve_zone_target(&zone, active_schedule, target_mode); zone.active_preset = preset; zone.effective_setpoint = Some(target); // Keep effective_mode's existing meaning during takeover: it reflects the physical // unit, while effective_setpoint above remains the thermostat intent. zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() }; zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None }; zone.demand = false; zone.demand_since = None; zone.target_alerted_at = None; record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; } if zone.local_thermostat_power == Some(false) { zone.effective_mode = "off".into(); zone.demand = false; zone.demand_since = None; zone.device_setpoint = None; if device.online && device.communication_failures == 0 && device.power { let _device_guard = state.lock_device_operation(&zone.device_id).await; let latest = state.db.get_zone(&zone.id)?; if latest.as_ref().map(|item| item.local_thermostat_power == Some(false) && !item.device_manual_override).unwrap_or(false) { if let Err(err) = send_command_locked( state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }, ).await { state.log("error", "zone.local_power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id})); } } } record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; } let blocked_by_group = ownership_blocked_by_group; if blocked_by_group { zone.effective_mode = "off".into(); zone.demand = false; zone.demand_since = None; zone.device_setpoint = None; if device.online && device.communication_failures == 0 && device.power { match send_zone_command_if_owned( state, &zone.id, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }, true, ).await { Ok(_) => {} Err(err) => state.log("error", "group.power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id})), } } record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; } if discrepancy && previous_source != "device_discrepancy_fallback" { state.log("warn", "zone.sensor_discrepancy", &format!("Zone {} sensors differ by more than {:.1} C; using GREE sensor", zone.name, zone.max_sensor_difference), json!({ "zone_id": zone.id, "device_temperature": zone.device_temperature, "external_temperature": zone.external_temperature, "max_difference": zone.max_sensor_difference, "entity_id": zone.ha_entity_id.as_deref(), })); } // House "off" is a no-control state, not a power-off command. Keep polling and // publishing the zone, but never overwrite manual device state while it follows // the house mode. Explicit per-zone heat/cool bypasses this branch above. if effective_mode == "off" { zone.effective_setpoint = None; zone.device_setpoint = None; zone.demand = false; zone.demand_since = None; zone.target_alerted_at = None; record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; } let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now()); let (preset, target) = resolve_zone_target(&zone, active_schedule, effective_mode); zone.active_preset = preset; zone.effective_setpoint = Some(target); let Some(temp) = temperature else { record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; }; let half = zone.hysteresis.max(0.1) / 2.0; let previous_demand = zone.demand; zone.demand = match effective_mode { "heat" => { if temp <= target - half { true } else if temp >= target + half { false } else { zone.demand } } _ => { if temp >= target + half { true } else if temp <= target - half { false } else { zone.demand } } }; if zone.demand && !previous_demand { zone.demand_since = Some(Utc::now()); zone.target_alerted_at = None; } else if !zone.demand { zone.demand_since = None; zone.target_alerted_at = None; } if zone.demand && zone.target_alerted_at.is_none() { let timeout_minutes = settings.notifications.target_timeout_minutes.max(5) as i64; if let Some(since) = zone.demand_since { if (Utc::now() - since).num_minutes() >= timeout_minutes { state.log("warn", "zone.target_timeout", &format!("Zone {} has not reached {:.1} C within {} minutes", zone.name, target, timeout_minutes), json!({ "zone_id": zone.id, "room_temperature": temp, "target_temperature": target, "minutes": timeout_minutes })); zone.target_alerted_at = Some(Utc::now()); } } } // Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor // stop naturally when we move the target to the satisfied side of room temperature. let outdoor_assist = outdoor_assist_offset(effective_mode, outdoor_assist_temperature, temp, target); // When an independent room sensor is actually driving cooling, the indoor unit's // own sensor can satisfy too early. Apply a half-degree pre-rounding bias: because // GREE setpoints are sent as whole degrees, this selects the next lower whole-degree // target (0.5-1.0 C below the room target). Do not stack it with outdoor assist and // do not use it during device/fallback control. let room_sensor_assist = external_room_sensor_cooling_assist(effective_mode, &zone.control_temperature_source); let demand_assist = outdoor_assist.max(room_sensor_assist); let active_target = match effective_mode { "heat" => target + outdoor_assist, _ => target - demand_assist, }; let standby_target = match effective_mode { "heat" => target - zone.standby_offset_c.max(0.5), _ => target + zone.standby_offset_c.max(0.5), }; let desired_device_target = round_device_setpoint(effective_mode, zone.demand, if zone.demand { active_target } else { standby_target }); // Report only the last confirmed device state here. The desired target belongs to // effective_setpoint/command planning until a device command succeeds. zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None }; let demand_changed = previous_demand != zone.demand; let desired_fan = if night_active { let max_fan = settings.night_mode.max_fan_speed.clamp(1, 5); if zone.smart_fan { Some(night_limited_fan_speed( smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand), max_fan, )) } else if device.fan_speed == 0 || device.fan_speed > max_fan { Some(max_fan) } else { Some(device.fan_speed) } } else if zone.smart_fan { Some(smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand)) } else { None }; // When the room becomes satisfied, ask compatible units for Quiet in the same // frame as the standby setpoint and Low fan. When demand returns, disable Quiet // on the normal smart-fan transition. When scheduled night mode owns Quiet, it // explicitly enables it inside the window and releases it outside the window. let desired_quiet = smart_quiet_command( zone.smart_fan, state.gree.quiet_command_supported(&device.id), previous_demand, zone.demand, device.quiet, settings.night_mode.enabled, night_active, settings.night_mode.force_quiet, ); let desired_sleep = native_sleep_command( settings.night_mode.enabled, night_active, settings.night_mode.use_native_sleep, device.supports_sleep == Some(true) && state.gree.sleep_command_supported(&device.id), device.sleep, ); // Compressor protection for automatic ownership. Direct/manual commands and global safety OFF // deliberately bypass this path, while the thermostat never performs an immediate Heat<->Cool swap. let now = Utc::now(); if zone.lockout_until.map(|until| until <= now).unwrap_or(false) { zone.lockout_until = None; zone.lockout_reason = None; } if device.power && device.mode != effective_mode { let min_on = chrono::Duration::seconds(zone.min_on_seconds as i64); if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_on).unwrap_or(false) { let until = zone.last_power_change_at.map(|at| at + min_on); zone.lockout_until = until; zone.lockout_reason = Some("minimum_on_before_mode_change".into()); record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; } match send_zone_command_if_owned(state, &zone.id, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }, false).await { Ok(Some(_)) => { zone.last_power_change_at = Some(now); zone.lockout_until = Some(now + chrono::Duration::seconds(zone.min_off_seconds as i64)); zone.lockout_reason = Some("mode_change_off_delay".into()); state.log("info", "zone.mode_change_lockout", &format!("Zone {} switched off before {} mode", zone.name, effective_mode), json!({"zone_id": zone.id, "resume_at": zone.lockout_until})); } Ok(None) => {} Err(err) => state.log("error", "zone.mode_change_off_error", &err.to_string(), json!({"zone_id": zone.id})), } record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; } if !device.power { let min_off = chrono::Duration::seconds(zone.min_off_seconds as i64); if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_off).unwrap_or(false) { zone.lockout_until = zone.last_power_change_at.map(|at| at + min_off); zone.lockout_reason = Some("minimum_off_before_start".into()); record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; } } let core_needs_command = !device.power || (device.target_temperature - desired_device_target).abs() >= 0.5; // In normal standby, Low fan is a transition hint rather than a state that should // be reasserted forever. Some GREE firmwares accept the frame but later report Auto // again; retrying every min_adjust_seconds only causes needless command beeps. let fan_needs_command = desired_fan .map(|fan| fan != device.fan_speed) .unwrap_or(false) && (zone.demand || demand_changed || core_needs_command || night_active); let needs_command = core_needs_command || fan_needs_command || desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false) || desired_sleep.map(|sleep| sleep != device.sleep).unwrap_or(false); let urgent_start = !device.power; if needs_command && (urgent_start || adjustment_allowed(&zone)) { let command = DeviceCommand { power: Some(true), mode: Some(effective_mode.to_string()), target_temperature: Some(desired_device_target), fan_speed: if fan_needs_command { desired_fan } else { None }, quiet: desired_quiet, sleep: desired_sleep, ..Default::default() }; match send_zone_command_if_owned(state, &zone.id, &zone.device_id, command, false).await { Ok(Some(updated_device)) => { let transition_at = Utc::now(); if device.power != updated_device.power { zone.last_power_change_at = Some(transition_at); } if device.mode != updated_device.mode { zone.last_mode_change_at = Some(transition_at); } zone.device_setpoint = if updated_device.power { Some(updated_device.target_temperature) } else { None }; zone.last_action_at = Some(transition_at); state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({ "zone_id": zone.id, "room_temperature": temp, "comfort_target": target, "device_target": desired_device_target, "mode": effective_mode, "preset": zone.active_preset, "outdoor_temperature": outdoor_temperature, "fan_speed": updated_device.fan_speed, "quiet": updated_device.quiet, "sleep": updated_device.sleep, "night_mode": night_active, })); } Ok(None) => { record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; } Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})), } } record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); } Ok(()) } fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Option, poll_interval_seconds: u64) { let device = match state.db.get_device(&zone.device_id) { Ok(Some(device)) => device, Ok(None) => return, Err(err) => { tracing::warn!(error=?err, zone_id=%zone.id, "cannot load device for zone history"); return; } }; let reading = ZoneReading { id: 0, zone_id: zone.id.clone(), device_id: zone.device_id.clone(), timestamp: Utc::now(), gree_temperature: zone.device_temperature.or(device.current_temperature), external_temperature: zone.external_temperature, control_temperature: zone.current_temperature.or(zone.device_temperature).or(device.current_temperature), target_temperature: zone.effective_setpoint.or(zone.manual_setpoint).or(Some(zone.setpoint)), device_setpoint: zone.device_setpoint.or(Some(device.target_temperature)), outdoor_temperature: outdoor_temperature.or(device.outdoor_temperature), power: device.power, mode: if zone.effective_mode.is_empty() { device.mode.clone() } else { zone.effective_mode.clone() }, fan_speed: device.fan_speed, demand: zone.demand, control_source: zone.control_temperature_source.clone(), active_preset: zone.active_preset.clone(), }; let interval = poll_interval_seconds.max(15) as i64; match state.db.add_zone_reading_if_due(&reading, interval) { Ok(true) => queue_influx_zone(state, reading), Ok(false) => {} Err(err) => tracing::warn!(error=?err, zone_id=%zone.id, "cannot save zone history sample"), } } fn record_ha_history( state: &AppState, entity_id: &str, zone_id: Option<&str>, kind: &str, temperature: f64, poll_interval_seconds: u64, ) { let reading = HaReading { id: 0, entity_id: entity_id.to_string(), zone_id: zone_id.map(str::to_string), kind: kind.to_string(), timestamp: Utc::now(), temperature, }; let interval = poll_interval_seconds.max(15) as i64; match state.db.add_ha_reading_if_due(&reading, interval) { Ok(true) => queue_influx_ha(state, reading), Ok(false) => {} Err(err) => tracing::warn!(error=?err, entity_id=%entity_id, "cannot save Home Assistant history sample"), } } fn queue_influx_device(state: &AppState, reading: Reading) { let state = state.clone(); tokio::spawn(async move { let settings = state.settings.read().await.influxdb.clone(); if !settings.enabled { return; } if let Err(err) = influxdb::write_device(&state.http, &settings, &reading).await { tracing::warn!(error=?err, device_id=%reading.device_id, "cannot write device metric to InfluxDB"); } }); } fn queue_influx_zone(state: &AppState, reading: ZoneReading) { let state = state.clone(); tokio::spawn(async move { let settings = state.settings.read().await.influxdb.clone(); if !settings.enabled { return; } if let Err(err) = influxdb::write_zone(&state.http, &settings, &reading).await { tracing::warn!(error=?err, zone_id=%reading.zone_id, "cannot write zone metric to InfluxDB"); } }); } fn queue_influx_ha(state: &AppState, reading: HaReading) { let state = state.clone(); tokio::spawn(async move { let settings = state.settings.read().await.influxdb.clone(); if !settings.enabled { return; } if let Err(err) = influxdb::write_ha(&state.http, &settings, &reading).await { tracing::warn!(error=?err, entity_id=%reading.entity_id, "cannot write HA metric to InfluxDB"); } }); } fn gree_outdoor_temperature(devices: &[Device]) -> Option { let mut values: Vec = devices.iter() .filter(|device| device.enabled && device.online && device.communication_failures == 0) .filter_map(|device| device.outdoor_temperature) .filter(|value| value.is_finite() && (-60.0..=70.0).contains(value)) .collect(); if values.is_empty() { return None; } values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let middle = values.len() / 2; let value = if values.len() % 2 == 0 { (values[middle - 1] + values[middle]) / 2.0 } else { values[middle] }; Some((value * 10.0).round() / 10.0) } fn select_zone_temperature(zone: &Zone, device_temperature: Option, external_temperature: Option) -> (Option, String, bool) { match zone.sensor_source.as_str() { "home_assistant" => match (external_temperature, device_temperature) { (Some(value), _) => (Some(value), "external".into(), false), (None, Some(value)) => (Some(value), "device_fallback".into(), false), (None, None) => (None, "unavailable".into(), false), }, "combined" => match (device_temperature, external_temperature) { (Some(device), Some(external)) => { if (device - external).abs() > zone.max_sensor_difference.max(0.1) { (Some(device), "device_discrepancy_fallback".into(), true) } else { let external_weight = zone.external_sensor_weight.clamp(0.0, 1.0); let value = device * (1.0 - external_weight) + external * external_weight; (Some((value * 10.0).round() / 10.0), "combined".into(), false) } } (Some(value), None) => (Some(value), "device_fallback".into(), false), (None, Some(value)) => (Some(value), "external".into(), false), (None, None) => (None, "unavailable".into(), false), }, _ => match device_temperature { Some(value) => (Some(value), "device".into(), false), None => (None, "unavailable".into(), false), }, } } fn adjustment_allowed(zone: &Zone) -> bool { let Some(last) = zone.last_action_at else { return true; }; (Utc::now() - last).num_seconds().max(0) as u64 >= zone.min_adjust_seconds.max(15) } fn external_room_sensor_cooling_assist(mode: &str, control_source: &str) -> f64 { if mode == "cool" && matches!(control_source, "external" | "combined") { 0.5 } else { 0.0 } } fn effective_sensor_stale_after_seconds(zone_value: u64, global_value: u64) -> u64 { let global = global_value.clamp(30, 86_400); // 0 and the historical hidden default (300 s) mean "inherit the HA setting". // A non-default value supplied through the existing zone API remains a per-zone override. if zone_value == 0 || zone_value == 300 { global } else { zone_value.clamp(30, 86_400) } } fn round_device_setpoint(mode: &str, demand: bool, value: f64) -> f64 { let value = value.clamp(16.0, 30.0); match (mode, demand) { ("heat", true) => value.ceil(), ("heat", false) => value.floor(), (_, true) => value.floor(), (_, false) => value.ceil(), } } fn outdoor_assist_offset(mode: &str, outdoor: Option, room: f64, target: f64) -> f64 { let Some(outdoor) = outdoor else { return 0.0; }; let room_error = (room - target).abs(); let weather = match mode { "heat" => ((5.0 - outdoor) / 15.0).clamp(0.0, 1.0), _ => ((outdoor - 30.0) / 10.0).clamp(0.0, 1.0), }; (weather * room_error.clamp(0.0, 2.0) * 0.5).clamp(0.0, 1.0) } fn smart_quiet_command( smart_fan: bool, quiet_supported: bool, previous_demand: bool, demand: bool, device_quiet: bool, night_enabled: bool, night_active: bool, night_force_quiet: bool, ) -> Option { if !quiet_supported { return None; } if night_enabled && night_force_quiet && night_active { return if device_quiet { None } else { Some(true) }; } if night_enabled && night_force_quiet && !night_active && device_quiet { // Explicitly release Quiet when the scheduled night window ends. return Some(false); } if smart_fan { // Smart Quiet follows demand transitions. Do not keep reasserting Quiet while a // satisfied room remains in standby: some units report Quiet=false again even after // accepting the command, which otherwise produces a beep every adjustment interval. if previous_demand && !demand && !device_quiet { return Some(true); } if !previous_demand && demand && device_quiet { return Some(false); } return None; } // Without Smart Fan, Quiet can only have been requested by scheduled night mode, // so release it after the night window ends. if night_enabled && night_force_quiet && device_quiet { return Some(false); } None } fn native_sleep_command( night_enabled: bool, night_active: bool, use_native_sleep: bool, sleep_supported: bool, device_sleep: bool, ) -> Option { if !sleep_supported { return None; } if night_enabled && use_native_sleep && night_active { return if device_sleep { None } else { Some(true) }; } // If night mode ended or native Sleep was disabled in settings, remove a previously // active device Sleep flag instead of leaving it latched indefinitely. if device_sleep { return Some(false); } None } fn night_limited_fan_speed(requested: u8, max_fan: u8) -> u8 { let max_fan = max_fan.clamp(1, 5); if requested == 0 { 1 } else { requested.min(max_fan) } } pub fn night_mode_active(settings: &NightModeSettings, time: NaiveTime) -> bool { if !settings.enabled { return false; } let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return false; }; let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return false; }; if start == end { return true; } if start < end { time >= start && time < end } else { time >= start || time < end } } fn smart_fan_speed(mode: &str, room: f64, target: f64, outdoor: Option, demand: bool) -> u8 { // When the thermostat is satisfied, keep airflow quiet instead of leaving the // unit in Auto. The caller sends this together with the standby setpoint in // the same GREE command, so e.g. 21 C reached -> 19 C + Low fan for heating. if !demand { return 1; } let error = (room - target).abs(); let extreme_weather = match (mode, outdoor) { ("heat", Some(value)) => value <= 0.0, (_, Some(value)) => value >= 32.0, _ => false, }; if error >= 2.0 || extreme_weather { 3 } else if error >= 1.0 { 2 } else { 0 } } fn profile_setpoint(zone: &Zone, preset: &str, mode: &str) -> f64 { if zone.profile_version == 0 && preset == "comfort" { return zone.setpoint; } match (mode, preset) { ("heat", "sleep") => zone.heat_sleep_setpoint, ("heat", "away") => zone.heat_away_setpoint, ("heat", _) => zone.heat_comfort_setpoint, (_, "sleep") => zone.cool_sleep_setpoint, (_, "away") => zone.cool_away_setpoint, (_, _) => zone.cool_comfort_setpoint, } } fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) -> (String, f64) { let (preset, base_target) = if let Some(manual) = zone.manual_preset.as_deref() { if manual == "custom" { ("custom".into(), zone.setpoint) } else { (manual.to_string(), profile_setpoint(zone, manual, mode)) } } else if let Some(item) = schedule { if item.preset == "custom" { ("custom".into(), item.setpoint) } else { (item.preset.clone(), profile_setpoint(zone, &item.preset, mode)) } } else { ("comfort".into(), profile_setpoint(zone, "comfort", mode)) }; // Quick +/- temperature adjustments are independent from the selected preset. // The UI can therefore stay in Auto/Sleep/Comfort while temporarily nudging the target. (preset, zone.manual_setpoint.unwrap_or(base_target)) } pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) { 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; if !zone.device_manual_override { zone.effective_setpoint = Some(target); } } 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) -> Option<&'a Schedule> { schedules.iter() .filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now)) // Overlaps are rejected by the API, but imported/legacy data may still contain one. // Prefer the most recently edited entry instead of depending on database/name order. .max_by_key(|item| item.updated_at) } fn minute_floor(now: DateTime) -> DateTime { now.with_second(0).and_then(|value| value.with_nanosecond(0)).unwrap_or(now) } pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: DateTime) -> Option> { let current = schedules.iter() .filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, now)) .max_by_key(|item| item.updated_at) .map(|item| item.id.as_str()); let base = minute_floor(now); // Eight days cover a complete weekly schedule plus the next transition. for minute in 1..=(8 * 24 * 60) { let candidate = base + chrono::Duration::minutes(minute); let next = schedules.iter() .filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, candidate)) .max_by_key(|item| item.updated_at) .map(|item| item.id.as_str()); if next != current { return Some(candidate.with_timezone(&Utc)); } } // No schedule transition exists: keep a manual override until the user clears it. None } fn schedule_active(item: &Schedule, now: DateTime) -> bool { let Ok(start) = NaiveTime::parse_from_str(&item.start_time, "%H:%M") else { return false; }; let Ok(end) = NaiveTime::parse_from_str(&item.end_time, "%H:%M") else { return false; }; let time = now.time(); let today = now.weekday().number_from_monday(); if start == end { // Equal times mean a 24-hour block starting on each selected weekday. if time >= start { item.weekdays.contains(&today) } else { let previous = previous_weekday(now.weekday()).number_from_monday(); item.weekdays.contains(&previous) } } else if start < end { item.weekdays.contains(&today) && time >= start && time < end } else if time >= start { item.weekdays.contains(&today) } else if time < end { let previous = previous_weekday(now.weekday()).number_from_monday(); item.weekdays.contains(&previous) } else { false } } fn schedule_week_mask(item: &Schedule) -> Option> { let start = NaiveTime::parse_from_str(&item.start_time, "%H:%M").ok()?; let end = NaiveTime::parse_from_str(&item.end_time, "%H:%M").ok()?; let start_minute = (start.hour() * 60 + start.minute()) as usize; let end_minute = (end.hour() * 60 + end.minute()) as usize; let mut mask = vec![false; 7 * 24 * 60]; for weekday in &item.weekdays { if !(1..=7).contains(weekday) { return None; } let day = (*weekday as usize) - 1; let mark = |mask: &mut [bool], day: usize, from: usize, to: usize| { let base = (day % 7) * 24 * 60; for minute in from..to { mask[base + minute] = true; } }; if start_minute == end_minute { mark(&mut mask, day, start_minute, 24 * 60); mark(&mut mask, day + 1, 0, end_minute); } else if start_minute < end_minute { mark(&mut mask, day, start_minute, end_minute); } else { mark(&mut mask, day, start_minute, 24 * 60); mark(&mut mask, day + 1, 0, end_minute); } } Some(mask) } pub(crate) fn schedules_overlap(a: &Schedule, b: &Schedule) -> bool { if !a.enabled || !b.enabled || a.zone_id != b.zone_id { return false; } let (Some(left), Some(right)) = (schedule_week_mask(a), schedule_week_mask(b)) else { return false; }; left.iter().zip(right.iter()).any(|(a, b)| *a && *b) } fn previous_weekday(day: Weekday) -> Weekday { match day { Weekday::Mon => Weekday::Sun, Weekday::Tue => Weekday::Mon, Weekday::Wed => Weekday::Tue, Weekday::Thu => Weekday::Wed, Weekday::Fri => Weekday::Thu, Weekday::Sat => Weekday::Fri, Weekday::Sun => Weekday::Sat, } } pub async fn build_control_plan(state: &AppState) -> Result { let settings = state.settings.read().await.clone(); let schedules = state.db.list_schedules()?; let devices = state.db.list_devices()?; let zones = state.db.list_zones()?; let groups = state.db.list_groups()?; let house_preset = zones.first().and_then(|first| { let first_preset = first.manual_preset.as_deref().unwrap_or("auto"); zones.iter().all(|zone| zone.manual_preset.as_deref().unwrap_or("auto") == first_preset) .then(|| first_preset.to_string()) }); let house_power = settings.house_power_enabled; let now = Local::now(); let night_active = night_mode_active(&settings.night_mode, now.time()); let mut zones_out = Vec::new(); let mut house_events = next_night_mode_events(&settings.night_mode, now, 2); for mut zone in zones { let device = devices.iter().find(|item| item.id == zone.device_id); let configured_effective_mode_owned = if zone.inherit_house_mode { settings.house_mode.clone() } else { zone.mode.clone() }; let 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)); refresh_control_ownership(&mut zone, settings.house_power_enabled, blocked_by_group); let configured_effective_mode = configured_effective_mode_owned.as_str(); let manual_device_mode = device.map(|item| if item.power { item.mode.as_str() } else { "off" }); let effective_mode = if zone.device_manual_override { manual_device_mode.unwrap_or(configured_effective_mode) } else if zone.local_thermostat_power == Some(false) || blocked_by_group { "off" } else { configured_effective_mode }; // Keep the thermostat target readable even while the zone/group/house control is off. // Home Assistant climate entities otherwise expose target_temperature as unknown. let target_mode = if configured_effective_mode == "off" { zone.mode.as_str() } else { configured_effective_mode }; let active_for_target = active_schedule_for_zone(&zone, &schedules, now); let (resolved_preset, resolved_target) = resolve_zone_target(&zone, active_for_target, target_mode); let active = if effective_mode == "off" { None } else { active_for_target }; let next_events = if effective_mode == "off" { Vec::new() } else { next_schedule_events(&zone, &schedules, effective_mode, now, 8) }; for event in next_events.iter().take(2) { let mut event = event.clone(); event.label = format!("{}: {}", zone.name, event.label); house_events.push(event); } let effective_enabled = zone.enabled && zone.local_thermostat_power != Some(false) && (!blocked_by_group || zone.device_manual_override); zones_out.push(ZoneControlPlan { zone_id: zone.id.clone(), zone_name: zone.name.clone(), device_id: zone.device_id.clone(), device_name: device.map(|item| item.name.clone()).unwrap_or_else(|| zone.device_id.clone()), enabled: zone.enabled, effective_enabled, mode: effective_mode.to_string(), configured_mode: zone.mode.clone(), inherit_house_mode: zone.inherit_house_mode, preset: resolved_preset, preset_override: zone.manual_preset.clone(), current_temperature: zone.current_temperature, target_temperature: if zone.device_manual_override || !zone.enabled || effective_mode == "off" { // A remote/manual takeover may leave a physical standby target persisted in the // zone runtime snapshot. Never publish that value as the thermostat target. Some(resolved_target) } else { zone.effective_setpoint.or(Some(resolved_target)) }, device_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature), desired_power: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && !blocked_by_group, desired_mode: effective_mode.to_string(), actual_power: device.map(|item| item.power), actual_mode: device.map(|item| if item.power { item.mode.clone() } else { "off".into() }), actual_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature), demand: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && zone.demand, control_source: zone.control_temperature_source.clone(), manual_override_until: zone.manual_override_until, local_thermostat_power: zone.local_thermostat_power, local_thermostat_resume_at: zone.local_thermostat_resume_at, device_manual_override: zone.device_manual_override, device_manual_override_until: zone.device_manual_override_until, control_owner: zone.control_owner.clone(), control_command_source: zone.control_source.clone(), control_since: zone.control_since, resume_at: zone.control_resume_at, control_reason: zone.control_reason.clone(), blocked_reason: if !settings.house_power_enabled { Some("global_off".into()) } else if zone.device_manual_override { Some("manual_override".into()) } else if blocked_by_group { Some("group_off".into()) } else if zone.lockout_until.map(|until| until > Utc::now()).unwrap_or(false) { Some(zone.lockout_reason.clone().unwrap_or_else(|| "lockout".into())) } else if !zone.enabled { Some("zone_disabled".into()) } else if device.map(|d| !d.online || d.communication_failures > 0).unwrap_or(true) { Some("offline".into()) } else { None }, lockout_until: zone.lockout_until, current_schedule_id: active.map(|item| item.id.clone()), current_schedule_name: active.map(|item| item.name.clone()), next_events, }); } let mut rules = Vec::new(); for item in state.db.list_automations()? { let action_group_name = item.action_group_id.as_deref() .and_then(|id| groups.iter().find(|group| group.id == id)) .map(|group| group.name.clone()); let action_name = action_group_name.clone().unwrap_or_else(|| { devices.iter().find(|device| device.id == item.action_device_id) .map(|device| device.name.clone()) .unwrap_or_else(|| item.action_device_id.clone()) }); let trigger_name = item.trigger_device_id.as_deref().and_then(|id| devices.iter().find(|device| device.id == id)).map(|device| device.name.clone()); let next_ready_at = item.last_fired_at.map(|last| last + chrono::Duration::seconds(item.cooldown_seconds as i64)); if item.enabled && item.trigger_kind == "time" { if let Some(event) = next_time_automation_event(&item, &action_name, now) { house_events.push(event); } } rules.push(AutomationPlanRule { id: item.id, name: item.name, enabled: item.enabled, trigger_kind: item.trigger_kind, trigger_device_id: item.trigger_device_id, trigger_device_name: trigger_name, threshold: item.threshold, at_time: item.at_time, action_device_id: item.action_device_id, action_device_name: action_name, action_group_id: item.action_group_id, action_group_name, action_preset: item.action_preset, action: item.action, last_fired_at: item.last_fired_at, next_ready_at, }); } house_events.sort_by_key(|event| event.at); house_events.truncate(12); Ok(ControlPlan { generated_at: Utc::now(), house_mode: settings.house_mode, house_preset, house_power, outdoor_temperature: *state.outdoor_temperature.read().await, control_strategy: settings.control_strategy, night_mode_active: night_active, night_mode_start: settings.night_mode.start_time, night_mode_end: settings.night_mode.end_time, night_mode_max_fan_speed: settings.night_mode.max_fan_speed.clamp(1, 5), next_events: house_events, zones: zones_out, rules, }) } fn next_night_mode_events(settings: &NightModeSettings, now: DateTime, limit: usize) -> Vec { if !settings.enabled || limit == 0 { return Vec::new(); } let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return Vec::new(); }; let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return Vec::new(); }; let mut events = Vec::new(); let base = minute_floor(now); for minute in 1..=(48 * 60) { let candidate = base + chrono::Duration::minutes(minute); let time = candidate.time(); let (kind, label) = if time.hour() == start.hour() && time.minute() == start.minute() { let quiet = if settings.force_quiet { " + Quiet" } else { "" }; ("night_mode_start", format!("Night mode -> fan max {}{}", settings.max_fan_speed.clamp(1, 5), quiet)) } else if time.hour() == end.hour() && time.minute() == end.minute() { ("night_mode_end", "Night mode ends".to_string()) } else { continue; }; events.push(ControlPlanEvent { at: candidate.with_timezone(&Utc), kind: kind.into(), label, preset: None, target_temperature: None, }); if events.len() >= limit { break; } } events } fn next_time_automation_event(item: &Automation, action_name: &str, now: DateTime) -> Option { let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?; let base = minute_floor(now.clone()); if time_automation_due(item, now) { return Some(ControlPlanEvent { at: base.with_timezone(&Utc), kind: "automation".into(), label: format!("{} -> {}", item.name, action_name), preset: None, target_temperature: item.action.target_temperature, }); } // A local day can last 25 hours at the end of daylight saving time. for minute in 1..=(26 * 60) { let candidate = base + chrono::Duration::minutes(minute); if candidate.hour() == expected.hour() && candidate.minute() == expected.minute() { return Some(ControlPlanEvent { at: candidate.with_timezone(&Utc), kind: "automation".into(), label: format!("{} -> {}", item.name, action_name), preset: None, target_temperature: item.action.target_temperature, }); } } None } fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: DateTime, limit: usize) -> Vec { if mode == "off" { return Vec::new(); } let mut events = Vec::new(); let mut current = active_schedule_for_zone(zone, schedules, now).map(|item| item.id.as_str()); let base = minute_floor(now); for minute in 1..=(8 * 24 * 60) { let candidate = base + chrono::Duration::minutes(minute); let next = active_schedule_for_zone(zone, schedules, candidate); let next_id = next.map(|item| item.id.as_str()); if next_id == current { continue; } current = next_id; let (preset, target, label) = if let Some(item) = next { let target = if item.preset == "custom" { item.setpoint } else { profile_setpoint(zone, &item.preset, mode) }; (Some(item.preset.clone()), Some(target), format!("{} -> {} {:.1} C", item.name, item.preset, target)) } else { let target = profile_setpoint(zone, "comfort", mode); (Some("comfort".into()), Some(target), format!("comfort {:.1} C", target)) }; events.push(ControlPlanEvent { at: candidate.with_timezone(&Utc), kind: "schedule_transition".into(), label, preset, target_temperature: target, }); if events.len() >= limit { break; } } events } async fn run_automations(state: &AppState) -> Result<()> { if !state.settings.read().await.house_power_enabled { return Ok(()); } let devices = state.db.list_devices()?; let zones = state.db.list_zones()?; let groups = state.db.list_groups()?; for mut item in state.db.list_automations()? { if !item.enabled || !automation_ready(&item) { continue; } let should_fire = match item.trigger_kind.as_str() { "temperature_above" => find_temperature(&devices, item.trigger_device_id.as_deref()) .zip(item.threshold).map(|(t, threshold)| t > threshold).unwrap_or(false), "temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref()) .zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false), "time" => time_automation_due(&item, Local::now()), _ => false, }; if !should_fire { continue; } if item.action_group_id.is_none() && device_blocked_by_disabled_zone(&item.action_device_id, &zones) { // Disabled thermostat zones are outside normal automation. The underlying // unit can still be operated manually from the technical Devices view. item.last_fired_at = Some(Utc::now()); item.updated_at = Utc::now(); state.db.save_automation(&item)?; state.log("info", "automation.blocked_by_zone", &format!("Automation {} suppressed by disabled zone", item.name), json!({ "automation_id": item.id, "device_id": item.action_device_id })); continue; } if item.action_group_id.is_none() && device_blocked_by_manual_override(&item.action_device_id, &zones) { item.last_fired_at = Some(Utc::now()); item.updated_at = Utc::now(); state.db.save_automation(&item)?; state.log("info", "automation.blocked_by_manual_override", &format!("Automation {} suppressed by manual device control", item.name), json!({ "automation_id": item.id, "device_id": item.action_device_id })); continue; } if item.action_group_id.is_none() && device_blocked_by_local_thermostat(&item.action_device_id, &zones) { item.last_fired_at = Some(Utc::now()); item.updated_at = Utc::now(); state.db.save_automation(&item)?; state.log("info", "automation.blocked_by_local_thermostat", &format!("Automation {} suppressed by local thermostat ownership", item.name), json!({ "automation_id": item.id, "device_id": item.action_device_id })); continue; } if item.action_group_id.is_none() && device_blocked_by_disabled_group(&item.action_device_id, &zones, &groups) { // Group power-off is authoritative for normal controller-owned zones. A manual // takeover is filtered above and therefore remains higher priority than the group. item.last_fired_at = Some(Utc::now()); item.updated_at = Utc::now(); state.db.save_automation(&item)?; state.log("info", "automation.blocked_by_group", &format!("Automation {} suppressed by disabled group", item.name), json!({ "automation_id": item.id, "device_id": item.action_device_id })); continue; } let result = if let Some(group_id) = item.action_group_id.as_deref() { let group_mode = item.action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() }); control_group(state, group_id, GroupControlPatch { power: item.action.power, mode: group_mode, preset: item.action_preset.clone(), }, "automation.group").await.map(|_| ()) } else { match send_automatic_device_command_if_owned(state, &item.action_device_id, item.action.clone()).await { Ok(Some(_)) => Ok(()), Ok(None) => { state.log("info", "automation.blocked_by_fresh_ownership", &format!("Automation {} was suppressed after ownership changed", item.name), json!({ "automation_id": item.id, "device_id": item.action_device_id })); Ok(()) } Err(err) => Err(err), } }; match result { Ok(()) => { item.last_fired_at = Some(Utc::now()); item.updated_at = Utc::now(); state.db.save_automation(&item)?; state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({ "automation_id": item.id, "group_id": item.action_group_id, "device_id": item.action_device_id })); } Err(err) => { // A failed action is still an execution attempt. Apply the configured cooldown // so an offline/disabled target cannot be hammered on every automation cycle. item.last_fired_at = Some(Utc::now()); item.updated_at = Utc::now(); state.db.save_automation(&item)?; state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id})); } } } Ok(()) } fn device_blocked_by_disabled_zone(device_id: &str, zones: &[Zone]) -> bool { zones.iter().any(|zone| zone.device_id == device_id && !zone.enabled) } fn device_blocked_by_manual_override(device_id: &str, zones: &[Zone]) -> bool { zones.iter().any(|zone| zone.device_id == device_id && zone.device_manual_override) } fn device_blocked_by_local_thermostat(device_id: &str, zones: &[Zone]) -> bool { zones.iter().any(|zone| zone.device_id == device_id && zone.local_thermostat_power.is_some()) } fn device_blocked_by_disabled_group(device_id: &str, zones: &[Zone], groups: &[crate::models::ClimateGroup]) -> bool { let zone_ids: std::collections::HashSet<&str> = zones.iter() .filter(|zone| zone.device_id == device_id) .map(|zone| zone.id.as_str()) .collect(); if zone_ids.is_empty() { return false; } groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id.as_str()))) } fn find_temperature(devices: &[Device], device_id: Option<&str>) -> Option { let id = device_id?; // Never fire a temperature automation from stale cached data of an offline/disabled unit. devices.iter().find(|d| d.id == id && d.enabled && d.online && d.communication_failures == 0)?.current_temperature } fn automation_ready(item: &Automation) -> bool { item.last_fired_at.map(|last| (Utc::now() - last).num_seconds().max(0) as u64 >= item.cooldown_seconds).unwrap_or(true) } fn time_automation_due(item: &Automation, now: DateTime) -> bool { let Some(expected) = item.at_time.as_deref() else { return false; }; let Ok(value) = NaiveTime::parse_from_str(expected, "%H:%M") else { return false; }; if now.hour() != value.hour() || now.minute() != value.minute() { return false; } if let Some(last) = item.last_fired_at { let local_last = last.with_timezone(&Local); if local_last.date_naive() == now.date_naive() && local_last.hour() == now.hour() && local_last.minute() == now.minute() { return false; } } true } #[cfg(test)] mod tests { use super::*; use chrono::TimeZone; #[test] fn global_ha_sensor_stale_timeout_is_used_for_default_zone_value() { assert_eq!(effective_sensor_stale_after_seconds(300, 600), 600); assert_eq!(effective_sensor_stale_after_seconds(0, 900), 900); assert_eq!(effective_sensor_stale_after_seconds(120, 600), 120); assert_eq!(effective_sensor_stale_after_seconds(120_000, 600), 86_400); } #[test] fn overnight_schedule_works() { let now = Utc.with_ymd_and_hms(2025, 1, 7, 1, 0, 0).unwrap().with_timezone(&Local); // Tuesday let item = Schedule { id: "1".into(), zone_id: "z".into(), name: "night".into(), enabled: true, weekdays: vec![1], start_time: "22:00".into(), end_time: "06:00".into(), preset: "custom".into(), setpoint: 20.0, created_at: Utc::now(), updated_at: Utc::now(), }; assert!(schedule_active(&item, now)); } fn test_schedule(id: &str, weekdays: Vec, start: &str, end: &str) -> Schedule { Schedule { id: id.into(), zone_id: "z".into(), name: id.into(), enabled: true, weekdays, start_time: start.into(), end_time: end.into(), preset: "comfort".into(), setpoint: 21.0, created_at: Utc::now(), updated_at: Utc::now(), } } #[test] fn equal_schedule_times_mean_a_full_day() { let item = test_schedule("full", vec![1], "06:00", "06:00"); let monday_noon = Local.with_ymd_and_hms(2025, 1, 6, 12, 0, 0).single().unwrap(); let tuesday_early = Local.with_ymd_and_hms(2025, 1, 7, 5, 59, 0).single().unwrap(); let tuesday_after = Local.with_ymd_and_hms(2025, 1, 7, 6, 1, 0).single().unwrap(); assert!(schedule_active(&item, monday_noon)); assert!(schedule_active(&item, tuesday_early)); assert!(!schedule_active(&item, tuesday_after)); } #[test] fn schedule_overlap_detection_handles_overnight_ranges() { let daytime = test_schedule("day", vec![1,2,3,4,5,6,7], "06:30", "22:30"); let night = test_schedule("night", vec![1,2,3,4,5,6,7], "22:30", "06:30"); let conflict = test_schedule("conflict", vec![1], "22:00", "23:00"); assert!(!schedules_overlap(&daytime, &night)); assert!(schedules_overlap(&night, &conflict)); } #[test] fn next_schedule_boundary_scans_the_whole_week() { let friday = test_schedule("friday", vec![5], "12:00", "13:00"); let monday = Local.with_ymd_and_hms(2025, 1, 6, 10, 0, 30).single().unwrap(); let boundary = next_schedule_boundary_utc("z", &[friday], monday).unwrap().with_timezone(&Local); assert_eq!(boundary.weekday(), Weekday::Fri); assert_eq!(boundary.hour(), 12); assert_eq!(boundary.minute(), 0); assert_eq!(boundary.second(), 0); } #[test] fn full_week_schedule_has_no_manual_override_boundary() { let always = test_schedule("always", vec![1,2,3,4,5,6,7], "00:00", "00:00"); let now = Local.with_ymd_and_hms(2025, 1, 6, 10, 0, 30).single().unwrap(); assert!(next_schedule_boundary_utc("z", &[always], now).is_none()); } #[test] fn workday_weekend_handoff_has_no_overlaps() { let schedules = vec![ test_schedule("morning", vec![1,2,3,4,5], "06:30", "08:00"), test_schedule("away", vec![1,2,3,4,5], "08:00", "16:00"), test_schedule("evening", vec![1,2,3,4,5], "16:00", "22:30"), test_schedule("sleep", vec![1,2,3,4,5], "22:30", "06:30"), test_schedule("weekend", vec![6,7], "08:00", "23:00"), test_schedule("saturday-sleep", vec![6], "23:00", "08:00"), test_schedule("sunday-sleep", vec![7], "23:00", "06:30"), ]; for (index, item) in schedules.iter().enumerate() { for other in schedules.iter().skip(index + 1) { assert!(!schedules_overlap(item, other), "{} overlaps {}", item.name, other.name); } } } #[test] fn time_automation_fires_only_once_in_the_same_minute() { let now = Local.with_ymd_and_hms(2025, 1, 6, 10, 15, 40).single().unwrap(); let mut item = Automation { id: "a".into(), name: "at time".into(), enabled: true, trigger_kind: "time".into(), trigger_device_id: None, threshold: None, at_time: Some("10:15".into()), action_device_id: "d".into(), action_group_id: None, action_preset: None, action: DeviceCommand { power: Some(true), ..Default::default() }, cooldown_seconds: 30, last_fired_at: None, created_at: Utc::now(), updated_at: Utc::now(), }; assert!(time_automation_due(&item, now.clone())); item.last_fired_at = Some((now.clone() - chrono::Duration::seconds(35)).with_timezone(&Utc)); assert!(!time_automation_due(&item, now)); } fn test_zone(source: &str) -> Zone { Zone { id: "z".into(), name: "Room".into(), device_id: "d".into(), enabled: true, mode: "heat".into(), inherit_house_mode: true, setpoint: 21.0, profile_version: 1, cool_comfort_setpoint: 23.0, cool_sleep_setpoint: 24.5, cool_away_setpoint: 27.0, heat_comfort_setpoint: 21.0, heat_sleep_setpoint: 19.0, heat_away_setpoint: 17.0, hysteresis: 0.6, min_on_seconds: 180, min_off_seconds: 180, min_adjust_seconds: 120, standby_offset_c: 2.0, smart_fan: true, sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()), external_sensor_weight: 0.4, max_sensor_difference: 3.0, sensor_stale_after_seconds: 300, device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, temporary_quick_thermostat: None, device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None, revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "test".into(), last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None, effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None, demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(), } } #[test] fn external_device_change_detects_manual_climate_controls() { let zone = test_zone("device"); let before = Device::simulated_default(); let mut after = before.clone(); after.power = !before.power; after.target_temperature = before.target_temperature + 1.0; after.fan_speed = 3; let fields = externally_changed_control_fields(&before, &after, &zone); assert!(fields.iter().any(|field| field == "power")); assert!(fields.iter().any(|field| field == "target_temperature")); assert!(fields.iter().any(|field| field == "fan_speed")); } #[test] fn controller_expected_climate_change_is_recognized() { let mut device = Device::simulated_default(); device.power = true; device.mode = "cool".into(); device.target_temperature = 22.0; let command = DeviceCommand { power: Some(true), mode: Some("cool".into()), target_temperature: Some(22.0), ..Default::default() }; assert!(command_field_matches_device(&command, "power", &device)); assert!(command_field_matches_device(&command, "mode", &device)); assert!(command_field_matches_device(&command, "target_temperature", &device)); device.target_temperature = 25.0; assert!(!command_field_matches_device(&command, "target_temperature", &device)); } #[test] fn local_thermostat_ownership_blocks_direct_automation() { let mut zone = test_zone("device"); assert!(!device_blocked_by_local_thermostat( &zone.device_id, std::slice::from_ref(&zone), )); zone.local_thermostat_power = Some(true); assert!(device_blocked_by_local_thermostat( &zone.device_id, std::slice::from_ref(&zone), )); zone.local_thermostat_power = Some(false); assert!(device_blocked_by_local_thermostat( &zone.device_id, std::slice::from_ref(&zone), )); } #[test] fn local_thermostat_resume_clears_only_local_quick_control_state() { let mut zone = test_zone("device"); zone.local_thermostat_power = Some(false); zone.local_thermostat_resume_at = Some(Utc::now() + chrono::Duration::minutes(15)); zone.manual_preset = Some("comfort".into()); zone.manual_setpoint = Some(23.0); zone.manual_override_until = Some(Utc::now() + chrono::Duration::hours(1)); zone.device_manual_override = true; assert!(reset_local_thermostat_override(&mut zone)); assert!(zone.local_thermostat_power.is_none()); assert!(zone.local_thermostat_resume_at.is_none()); assert!(zone.manual_preset.is_none()); assert!(zone.manual_setpoint.is_none()); assert!(zone.manual_override_until.is_none()); assert!(zone.device_manual_override); assert_eq!(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES, 15); } fn temporary_session(now: DateTime) -> TemporaryQuickThermostat { TemporaryQuickThermostat { start_kind: "now".into(), finish_kind: "temperature_stable".into(), started_at: now.clone(), activated_at: Some(now), restore_zone_enabled: Some(true), expires_at: None, temperature_target: Some(23.0), temperature_operator: Some("within".into()), tolerance_c: 0.3, hold_seconds: 3600, condition_started_at: None, safety_expires_at: None, } } #[test] fn temporary_quick_thermostat_restores_previous_zone_enabled_state() { let now = Utc::now(); let mut zone = test_zone("device"); zone.enabled = true; zone.local_thermostat_power = Some(true); let mut session = temporary_session(now); session.restore_zone_enabled = Some(false); zone.temporary_quick_thermostat = Some(session); zone.manual_setpoint = Some(23.0); assert!(reset_local_thermostat_override(&mut zone)); assert!(!zone.enabled); assert!(zone.local_thermostat_power.is_none()); assert!(zone.temporary_quick_thermostat.is_none()); assert!(zone.manual_setpoint.is_none()); } #[test] fn temporary_stable_condition_requires_continuous_hold_time() { let now = Utc::now(); let mut zone = test_zone("device"); zone.current_temperature = Some(23.2); let mut session = temporary_session(now.clone()); session.condition_started_at = Some(now.clone() - chrono::Duration::seconds(3599)); zone.temporary_quick_thermostat = Some(session); assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now.clone()).is_none()); assert_eq!(evaluate_temporary_quick_thermostat_condition(&mut zone, now + chrono::Duration::seconds(2)), Some("temperature_stable".into())); } #[test] fn temporary_stable_condition_resets_when_temperature_leaves_range() { let now = Utc::now(); let mut zone = test_zone("device"); zone.current_temperature = Some(24.0); let mut session = temporary_session(now.clone()); session.condition_started_at = Some(now.clone() - chrono::Duration::minutes(30)); 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 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"); 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"); let mut before = Device::simulated_default(); before.target_temperature = 23.5; let mut after = before.clone(); after.target_temperature = 24.0; assert!(externally_changed_control_fields(&before, &after, &zone).is_empty()); } #[test] fn standby_low_to_auto_fan_drift_is_not_manual_override() { let mut zone = test_zone("device"); zone.smart_fan = true; zone.demand = false; let mut before = Device::simulated_default(); before.fan_speed = 1; let mut after = before.clone(); after.fan_speed = 0; assert!(externally_changed_control_fields(&before, &after, &zone).is_empty()); } #[test] fn reset_device_manual_override_clears_takeover_state() { let mut zone = test_zone("device"); let device = Device::simulated_default(); zone.device_manual_override = true; zone.device_manual_override_since = Some(Utc::now()); zone.device_manual_override_until = Some(Utc::now()); zone.device_manual_override_fields = vec!["target_temperature".into()]; zone.device_manual_override_baseline = Some((&device).into()); assert!(reset_device_manual_override(&mut zone)); assert!(!zone.device_manual_override); assert!(zone.device_manual_override_since.is_none()); assert!(zone.device_manual_override_until.is_none()); assert!(zone.device_manual_override_fields.is_empty()); assert!(zone.device_manual_override_baseline.is_none()); } #[test] fn restored_manual_device_state_matches_original_takeover_baseline() { let mut zone = test_zone("device"); let baseline = Device::simulated_default(); zone.device_manual_override = true; zone.device_manual_override_fields = vec!["power".into(), "target_temperature".into()]; zone.device_manual_override_baseline = Some((&baseline).into()); let mut changed = baseline.clone(); changed.power = !baseline.power; changed.target_temperature = baseline.target_temperature + 2.0; assert!(!manual_override_matches_baseline(&zone, &changed)); let mut returned_off = baseline.clone(); returned_off.target_temperature = baseline.target_temperature + 3.0; assert!(manual_override_matches_baseline(&zone, &returned_off)); assert!(manual_override_matches_baseline(&zone, &baseline)); } #[test] fn device_command_drops_unchanged_fields() { let device = Device::simulated_default(); let command = DeviceCommand { power: Some(false), mode: Some("cool".into()), target_temperature: Some(23.4), fan_speed: Some(3), light: Some(false), ..DeviceCommand::default() }; let changed = command.changed_from(&device); assert_eq!(changed.power, None); assert_eq!(changed.mode, None); assert_eq!(changed.target_temperature, None); assert_eq!(changed.fan_speed, Some(3)); assert_eq!(changed.light, Some(false)); } #[test] fn combined_temperature_prefers_room_sensor_weight() { let zone = test_zone("combined"); let (value, source, discrepancy) = select_zone_temperature(&zone, Some(22.0), Some(20.0)); assert_eq!(value, Some(21.2)); assert_eq!(source, "combined"); assert!(!discrepancy); } #[test] fn combined_temperature_falls_back_on_large_discrepancy() { let zone = test_zone("combined"); let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.0), Some(27.0)); assert_eq!(value, Some(21.0)); assert_eq!(source, "device_discrepancy_fallback"); assert!(discrepancy); } #[test] fn combined_temperature_falls_back_when_external_is_missing() { let zone = test_zone("combined"); let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.5), None); assert_eq!(value, Some(21.5)); assert_eq!(source, "device_fallback"); assert!(!discrepancy); } #[test] fn seasonal_profiles_resolve_independently() { let zone = test_zone("device"); assert_eq!(profile_setpoint(&zone, "comfort", "cool"), 23.0); assert_eq!(profile_setpoint(&zone, "sleep", "cool"), 24.5); assert_eq!(profile_setpoint(&zone, "comfort", "heat"), 21.0); assert_eq!(profile_setpoint(&zone, "sleep", "heat"), 19.0); } #[test] fn quick_setpoint_keeps_active_preset() { let mut zone = test_zone("device"); zone.manual_setpoint = Some(22.5); let (preset, target) = resolve_zone_target(&zone, None, "cool"); assert_eq!(preset, "comfort"); assert_eq!(target, 22.5); } #[test] fn runtime_target_refresh_applies_manual_profile_immediately() { let mut zone = test_zone("device"); zone.inherit_house_mode = false; zone.mode = "cool".into(); zone.manual_preset = Some("sleep".into()); zone.active_preset = "comfort".into(); zone.effective_setpoint = Some(25.0); refresh_zone_runtime_target(&mut zone, &[], "cool"); assert_eq!(zone.active_preset, "sleep"); assert_eq!(zone.effective_setpoint, Some(24.5)); assert_eq!(zone.effective_mode, "cool"); } #[test] fn legacy_zone_keeps_old_comfort_setpoint() { let mut zone = test_zone("device"); zone.profile_version = 0; zone.setpoint = 22.5; assert_eq!(profile_setpoint(&zone, "comfort", "cool"), 22.5); assert_eq!(profile_setpoint(&zone, "comfort", "heat"), 22.5); } #[test] fn device_setpoint_rounding_preserves_control_direction() { assert_eq!(round_device_setpoint("cool", true, 23.5), 23.0); assert_eq!(round_device_setpoint("cool", false, 25.5), 26.0); assert_eq!(round_device_setpoint("heat", true, 21.5), 22.0); assert_eq!(round_device_setpoint("heat", false, 19.5), 19.0); } #[test] fn external_room_sensor_selects_lower_cooling_setpoint_only_when_used() { assert_eq!(external_room_sensor_cooling_assist("cool", "external"), 0.5); assert_eq!(external_room_sensor_cooling_assist("cool", "combined"), 0.5); assert_eq!(external_room_sensor_cooling_assist("cool", "device_fallback"), 0.0); assert_eq!(external_room_sensor_cooling_assist("cool", "device_discrepancy_fallback"), 0.0); assert_eq!(external_room_sensor_cooling_assist("heat", "external"), 0.0); } #[test] fn smart_fan_uses_low_speed_when_zone_is_satisfied() { assert_eq!(smart_fan_speed("heat", 21.0, 21.0, None, false), 1); assert_eq!(smart_fan_speed("cool", 23.0, 23.0, None, false), 1); } #[test] fn smart_quiet_follows_satisfied_transition_only_when_supported() { assert_eq!(smart_quiet_command(true, true, true, false, false, false, false, true), Some(true)); assert_eq!(smart_quiet_command(true, true, false, false, false, false, false, true), None); assert_eq!(smart_quiet_command(true, true, false, false, true, false, false, true), None); assert_eq!(smart_quiet_command(true, true, false, true, true, false, false, true), Some(false)); assert_eq!(smart_quiet_command(true, true, true, true, true, false, false, true), None); assert_eq!(smart_quiet_command(true, false, true, false, false, false, false, true), None); assert_eq!(smart_quiet_command(false, true, true, false, false, false, false, true), None); } #[test] fn night_mode_handles_midnight_and_limits_auto_fan() { let settings = NightModeSettings { enabled: true, start_time: "22:00".into(), end_time: "06:00".into(), max_fan_speed: 1, force_quiet: true, use_native_sleep: true }; assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(23, 30, 0).unwrap())); assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(5, 59, 0).unwrap())); assert!(!night_mode_active(&settings, NaiveTime::from_hms_opt(12, 0, 0).unwrap())); assert_eq!(night_limited_fan_speed(0, 1), 1); assert_eq!(night_limited_fan_speed(3, 1), 1); assert_eq!(smart_quiet_command(false, true, true, true, false, true, true, true), Some(true)); assert_eq!(smart_quiet_command(false, true, true, true, true, true, false, true), Some(false)); assert_eq!(smart_quiet_command(true, true, false, false, true, true, false, true), Some(false)); assert_eq!(native_sleep_command(true, true, true, true, false), Some(true)); assert_eq!(native_sleep_command(true, false, true, true, true), Some(false)); assert_eq!(native_sleep_command(false, false, false, true, true), Some(false)); assert_eq!(native_sleep_command(true, true, true, false, false), None); } #[test] fn gree_outdoor_fallback_uses_median_of_online_units() { let mut a = Device::simulated_default(); a.outdoor_temperature = Some(10.0); let mut b = Device::simulated_default(); b.id = "sim-b".into(); b.outdoor_temperature = Some(12.0); let mut c = Device::simulated_default(); c.id = "sim-c".into(); c.outdoor_temperature = Some(40.0); assert_eq!(gree_outdoor_temperature(&[a, b, c]), Some(12.0)); } #[test] fn outdoor_assist_is_bounded_and_direction_neutral() { let cool = outdoor_assist_offset("cool", Some(36.0), 27.0, 23.0); let heat = outdoor_assist_offset("heat", Some(-5.0), 17.0, 21.0); assert!(cool > 0.0 && cool <= 1.0); assert!(heat > 0.0 && heat <= 1.0); assert_eq!(outdoor_assist_offset("cool", None, 27.0, 23.0), 0.0); } }