fn compressor_action_id(kind: &str, mode: &str, target: f64) -> String { format!("{kind}:{mode}:{target:.1}") } pub(crate) fn clear_compressor_pending(zone: &mut Zone, clear_cancelled: bool) { zone.lockout_until = None; zone.lockout_reason = None; zone.compressor_pending_action = None; zone.compressor_pending_since = None; zone.compressor_pending_until = None; if clear_cancelled { zone.compressor_cancelled_action = None; } } pub(crate) fn queue_compressor_action(zone: &mut Zone, action: String, until: DateTime, reason: &str) { let now = Utc::now(); if zone.compressor_pending_action.as_deref() != Some(action.as_str()) { zone.compressor_pending_since = Some(now); } zone.compressor_pending_action = Some(action); zone.compressor_pending_until = Some(until.clone()); zone.lockout_until = Some(until); zone.lockout_reason = Some(reason.to_string()); } fn compressor_action_is_cancelled(zone: &Zone, action: &str) -> bool { zone.compressor_cancelled_action.as_deref() == Some(action) } pub(crate) fn rearm_compressor_queue(zone: &mut Zone) { clear_compressor_pending(zone, true); } async fn resolve_cycle_outdoor_temperature( state: &AppState, settings: &RuntimeSettings, devices: &[Device], ) -> Option { let configured = settings.home_assistant.outdoor_entity_id.trim(); let resolved = if configured.is_empty() { None } else { home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured)) }; let from_home_assistant = if let Some(entity_id) = resolved.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, resolved_entity=%entity_id, error=?err, "outdoor Home Assistant sensor unavailable; trying GREE fallback"); None } } } else { None }; let temperature = from_home_assistant.or_else(|| gree_outdoor_temperature(devices)); let mut current = state.outdoor_temperature.write().await; if *current != temperature { *current = temperature; state.broadcast("outdoor.updated", json!({"temperature": temperature})); } temperature } async fn read_cycle_room_sensors( state: &AppState, settings: &RuntimeSettings, zones: &[Zone], ) -> HashMap, Result)> { futures_util::future::join_all(zones.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 .into_iter() .map(|(zone_id, entity_id, result)| (zone_id, (entity_id, result))) .collect() } fn refresh_zone_temperature( state: &AppState, settings: &RuntimeSettings, zone: &mut Zone, device: &Device, room_sensor_results: &mut HashMap, Result)>, ) -> (String, bool) { let previous_source = zone.control_temperature_source.clone(); 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") { let kind = if err.contains("Home Assistant sensor is stale:") { "ha.sensor_stale" } else { "ha.sensor_error" }; state.log("warn", kind, &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, 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 = source; zone.updated_at = Utc::now(); (previous_source, discrepancy) } fn persist_zone_cycle_with_history( state: &AppState, zone: &Zone, cycle_started_at: DateTime, outdoor_temperature: Option, poll_interval_seconds: u64, ) -> Result { record_zone_history(state, zone, outdoor_temperature, poll_interval_seconds); let persisted = persist_zone_cycle(state, zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted)?); Ok(persisted) } async fn handle_zone_pre_control_state( state: &AppState, settings: &RuntimeSettings, schedules: &[Schedule], temporary_restored_disabled: &[String], zone: &mut Zone, device: &Device, effective_mode: &str, cycle_started_at: DateTime, outdoor_temperature: Option, ) -> Result { // A queued whole-house ON is a delayed bulk physical action, not thermostat ownership. if zone.compressor_pending_action.as_deref() == Some("global_power_on") { let now = Utc::now(); if device.power { clear_compressor_pending(zone, true); zone.updated_at = now; persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; return Ok(true); } let due = !settings.compressor_protection_enabled || zone.compressor_pending_until.as_ref().map(|until| until <= &now).unwrap_or(true); if due { let _device_guard = state.lock_device_operation(&zone.device_id).await; match send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(true), ..Default::default() }).await { Ok(updated_device) => { if !device.power && updated_device.power { zone.last_power_change_at = Some(Utc::now()); } clear_compressor_pending(zone, true); zone.last_action_at = Some(Utc::now()); state.log("info", "house.power_one_shot_executed", &format!("Executed queued global ON for {}", zone.name), json!({ "zone_id": zone.id, "device_id": zone.device_id })); } Err(err) => { zone.compressor_pending_until = Some(Utc::now() + chrono::Duration::seconds(10)); zone.lockout_until = zone.compressor_pending_until.clone(); zone.lockout_reason = Some("global_start_retry".into()); state.log("error", "house.power_one_shot_error", &err.to_string(), json!({ "zone_id": zone.id, "device_id": zone.device_id })); } } } zone.updated_at = Utc::now(); persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; return Ok(true); } 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; } clear_compressor_pending(zone, true); zone.demand = false; zone.demand_since = None; persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; return Ok(true); } if !device.enabled { clear_compressor_pending(zone, true); zone.demand = false; zone.demand_since = None; zone.device_setpoint = None; persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; return Ok(true); } if zone.device_manual_override { clear_compressor_pending(zone, true); let temporary_active = temporary_quick_thermostat_is_active(zone, zone.updated_at.clone()); let pause_started_at = zone.updated_at.clone(); if temporary_active { if let Some(session) = zone.temporary_quick_thermostat.as_mut() { if session.paused_at.is_none() { session.paused_at = Some(pause_started_at); } session.state = "paused_manual".into(); session.condition_started_at = None; session.condition_last_observed_at = None; } } 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); 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; persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; return Ok(true); } let condition_sample_at = match zone.control_temperature_source.as_str() { "home_assistant" | "combined" => Some(zone.updated_at.clone()), _ => device.last_seen.clone(), }; let max_condition_gap_seconds = settings.poll_interval_seconds .max(settings.zone_interval_seconds) .saturating_mul(2) .saturating_add(5); let condition_now = zone.updated_at.clone(); if let Some(reason) = evaluate_temporary_quick_thermostat_condition( zone, condition_now, condition_sample_at, max_condition_gap_seconds, ) { let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default(); finish_temporary_quick_thermostat(zone, schedules, &settings.house_mode); let persisted = persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; ensure_device_off_after_temporary_disabled_restore(state, &persisted, 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(); return Ok(true); } 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})); } } } persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; return Ok(true); } Ok(false) } async fn control_zones(state: &AppState) -> Result<()> { let _cycle_guard = state.lock_zone_control_cycle().await; let schedules = state.db.list_schedules()?; let settings = state.settings.read().await.clone(); let mut zone_snapshot = state.db.list_zones()?; // Local/temporary thermostat ownership has its own deadlines. Expire and activate sessions independently. expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; let device_snapshot = state.db.list_devices()?; let outdoor_temperature = resolve_cycle_outdoor_temperature(state, &settings, &device_snapshot).await; 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()); let mut room_sensor_results = read_cycle_room_sensors(state, &settings, &zone_snapshot).await; for zone_snapshot_item in zone_snapshot { // Every thermostat decision participates in the same zone -> device ordering as // Web/HA/manual control and polling. Re-read after taking the zone lock so an // interactive change cannot be evaluated from a stale snapshot. let _zone_guard = state.lock_zone_operation(&zone_snapshot_item.id).await; let Some(mut zone) = state.db.get_zone(&zone_snapshot_item.id)? else { continue; }; let cycle_started_at = zone.updated_at.clone(); 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.control_source.starts_with("group:") { zone.control_source = "automation".into(); zone.control_since = Some(Utc::now()); zone.control_reason = "Group override expired at schedule boundary".into(); } } // Direct/manual takeover stays active until the user explicitly resumes automation. if zone.device_manual_override && zone.device_manual_override_until.is_some() { zone.device_manual_override_until = None; zone.control_resume_at = None; state.log("info", "zone.device_manual_override_migrated", &format!("Manual device control remains active for {} until explicit resume", 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. Global ON/OFF is intentionally not a // persistent gate: later thermostat/group/manual intent may act independently. let effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode); zone.effective_mode = effective_mode_owned.clone(); refresh_control_ownership(&mut zone); let effective_mode = effective_mode_owned.as_str(); let (previous_source, discrepancy) = refresh_zone_temperature( state, &settings, &mut zone, &device, &mut room_sensor_results, ); if handle_zone_pre_control_state( state, &settings, &schedules, &temporary_restored_disabled, &mut zone, &device, effective_mode, cycle_started_at.clone(), outdoor_temperature, ).await? { 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()); // Heat/Cool configuration alone is not an instruction to run forever on the implicit // Comfort profile. After local/global/group OFF is handed back to automation, a zone // with no active schedule, quick/manual target, temporary session or active scoped // controller stays physically OFF. This also makes gaps between schedule windows true // OFF periods instead of silently falling back to Comfort and recreating demand. if !zone_has_active_thermostat_intent(&zone, active_schedule, Utc::now()) { clear_compressor_pending(&mut zone, true); zone.effective_mode = "off".into(); zone.effective_setpoint = None; zone.device_setpoint = None; zone.demand = false; zone.demand_since = None; zone.target_alerted_at = None; if zone.control_owner == "automation" { zone.control_reason = "Automation idle: no active schedule or explicit thermostat request".into(); zone.control_resume_at = 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() }, ).await { Ok(Some(updated_device)) => { let transition_at = Utc::now(); if device.power != updated_device.power { zone.last_power_change_at = Some(transition_at); } zone.last_action_at = Some(transition_at); state.log("info", "zone.automation_idle_off", &format!("Zone {} remains OFF: no active thermostat intent", zone.name), json!({ "zone_id": zone.id, "device_id": zone.device_id, "active_schedule": false, "manual_preset": zone.manual_preset, "manual_setpoint": zone.manual_setpoint, "control_source": zone.control_source, })); } Ok(None) => {} Err(err) => state.log("error", "zone.automation_idle_off_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 (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_for_mode(effective_mode) / 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 full-degree pre-rounding bias: because // GREE setpoints are sent as whole degrees, this keeps the unit at least one full // degree below the room target, including half-degree thermostat setpoints. Do not // stack it with outdoor assist or 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, ); // Global compressor protection. Automatic thermostat/group/house requests are not // discarded while the protection window is active: they become a visible pending // task which can be cancelled from the thermostat UI. Safety OFF paths still bypass // protection. A cancelled task is not silently re-created until a new control intent // re-arms the queue (or a different mode/target produces a different task id). let now = Utc::now(); if !settings.compressor_protection_enabled { clear_compressor_pending(&mut zone, true); } else if zone.lockout_until.map(|until| until <= now).unwrap_or(false) { zone.lockout_until = None; zone.lockout_reason = None; zone.compressor_pending_until = None; } let protection = chrono::Duration::seconds(settings.compressor_protection_seconds as i64); if settings.compressor_protection_enabled && device.power && device.mode != effective_mode { let action = compressor_action_id("mode_change", effective_mode, desired_device_target); if compressor_action_is_cancelled(&zone, &action) { clear_compressor_pending(&mut zone, false); 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 let Some(last_change) = zone.last_power_change_at { if now.signed_duration_since(last_change) < protection { queue_compressor_action(&mut zone, action, last_change + protection, "minimum_on_before_mode_change"); 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() }).await { Ok(Some(_)) => { zone.last_power_change_at = Some(now); queue_compressor_action(&mut zone, action, now + protection, "mode_change_off_delay"); 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, "compressor_protection_enabled": settings.compressor_protection_enabled})); } 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 action = zone.compressor_pending_action.clone() .filter(|value| value.starts_with("mode_change:")) .unwrap_or_else(|| compressor_action_id("power_on", effective_mode, desired_device_target)); if compressor_action_is_cancelled(&zone, &action) { clear_compressor_pending(&mut zone, false); 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 settings.compressor_protection_enabled { if let Some(last_change) = zone.last_power_change_at { if now.signed_duration_since(last_change) < protection { queue_compressor_action(&mut zone, action, last_change + protection, "minimum_off_before_start"); 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; } } } // The safe window is open. Remove the pending marker before attempting the // command; a transport error will be retried by the normal thermostat cycle. zone.compressor_pending_action = None; zone.compressor_pending_since = None; zone.compressor_pending_until = None; zone.lockout_until = None; zone.lockout_reason = None; } else { // Reaching the intended powered/mode state retires both pending and cancelled // markers so a future independent request starts with a clean queue. clear_compressor_pending(&mut zone, true); } let core_needs_command = !device.power || device.mode != effective_mode || (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).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); clear_compressor_pending(&mut zone, true); 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, "schedule_id": active_schedule.map(|item| item.id.as_str()), "flow_id": active_schedule.and_then(|item| item.flow_id.as_deref()), "flow_node_id": active_schedule.and_then(|item| item.flow_node_id.as_deref()), })); } 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(()) } /// Run one thermostat arbitration cycle immediately and wait for all currently eligible zones. /// The cycle lock prevents overlap with the background regulator. pub async fn run_zone_control_now(state: &AppState) -> Result<(), AppError> { control_zones(state).await.map_err(AppError::from) }