#[derive(Debug, Deserialize)] struct ZoneInput { name: String, device_id: String, #[serde(default = "yes")] enabled: bool, #[serde(default = "cool")] mode: String, #[serde(default = "yes")] inherit_house_mode: bool, #[serde(default = "setpoint")] setpoint: f64, #[serde(default = "cool_comfort")] cool_comfort_setpoint: f64, #[serde(default = "cool_sleep")] cool_sleep_setpoint: f64, #[serde(default = "cool_away")] cool_away_setpoint: f64, #[serde(default = "heat_comfort")] heat_comfort_setpoint: f64, #[serde(default = "heat_sleep")] heat_sleep_setpoint: f64, #[serde(default = "heat_away")] heat_away_setpoint: f64, #[serde(default = "hysteresis")] hysteresis: f64, #[serde(default)] separate_hysteresis: bool, #[serde(default = "hysteresis")] cool_hysteresis: f64, #[serde(default = "hysteresis")] heat_hysteresis: f64, #[serde(default = "cycle")] min_on_seconds: u64, #[serde(default = "cycle")] min_off_seconds: u64, #[serde(default = "min_adjust")] min_adjust_seconds: u64, #[serde(default = "standby_offset")] standby_offset_c: f64, #[serde(default = "yes")] smart_fan: bool, #[serde(default = "device_source")] sensor_source: String, #[serde(default)] ha_entity_id: Option, #[serde(default = "external_sensor_weight")] external_sensor_weight: f64, #[serde(default = "max_sensor_difference")] max_sensor_difference: f64, #[serde(default = "sensor_stale_after")] sensor_stale_after_seconds: u64, #[serde(default)] revision: Option, } fn yes() -> bool { true } fn cool() -> String { "cool".into() } fn setpoint() -> f64 { 24.0 } fn cool_comfort() -> f64 { 23.0 } fn cool_sleep() -> f64 { 24.5 } fn cool_away() -> f64 { 27.0 } fn heat_comfort() -> f64 { 21.0 } fn heat_sleep() -> f64 { 19.0 } fn heat_away() -> f64 { 17.0 } fn hysteresis() -> f64 { 0.6 } fn cycle() -> u64 { 180 } fn min_adjust() -> u64 { 120 } fn standby_offset() -> f64 { 2.0 } fn external_sensor_weight() -> f64 { 0.4 } fn max_sensor_difference() -> f64 { 3.0 } fn sensor_stale_after() -> u64 { 300 } fn device_source() -> String { "device".into() } impl ZoneInput { fn validate(&self) -> Result<(), AppError> { if self.name.trim().is_empty() { return Err(AppError::BadRequest("zone name is required".into())); } for value in [self.setpoint, self.cool_comfort_setpoint, self.cool_sleep_setpoint, self.cool_away_setpoint, self.heat_comfort_setpoint, self.heat_sleep_setpoint, self.heat_away_setpoint] { if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone temperatures must be between 8 and 30 C".into())); } } if !(0.1..=5.0).contains(&self.hysteresis) { return Err(AppError::BadRequest("hysteresis must be between 0.1 and 5 C".into())); } if !(0.1..=5.0).contains(&self.cool_hysteresis) { return Err(AppError::BadRequest("cooling hysteresis must be between 0.1 and 5 C".into())); } if !(0.1..=5.0).contains(&self.heat_hysteresis) { return Err(AppError::BadRequest("heating hysteresis must be between 0.1 and 5 C".into())); } if !(0.5..=8.0).contains(&self.standby_offset_c) { return Err(AppError::BadRequest("standby offset must be between 0.5 and 8 C".into())); } if !matches!(self.mode.as_str(), "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); } if !matches!(self.sensor_source.as_str(), "device" | "home_assistant" | "combined") { return Err(AppError::BadRequest("unsupported sensor source".into())); } if !(0.0..=1.0).contains(&self.external_sensor_weight) { return Err(AppError::BadRequest("external sensor weight must be between 0 and 1".into())); } if !(0.1..=20.0).contains(&self.max_sensor_difference) { return Err(AppError::BadRequest("maximum sensor difference must be between 0.1 and 20 C".into())); } if matches!(self.sensor_source.as_str(), "home_assistant" | "combined") && self.ha_entity_id.as_deref().map(|value| value.trim()).unwrap_or("").is_empty() { return Err(AppError::BadRequest("a per-zone Home Assistant entity_id is required for external or combined temperature control".into())); } Ok(()) } fn into_zone(self, id: String, created_at: chrono::DateTime) -> Zone { Zone { id, name: self.name.trim().into(), device_id: self.device_id, enabled: self.enabled, mode: self.mode, inherit_house_mode: self.inherit_house_mode, setpoint: self.setpoint, profile_version: 1, cool_comfort_setpoint: self.cool_comfort_setpoint, cool_sleep_setpoint: self.cool_sleep_setpoint, cool_away_setpoint: self.cool_away_setpoint, heat_comfort_setpoint: self.heat_comfort_setpoint, heat_sleep_setpoint: self.heat_sleep_setpoint, heat_away_setpoint: self.heat_away_setpoint, hysteresis: self.hysteresis, separate_hysteresis: self.separate_hysteresis, cool_hysteresis: self.cool_hysteresis, heat_hysteresis: self.heat_hysteresis, min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds, min_adjust_seconds: self.min_adjust_seconds, standby_offset_c: self.standby_offset_c, smart_fan: self.smart_fan, sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()), external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference, sensor_stale_after_seconds: self.sensor_stale_after_seconds, 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, local_thermostat_restore_zone_enabled: 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: "zone created".into(), last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None, effective_mode: String::new(), effective_setpoint: None, device_setpoint: None, demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at, updated_at: Utc::now(), } } } fn validate_zone_device_assignment(state: &AppState, device_id: &str, current_zone_id: Option<&str>) -> Result<(), AppError> { if state.db.list_zones()?.iter().any(|zone| zone.device_id == device_id && current_zone_id != Some(zone.id.as_str())) { return Err(AppError::BadRequest("a device can belong to only one thermostat zone".into())); } Ok(()) } async fn list_zones(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_zones()?)) } async fn get_zone(State(state): State, Path(id): Path) -> Result, AppError> { state.db.get_zone(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("zone {id}"))) } async fn create_zone(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _reference_guard = state.lock_automation_operation().await; input.validate()?; if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); } validate_zone_device_assignment(&state, &input.device_id, None)?; // Creating thermostat ownership must not overlap a poll of the device. Otherwise a poll // that started before the zone existed could apply its old physical-control snapshot to // the newly created zone without participating in the zone operation lock. let _device_guard = state.lock_device_operation(&input.device_id).await; let mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now()); let settings = state.settings.read().await.clone(); canonicalize_zone_ha_entity(&mut zone, &settings); state.db.save_zone(&zone)?; state.broadcast("zone.created", serde_json::to_value(&zone)?); state.wake_zone_control(); Ok((StatusCode::CREATED, Json(zone))) } async fn update_zone(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _reference_guard = state.lock_automation_operation().await; input.validate()?; let _zone_guard = state.lock_zone_operation(&id).await; let mut existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; if let Some(expected) = input.revision { if expected != existing.revision { return Err(AppError::Conflict(format!("zone {id} changed; expected revision {expected}, current revision {}", existing.revision))); } } if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); } validate_zone_device_assignment(&state, &input.device_id, Some(&id))?; let device_changed = existing.device_id != input.device_id; // Keep the zone lock while taking all involved device locks in stable order. This makes a // reassignment atomic against polling of both the old and the new unit and preserves the // global zone -> device ordering used by live control paths. let mut locked_device_ids = vec![existing.device_id.clone(), input.device_id.clone()]; locked_device_ids.sort(); locked_device_ids.dedup(); let mut device_guards = Vec::with_capacity(locked_device_ids.len()); for device_id in &locked_device_ids { device_guards.push(state.lock_device_operation(device_id).await); } if !device_changed { // Polling may have updated takeover/runtime state while we were waiting for the // device lock. Re-read under both locks before building the replacement Zone. existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; if let Some(expected) = input.revision { if expected != existing.revision { return Err(AppError::Conflict(format!("zone {id} changed; expected revision {expected}, current revision {}", existing.revision))); } } } let mut zone = input.into_zone(id, existing.created_at); if !device_changed { zone.device_temperature = existing.device_temperature; zone.external_temperature = existing.external_temperature; zone.current_temperature = existing.current_temperature; zone.control_temperature_source = existing.control_temperature_source; zone.active_preset = existing.active_preset; zone.manual_preset = existing.manual_preset; zone.manual_setpoint = existing.manual_setpoint; zone.manual_override_until = existing.manual_override_until; zone.local_thermostat_power = existing.local_thermostat_power; zone.local_thermostat_resume_at = existing.local_thermostat_resume_at; zone.local_thermostat_restore_zone_enabled = existing.local_thermostat_restore_zone_enabled; zone.temporary_quick_thermostat = existing.temporary_quick_thermostat; zone.device_manual_override = existing.device_manual_override; zone.device_manual_override_since = existing.device_manual_override_since; zone.device_manual_override_until = existing.device_manual_override_until; zone.device_manual_override_fields = existing.device_manual_override_fields; zone.device_manual_override_baseline = existing.device_manual_override_baseline; zone.revision = existing.revision.saturating_add(1); zone.control_owner = existing.control_owner; zone.control_source = existing.control_source; zone.control_since = existing.control_since; zone.control_resume_at = existing.control_resume_at; zone.control_reason = existing.control_reason; zone.last_power_change_at = existing.last_power_change_at; zone.last_mode_change_at = existing.last_mode_change_at; zone.lockout_until = existing.lockout_until; zone.lockout_reason = existing.lockout_reason; zone.effective_mode = existing.effective_mode; zone.effective_setpoint = existing.effective_setpoint; zone.device_setpoint = existing.device_setpoint; zone.demand = existing.demand; zone.demand_since = existing.demand_since; zone.target_alerted_at = existing.target_alerted_at; zone.last_action_at = existing.last_action_at; } else { // A new physical unit starts with a clean ownership/runtime state. Never transfer // demand, sensor cache or remote-control takeover from the previous device. ensure_device_stopped_for_detach_locked(&state, &existing.device_id, "zone.device_reassigned").await?; zone.revision = existing.revision.saturating_add(1); } let settings = state.settings.read().await.clone(); canonicalize_zone_ha_entity(&mut zone, &settings); let power_off_device = !device_changed && existing.enabled && !zone.enabled; if power_off_device { // Full configuration PUT and quick-control disable use the same ownership cleanup. // No local/temporary/manual takeover survives a disabled thermostat zone (H6). if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { engine::finish_temporary_quick_thermostat(&mut zone, &state.db.list_schedules()?, &settings.house_mode); } else { zone.temporary_quick_thermostat = None; } engine::reset_local_thermostat_override(&mut zone); engine::reset_device_manual_override(&mut zone); zone.enabled = false; } state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); drop(device_guards); if power_off_device { power_off_zone_device(&state, &zone, "zone.disabled").await; } state.wake_zone_control(); Ok(Json(zone)) } async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch, source: &str) -> Result { // A quick preset/setpoint derives its resume boundary from schedules. Take the schedule // lock before the per-zone lock so a concurrent schedule edit cannot leave an override // pointing at an obsolete boundary (and so lock order stays schedule -> zone -> device). let _schedule_guard = state.lock_schedule_operation().await; // Serialize quick-thermostat changes with the same device lock used by GREE polling and // manual-takeover detection. Without this, a poll that started just before a Web/HA // thermostat action could save an older zone snapshot afterwards and resurrect a false // "physical/pilot" takeover. let _zone_guard = state.lock_zone_operation(id).await; let device_id = state.db.get_zone(id)? .ok_or_else(|| AppError::NotFound(format!("zone {id}")))? .device_id; let device_guard = state.lock_device_operation(&device_id).await; let mut zone = state.db.get_zone(id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let was_enabled = zone.enabled; let schedules = state.db.list_schedules()?; let resume_device_takeover = patch.clear_device_manual_override.unwrap_or(false); let resume_local_thermostat = patch.clear_local_thermostat_override.unwrap_or(false); let stop_temporary_quick_thermostat = patch.clear_temporary_quick_thermostat.unwrap_or(false); if patch.temporary_quick_thermostat.is_some() && (patch.power.is_some() || stop_temporary_quick_thermostat || resume_local_thermostat) { return Err(AppError::BadRequest("temporary thermostat cannot be combined with local power/clear operations in one request".into())); } // Direct/manual device takeover is higher priority than a temporary thermostat. Creating // or editing a temporary session therefore never clears an active pilot/Devices takeover; // the session waits/pauses instead. Other explicit thermostat actions still resume control. let resume_device_automation = resume_device_takeover || patch.power.is_some() || patch.setpoint.is_some() || patch.mode.is_some() || patch.preset.is_some() || patch.enabled.is_some(); if resume_device_automation || resume_local_thermostat || stop_temporary_quick_thermostat || patch.temporary_quick_thermostat.is_some() { zone.control_source = if source.contains("home_assistant") { "home_assistant_thermostat".into() } else { "web_thermostat".into() }; } if resume_local_thermostat { engine::reset_local_thermostat_override(&mut zone); } if stop_temporary_quick_thermostat && zone.temporary_quick_thermostat.is_some() { if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode); } else { // Cancelling a delayed session before it starts must not erase unrelated // quick preset/setpoint state that automation may be using in the meantime. zone.temporary_quick_thermostat = None; } } if let Some(request) = patch.temporary_quick_thermostat.as_ref() { let now = Utc::now(); let runtime = state.settings.read().await.clone(); let existing_session = zone.temporary_quick_thermostat.clone(); let editing_active = engine::temporary_quick_thermostat_is_active(&zone, now); let requested_start_kind = request.start_kind.as_str(); if !matches!(requested_start_kind, "now" | "delay" | "at") { return Err(AppError::BadRequest("unsupported temporary thermostat start kind".into())); } // Editing an already active session changes only target/finish rules. Its historical // start and activated_at are preserved, so delay/at sessions cannot be accidentally // rescheduled or rejected because their original start is now in the past. let (start_kind, started_at, activated_at) = if editing_active { let existing = existing_session.as_ref().expect("active temporary session must exist"); (existing.start_kind.clone(), existing.started_at.clone(), existing.activated_at.clone()) } else { let started_at = match requested_start_kind { "now" => now.clone(), "delay" => { let minutes = request.start_delay_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat start delay is required".into()))?; if !(1..=43_200).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat start delay must be between 1 minute and 30 days".into())); } now.clone() + ChronoDuration::minutes(minutes as i64) } "at" => { let at = request.start_at.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat start time is required".into()))?; if at <= now { return Err(AppError::BadRequest("temporary thermostat start time must be in the future".into())); } if at > now.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat start time cannot be more than 30 days away".into())); } at } _ => unreachable!(), }; (requested_start_kind.to_string(), started_at, None) }; if !editing_active && start_kind == "now" && !runtime.house_power_enabled { return Err(AppError::BadRequest("temporary thermostat cannot start while whole-house automation is off; enable house power or schedule it for later".into())); } let finish_kind = request.finish_kind.as_str(); if !matches!(finish_kind, "duration" | "until" | "temperature_reached" | "temperature_stable" | "schedule_boundary") { return Err(AppError::BadRequest("unsupported temporary thermostat finish kind".into())); } let target = request.target_temperature.unwrap_or(zone.manual_setpoint.unwrap_or(zone.effective_setpoint.unwrap_or(zone.setpoint))); if !(8.0..=30.0).contains(&target) { return Err(AppError::BadRequest("temporary thermostat target must be between 8 and 30 C".into())); } let target = (target * 2.0).round() / 2.0; let tolerance_mode = if zone.effective_mode.is_empty() { zone.mode.as_str() } else { zone.effective_mode.as_str() }; let min_stable_tolerance = (zone.hysteresis_for_mode(tolerance_mode) / 2.0 + 0.1).min(3.0); let requested_tolerance = request.tolerance_c.unwrap_or(min_stable_tolerance.max(0.3)); if !(0.1..=3.0).contains(&requested_tolerance) { return Err(AppError::BadRequest("temporary thermostat tolerance must be between 0.1 and 3 C".into())); } let tolerance = if finish_kind == "temperature_stable" { requested_tolerance.max(min_stable_tolerance) } else { requested_tolerance }; let temperature_operator = request.temperature_operator.as_deref().unwrap_or("within"); if !matches!(temperature_operator, "within" | "at_or_below" | "at_or_above") { return Err(AppError::BadRequest("unsupported temporary thermostat temperature operator".into())); } let duration_seconds = if finish_kind == "duration" { let minutes = request.duration_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat duration is required".into()))?; if !(1..=14_400).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat duration must be between 1 minute and 10 days".into())); } Some(minutes.saturating_mul(60)) } else { None }; let is_temperature_condition = matches!(finish_kind, "temperature_reached" | "temperature_stable"); let hold_seconds = if finish_kind == "temperature_stable" { let minutes = request.hold_minutes.ok_or_else(|| AppError::BadRequest("temperature hold time is required".into()))?; if !(1..=1_440).contains(&minutes) { return Err(AppError::BadRequest("temperature hold time must be between 1 minute and 24 hours".into())); } minutes.saturating_mul(60) } else { 0 }; let safety_duration_seconds = if is_temperature_condition { request.max_duration_minutes.map(|minutes| { if !(1..=14_400).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat safety limit must be between 1 minute and 10 days".into())); } Ok(minutes.saturating_mul(60)) }).transpose()? } else { None }; let active_base = activated_at.clone().unwrap_or(now.clone()); let expires_at = match finish_kind { "duration" => if editing_active { duration_seconds.map(|seconds| active_base.clone() + ChronoDuration::seconds(seconds as i64)) } else { None }, "until" => { let until = request.until.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat end time is required".into()))?; let comparison_start = if editing_active { now.clone() } else { started_at.clone() }; if until <= comparison_start { return Err(AppError::BadRequest("temporary thermostat end time must be in the future and after its start".into())); } if until > comparison_start.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days after start".into())); } Some(until) } "schedule_boundary" => { let reference = if editing_active { chrono::Local::now() } else { started_at.clone().with_timezone(&chrono::Local) }; Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, reference) .ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?) } _ => None, }; let safety_expires_at = if editing_active { safety_duration_seconds.map(|seconds| active_base.clone() + ChronoDuration::seconds(seconds as i64)) } else { None }; let starts_now = start_kind == "now"; let immediate_activation = !editing_active && starts_now && runtime.house_power_enabled && !zone.device_manual_override; let restore_zone_enabled = if editing_active { existing_session.as_ref().and_then(|session| session.restore_zone_enabled) } else if immediate_activation { Some(zone.enabled) } else { // Delayed sessions capture this at actual takeover time (H12), not planning time. None }; let configured_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() }; let captured_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() }; let active_mode = if editing_active { existing_session.as_ref().and_then(|session| session.active_mode.clone()) } else if immediate_activation { Some(captured_mode.clone()) } else { None }; let condition_mode = active_mode.as_deref().unwrap_or(captured_mode.as_str()); if is_temperature_condition { if (condition_mode == "heat" && temperature_operator == "at_or_below") || (condition_mode == "cool" && temperature_operator == "at_or_above") { return Err(AppError::BadRequest( "temporary thermostat temperature condition conflicts with the active heating/cooling direction".into(), )); } } let state_value = if editing_active { if zone.device_manual_override { "paused_manual" } else { "active" } } else if starts_now && zone.device_manual_override { "paused_manual" } else { "scheduled" }; let underlying_local_power = zone.local_thermostat_power; let underlying_local_resume_at = zone.local_thermostat_resume_at; let underlying_local_zone_enabled = zone.local_thermostat_restore_zone_enabled; let underlying_manual_preset = zone.manual_preset.clone(); let underlying_manual_setpoint = zone.manual_setpoint; let underlying_manual_override_until = zone.manual_override_until; if immediate_activation { zone.local_thermostat_power = Some(true); zone.local_thermostat_resume_at = None; zone.local_thermostat_restore_zone_enabled = None; zone.enabled = true; zone.manual_setpoint = Some(target); zone.effective_setpoint = Some(target); zone.manual_override_until = None; } else if editing_active { // Keep current ownership and update the live target without restarting the session. zone.manual_setpoint = Some(target); zone.effective_setpoint = Some(target); zone.manual_override_until = None; } zone.temporary_quick_thermostat = Some(TemporaryQuickThermostat { start_kind, finish_kind: finish_kind.into(), started_at, activated_at: if immediate_activation { Some(now.clone()) } else { activated_at.clone() }, state: state_value.into(), active_mode, restore_zone_enabled, restore_local_thermostat_power: if editing_active { existing_session.as_ref().and_then(|session| session.restore_local_thermostat_power) } else if immediate_activation { underlying_local_power } else { None }, restore_local_thermostat_resume_at: if editing_active { existing_session.as_ref().and_then(|session| session.restore_local_thermostat_resume_at) } else if immediate_activation { underlying_local_resume_at } else { None }, restore_local_thermostat_zone_enabled: if editing_active { existing_session.as_ref().and_then(|session| session.restore_local_thermostat_zone_enabled) } else if immediate_activation { underlying_local_zone_enabled } else { None }, restore_manual_preset: if editing_active { existing_session.as_ref().and_then(|session| session.restore_manual_preset.clone()) } else if immediate_activation { underlying_manual_preset } else { None }, restore_manual_setpoint: if editing_active { existing_session.as_ref().and_then(|session| session.restore_manual_setpoint) } else if immediate_activation { underlying_manual_setpoint } else { None }, restore_manual_override_until: if editing_active { existing_session.as_ref().and_then(|session| session.restore_manual_override_until) } else if immediate_activation { underlying_manual_override_until } else { None }, expires_at: if immediate_activation && finish_kind == "duration" { duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64)) } else { expires_at }, duration_seconds, safety_duration_seconds, temperature_target: Some(target), temperature_operator: is_temperature_condition.then(|| temperature_operator.to_string()), tolerance_c: tolerance, hold_seconds, condition_started_at: None, condition_last_observed_at: None, paused_at: if zone.device_manual_override && (editing_active || starts_now) { existing_session.as_ref().and_then(|session| session.paused_at.clone()).or(Some(now.clone())) } else { None }, deferred_mode: existing_session.as_ref().and_then(|session| session.deferred_mode.clone()), deferred_preset: existing_session.as_ref().and_then(|session| session.deferred_preset.clone()), deferred_setpoint: existing_session.as_ref().and_then(|session| session.deferred_setpoint), safety_expires_at: if immediate_activation && is_temperature_condition { safety_duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64)) } else { safety_expires_at }, }); } if let Some(power) = patch.power { // The neighbouring quick-power control and the explicit Stop button must use the // same temporary-session cleanup/restore semantics before local ownership changes. if zone.temporary_quick_thermostat.is_some() { if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode); } else { zone.temporary_quick_thermostat = None; } } engine::set_local_thermostat_power(&mut zone, power, Utc::now()); if power { zone.enabled = true; } // A manually started local thermostat keeps an already selected target/profile until // it is switched off and the delayed hand-back completes, Auto is selected, or the // user explicitly resumes automation. if power && (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) { zone.manual_override_until = None; } } if let Some(value) = patch.setpoint { if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); } let value = (value * 10.0).round() / 10.0; zone.setpoint = value; zone.manual_setpoint = Some(value); zone.effective_setpoint = Some(value); if zone.local_thermostat_power == Some(true) { if let Some(session) = zone.temporary_quick_thermostat.as_mut() { // +/- always edits the live temporary target, including duration/until // sessions, so the modal and regulator cannot diverge (M14). session.temperature_target = Some(value); if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { session.condition_started_at = None; session.condition_last_observed_at = None; } } } zone.manual_override_until = if zone.local_thermostat_power == Some(true) { None } else { engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()) }; } if let Some(value) = patch.mode.as_deref() { if !matches!(value, "house" | "auto" | "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be house, cool or heat".into())); } if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { if let Some(session) = zone.temporary_quick_thermostat.as_mut() { session.deferred_mode = Some(value.to_string()); } } else { match value { "house" | "auto" => zone.inherit_house_mode = true, "cool" | "heat" => { zone.inherit_house_mode = false; zone.mode = value.to_string(); } _ => unreachable!(), } } } if let Some(value) = patch.preset.as_deref() { if !matches!(value, "auto" | "comfort" | "sleep" | "away" | "custom") { return Err(AppError::BadRequest("unsupported zone preset".into())); } if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { if let Some(session) = zone.temporary_quick_thermostat.as_mut() { session.deferred_preset = Some(value.to_string()); if value != "custom" { session.deferred_setpoint = None; } } } else { match value { "auto" => { zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; } "comfort" | "sleep" | "away" | "custom" => { zone.manual_preset = Some(value.to_string()); zone.manual_setpoint = None; zone.manual_override_until = if zone.local_thermostat_power == Some(true) { None } else { engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()) }; } _ => unreachable!(), } } } if patch.clear_override.unwrap_or(false) { if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { if let Some(session) = zone.temporary_quick_thermostat.as_mut() { session.deferred_preset = Some("auto".into()); session.deferred_setpoint = None; } } else { zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; } } if let Some(value) = patch.enabled { if !value { if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode); } else { zone.temporary_quick_thermostat = None; } engine::reset_local_thermostat_override(&mut zone); engine::reset_device_manual_override(&mut zone); zone.enabled = false; } else { zone.enabled = true; } } let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false }; let runtime = state.settings.read().await.clone(); let house_mode = runtime.house_mode.clone(); engine::refresh_control_ownership(&mut zone, runtime.house_power_enabled); engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode); 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)?); // Release before any device command below; engine::send_command acquires this same lock. drop(device_guard); if was_enabled && !zone.enabled { power_off_zone_device(state, &zone, "zone.quick_disabled").await; } else if patch.power == Some(false) { if let Err(err) = engine::send_command( 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, "power": false })); } state.wake_zone_control(); } else { state.wake_zone_control(); } state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({ "zone_id": zone.id, "setpoint": zone.setpoint, "manual_setpoint": zone.manual_setpoint, "mode": zone.mode, "inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset, "override_until": zone.manual_override_until, "enabled": zone.enabled, "local_thermostat_power": zone.local_thermostat_power, "local_thermostat_resume_at": zone.local_thermostat_resume_at, "temporary_quick_thermostat": zone.temporary_quick_thermostat, "device_manual_override_cleared": device_override_cleared })); Ok(zone) } async fn update_zone_control(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { Ok(Json(apply_zone_control_patch(&state, &id, patch, "web.zone_thermostat").await?)) } async fn ensure_device_stopped_for_detach_locked(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> { let Some(device) = state.db.get_device(device_id)? else { return Ok(()); }; if !device.enabled { return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into())); } // Force one OFF transition even when the cached state already says OFF. A remote change // may not have been polled yet and detaching must not leave a running unit without owner. engine::force_power_off_device_locked(state, device_id).await?; state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({ "device_id": device.id, "source": source })); Ok(()) } async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> { let _device_guard = state.lock_device_operation(device_id).await; ensure_device_stopped_for_detach_locked(state, device_id, source).await } async fn power_off_zone_device(state: &AppState, zone: &Zone, source: &str) { let Ok(Some(device)) = state.db.get_device(&zone.device_id) else { return; }; if !device.enabled { return; } if let Err(err) = engine::force_power_off_device(state, &device.id).await { state.log("error", "zone.disable_power_error", &err.to_string(), json!({ "zone_id": zone.id, "device_id": device.id, "device_name": device.name, "source": source, })); } }