pub fn refresh_control_ownership(zone: &mut Zone, _house_power_enabled: bool) { let now = Utc::now(); let (owner, source, resume_at, reason) = 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 { let source = if zone.control_source.starts_with("group:") || matches!(zone.control_source.as_str(), "automation.device" | "house_power") { zone.control_source.clone() } else { "automation".into() }; ("automation", source, 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 now = Utc::now(); 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 let Some(session) = zone.temporary_quick_thermostat.as_mut() { let pause = session.paused_at.take() .map(|paused_at| now.signed_duration_since(paused_at)) .filter(|pause| *pause > chrono::Duration::zero()); if session.activated_at.is_some() { if let Some(pause) = pause { if matches!(session.finish_kind.as_str(), "duration" | "until") { session.expires_at = session.expires_at.map(|at| at + pause); } if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { session.safety_expires_at = session.safety_expires_at.map(|at| at + pause); } } session.state = "active".into(); } else { // A due session blocked by manual ownership has not started its work clock. // Preserve the requested remaining `until` window by excluding manual wait time. if session.finish_kind == "until" { if let Some(pause) = pause { session.expires_at = session.expires_at.map(|at| at + pause); } } session.state = "scheduled".into(); } session.condition_started_at = None; session.condition_last_observed_at = None; } if zone.control_owner == "direct_manual" { zone.control_owner = "automation".into(); zone.control_source = "automation".into(); zone.control_since = Some(now); zone.control_resume_at = None; zone.control_reason = "Manual takeover cleared; automation may resume".into(); } changed } #[cfg(test)] 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, })); state.wake_zone_control(); 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; if let Some(session) = zone.temporary_quick_thermostat.as_mut() { // A future scheduled session has no ownership yet. Do not mark it paused until its // requested start actually becomes due while manual control is still present. if session.activated_at.is_some() || session.started_at <= now { if session.paused_at.is_none() { session.paused_at = Some(now); } session.state = "paused_manual".into(); session.condition_started_at = None; session.condition_last_observed_at = None; } } zone.control_owner = "direct_manual".into(); zone.control_source = normalized_direct_source(source).into(); zone.control_reason = "Direct/manual device control has priority".into(); // Direct/pilot control is an explicit ownership takeover, not a temporary preset. // Keep it manual until the user explicitly chooses Resume automation. A global // ON/OFF command changes physical power only and must never erase manual ownership. // A schedule boundary must never silently take the unit back from the person controlling it. zone.device_manual_override_until = None; zone.control_resume_at = None; 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; // Direct/manual takeover supersedes any thermostat task that was waiting behind // compressor protection; never leave a stale queued badge behind. rearm_compressor_queue(zone); 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 raw_fields = externally_changed_control_fields(before, after, &zone); let controller_settling = if raw_fields.is_empty() { json!({ "active": false, "reason": "no_changed_control_fields" }) } else { controller_settling_diagnostics(state, &after.id).await }; let fields = suppress_expected_controller_changes( state, after, raw_fields.clone(), ).await; // Once direct/pilot ownership has been detected, merely returning the device to a // previous physical state must not silently hand control back to schedules. Only the // explicit Resume automation action ends manual ownership. 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; } state.log("info", "device.remote_control_detected", &format!("External/pilot control detected for {}", zone.name), json!({ "zone_id": zone.id.clone(), "device_id": zone.device_id.clone(), "raw_fields": raw_fields, "detected_fields": fields.clone(), "before": device_control_snapshot(before), "after": device_control_snapshot(after), "controller_settling": controller_settling, "source": "gree_poll", "zone_state": { "enabled": zone.enabled, "control_owner": zone.control_owner.clone(), "control_source": zone.control_source.clone(), "demand": zone.demand, "local_thermostat_power": zone.local_thermostat_power, "temporary_quick_thermostat": zone.temporary_quick_thermostat.clone(), }, })); 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 mut zone_ids: Vec = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).map(|zone| zone.id).collect(); zone_ids.sort(); zone_ids.dedup(); 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}")))?; // An explicit direct-control request is an ownership action even when the requested // value already matches the cached device state. Derive takeover fields from the user's // request, not only from the physical delta, so clicking ON / entering the current target // still switches the thermostat zone to persistent manual control. let fields = command_manual_control_fields(&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.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 { // Whole-house OFF physically forces the unit down after the API has persisted per-zone local OFF. // Keep zone -> device ordering so a concurrent local/manual action cannot race the frame. let mut zone_ids: Vec = state.db.list_zones()?.into_iter() .filter(|zone| zone.device_id == device_id) .map(|zone| zone.id) .collect(); zone_ids.sort(); zone_ids.dedup(); let mut _zone_guards = Vec::with_capacity(zone_ids.len()); for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); } let _device_guard = state.lock_device_operation(device_id).await; force_power_off_device_locked(state, device_id).await } pub async fn one_shot_house_power_on_device(state: &AppState, device_id: &str) -> Result { // Global ON releases per-zone OFF state in the API and must not create a local-ON ownership marker. For thermostat-managed // units it still respects compressor protection; a protected start is stored as a visible // queue item and executed at the protection deadline unless a newer intent cancels/replaces it. let mut zone_ids: Vec = state.db.list_zones()?.into_iter() .filter(|zone| zone.device_id == device_id) .map(|zone| zone.id) .collect(); zone_ids.sort(); zone_ids.dedup(); let mut _zone_guards = Vec::with_capacity(zone_ids.len()); for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); } let _device_guard = state.lock_device_operation(device_id).await; let 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())); } if device.power { return Ok(device); } let settings = state.settings.read().await.clone(); if settings.compressor_protection_enabled { if let Some(zone_id) = zone_ids.first() { if let Some(mut zone) = state.db.get_zone(zone_id)? { let now = Utc::now(); let protection = chrono::Duration::seconds(settings.compressor_protection_seconds as i64); if let Some(last_change) = zone.last_power_change_at { let until = last_change + protection; if until > now { rearm_compressor_queue(&mut zone); queue_compressor_action(&mut zone, "global_power_on".into(), until, "minimum_off_before_global_start"); zone.revision = zone.revision.saturating_add(1); zone.updated_at = now; state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); state.log("info", "zone.compressor_queue_queued", &format!("Queued global ON for {} behind compressor protection", zone.name), json!({ "zone_id": zone.id, "device_id": zone.device_id, "action": "global_power_on", "resume_at": zone.compressor_pending_until, })); state.wake_zone_control(); return Ok(device); } } } } } // The device lock is already held. This is physical global power only: no manual marker. send_command_locked(state, device_id, DeviceCommand { power: Some(true), ..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 } /// Same safety transition for callers that already hold the per-device operation lock. /// Keeping this separate avoids recursive lock acquisition during atomic configuration import. pub async fn force_power_off_device_locked(state: &AppState, device_id: &str) -> Result { send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await } /// Technical device disable is a safety transition, not just a database flag. The unit is /// explicitly powered off while it is still commandable, then removed from controller polling. pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result { let _device_guard = state.lock_device_operation(device_id).await; let mut device = state.db.get_device(device_id)? .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; if !device.enabled { return Ok(device); } device = send_command_locked_forced( state, device_id, DeviceCommand { power: Some(false), ..Default::default() }, ).await?; device.enabled = false; device.updated_at = Utc::now(); state.db.save_device(&device)?; state.broadcast("device.updated", serde_json::to_value(&device)?); state.wake_zone_control(); Ok(device) }