#[derive(Debug, Deserialize)] struct HouseControlPatch { mode: String, } async fn rearm_house_automation_compressor_queues(state: &AppState) -> Result<(), AppError> { let mut zone_ids: Vec = state .db .list_zones()? .into_iter() .map(|zone| zone.id) .collect(); zone_ids.sort(); zone_ids.dedup(); for zone_id in zone_ids { let _zone_guard = state.lock_zone_operation(&zone_id).await; let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; let scoped_manual = zone.device_manual_override || zone.local_thermostat_power.is_some() || zone.control_source.starts_with("group:") || engine::temporary_quick_thermostat_is_active(&zone, Utc::now()); if scoped_manual { continue; } if zone.compressor_pending_action.is_none() && zone.compressor_cancelled_action.is_none() && zone.lockout_until.is_none() && zone.lockout_reason.is_none() { continue; } engine::rearm_compressor_queue(&mut zone); 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)?); } Ok(()) } async fn set_all_thermostat_power_state(state: &AppState, power: bool) -> Result { let mut zone_ids: Vec = state .db .list_zones()? .into_iter() .map(|zone| zone.id) .collect(); zone_ids.sort(); zone_ids.dedup(); let mut changed = 0usize; for zone_id in zone_ids { let _zone_guard = state.lock_zone_operation(&zone_id).await; let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; engine::rearm_compressor_queue(&mut zone); if engine::set_house_bulk_thermostat_power(&mut zone, power) { changed += 1; } engine::refresh_control_ownership(&mut zone); 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)?); } Ok(changed) } async fn command_all_enabled_devices_power( state: &AppState, power: bool, source: &str, ) -> Result, AppError> { let mut failed = Vec::new(); for device in state.db.list_devices()? { if !device.enabled { continue; } // The per-zone thermostat power state is persisted before these physical commands. // OFF is immediate; ON still respects compressor protection. let result = if power { engine::one_shot_house_power_on_device(state, &device.id).await } else { engine::force_house_power_off_device(state, &device.id, source).await }; if let Err(err) = result { state.log( "error", "house.power_all_error", &err.to_string(), json!({ "device_id": device.id, "device_name": device.name, "power": power, "source": source, }), ); failed.push(json!({ "device_id": device.id, "device_name": device.name, "error": err.to_string(), })); } } Ok(failed) } async fn clear_all_automation_compressor_queues(state: &AppState) -> Result { let mut zone_ids: Vec = state .db .list_zones()? .into_iter() .map(|zone| zone.id) .collect(); zone_ids.sort(); zone_ids.dedup(); let mut changed = 0usize; for zone_id in zone_ids { let _zone_guard = state.lock_zone_operation(&zone_id).await; let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; if zone.compressor_pending_action.is_none() && zone.compressor_cancelled_action.is_none() && zone.lockout_until.is_none() && zone.lockout_reason.is_none() { continue; } engine::rearm_compressor_queue(&mut zone); 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)?); changed += 1; } Ok(changed) } async fn update_house_control( State(state): State, Json(input): Json, ) -> Result, AppError> { let _house_guard = state.lock_house_operation().await; // Serialize the ownership/configuration transition against an already-running thermostat // cycle. Otherwise a cycle that captured the previous house mode could send one stale // climate command after this interactive change. let cycle_guard = state.lock_zone_control_cycle().await; if !matches!(input.mode.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest( "house mode must be cool, heat or off".into(), )); } let mode = input.mode; let activate_all = mode != "off"; { let mut settings = state.settings.write().await; settings.house_mode = mode.clone(); state.db.save_runtime_settings(&settings)?; } let payload = json!({"mode": mode}); state.broadcast("house.mode_changed", payload.clone()); // House rules do not steal explicit local/group/manual ownership. Free zones follow the // new mode immediately; scoped manual controls continue independently. rearm_house_automation_compressor_queues(&state).await?; // run_zone_control_now takes the same cycle lock, so release the mutation window first. drop(cycle_guard); if activate_all { // House mode changes are interactive controls: arbitrate all zones now instead of // leaving part of the house waiting for the background interval. engine::run_zone_control_now(&state).await?; } else { state.wake_zone_control(); } state.log( "info", "house.mode", &format!("House mode set to {}", mode), json!({"mode": mode}), ); Ok(Json(payload)) } #[derive(Debug, Deserialize)] struct HousePowerPatch { power: bool, } #[derive(Debug, Deserialize)] struct HouseEmergencyStopPatch { active: bool, } async fn update_house_emergency_stop( State(state): State, Json(input): Json, ) -> Result, AppError> { // Automation execution already uses this lock before committing an action. Taking it first // gives the emergency stop a clean barrier: an in-flight action finishes, then no newer // automatic action can cross the persisted safety flag. let _automation_guard = state.lock_automation_operation().await; let _house_guard = state.lock_house_operation().await; // Persist the safety gate while automatic control is serialized. Once the flag is stored, // neither the background regulator nor an immediate thermostat run can emit automation // commands until the user explicitly resumes normal operation. let cycle_guard = state.lock_zone_control_cycle().await; let (changed, emergency_stop_since) = { let mut settings = state.settings.write().await; let changed = settings.emergency_stop_enabled != input.active; if changed { settings.emergency_stop_enabled = input.active; settings.emergency_stop_since = if input.active { Some(Utc::now()) } else { None }; state.db.save_runtime_settings(&settings)?; } (changed, settings.emergency_stop_since.clone()) }; // OFF is intentionally a one-shot side effect of activating the emergency stop. The // persistent flag survives restarts, but startup never replays physical commands from it. let mut failed = Vec::new(); let mut cleared_queues = 0usize; if input.active && changed { cleared_queues = clear_all_automation_compressor_queues(&state).await?; failed = command_all_enabled_devices_power(&state, false, "house_emergency_stop").await?; } let payload = json!({ "active": input.active, "since": emergency_stop_since, "changed": changed, "failed": failed, "cleared_queues": cleared_queues, }); state.broadcast("house.emergency_stop_changed", payload.clone()); drop(cycle_guard); state.wake_zone_control(); // Resuming does not force any unit ON. It only releases the persistent safety gate and // immediately re-evaluates current schedules, temperatures and ownership from fresh state. if !input.active && changed && state.initial_device_sync_complete.load(Ordering::Acquire) { if let Err(err) = engine::run_zone_control_now(&state).await { state.log( "error", "house.emergency_resume_control_error", &err.to_string(), json!({"active": false}), ); } } state.log( "info", if input.active { "house.emergency_stop_activated" } else { "house.emergency_stop_released" }, if input.active { "Emergency stop activated; automatic climate control paused" } else { "Emergency stop released; automatic climate control resumed" }, json!({ "active": input.active, "changed": changed, "failed_devices": payload["failed"].as_array().map(Vec::len).unwrap_or(0), "cleared_queues": cleared_queues, "persistent_across_restart": true, "off_replayed_on_restart": false, }), ); Ok(Json(payload)) } async fn update_house_power( State(state): State, Json(input): Json, ) -> Result, AppError> { let _house_guard = state.lock_house_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; // Global power is a bulk thermostat action, not a persistent master gate. OFF stores every // thermostat as an indefinite local OFF so the next regulator cycle cannot immediately // resurrect demand. ON releases that OFF state and records an explicit automatic house-power // intent for otherwise-free zones. Later explicit local/group/manual actions remain // independent and can take over only the selected scope. // Persist the thermostat power intent before touching devices. The cycle lock held by this // handler guarantees that no setpoint-modulation cycle can race between the marker and OFF. let changed_zones = set_all_thermostat_power_state(&state, input.power).await?; let failed = command_all_enabled_devices_power(&state, input.power, "house_power_bulk").await?; state.wake_zone_control(); let devices = state.db.list_devices()?; let groups = state.db.list_groups()?; state.log( "info", "house.power_all", if input.power { "Whole-house ON sent; local OFF state released and house thermostat intent armed" } else { "Whole-house OFF sent; all thermostats left locally OFF until explicitly re-enabled" }, json!({ "power": input.power, "failed": failed.len(), "changed_zones": changed_zones, "one_shot": true, "persistent_global_gate": false, }), ); Ok(Json(json!({ "power": input.power, "one_shot": true, "devices": devices, "groups": groups, "failed": failed, }))) } #[derive(Debug, Deserialize)] struct HousePresetPatch { preset: String, } async fn update_house_preset( State(state): State, Json(input): Json, ) -> Result, AppError> { let _house_guard = state.lock_house_operation().await; let cycle_guard = state.lock_zone_control_cycle().await; if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") { return Err(AppError::BadRequest( "house preset must be auto, comfort, sleep or away".into(), )); } // A house profile applies to free house-controlled zones. Explicit local/group/direct // owners remain higher priority and are not cleared or re-armed by changing house rules. rearm_house_automation_compressor_queues(&state).await?; let schedules = state.db.list_schedules()?; let mut zone_ids: Vec = state .db .list_zones()? .into_iter() .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 mut zones = Vec::with_capacity(zone_ids.len()); for zone_id in &zone_ids { 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 scoped_manual = zone.device_manual_override || zone.local_thermostat_power.is_some() || zone.control_source.starts_with("group:") || engine::temporary_quick_thermostat_is_active(&zone, Utc::now()); if scoped_manual { // House rules never overwrite explicit manual/group/local ownership. The scoped // controller keeps its own target/profile until the user releases it. zones.push(zone); continue; } else if input.preset == "auto" { zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; } else { zone.manual_preset = Some(input.preset.clone()); zone.manual_setpoint = None; zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()); } zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); zones.push(zone); } // The immediate regulator cycle takes the same per-zone locks; release the batch guards // after the profile update is fully persisted to preserve the global zone -> device order. drop(_zone_guards); drop(cycle_guard); // Apply the whole-house profile before returning so every eligible thermostat gets the // same arbitration cycle and no member is left waiting behind the periodic interval. let mut failed: Vec = Vec::new(); if let Err(err) = engine::run_zone_control_now(&state).await { state.log( "error", "house.immediate_control_error", &err.to_string(), json!({"source":"house_preset"}), ); failed.push(json!({"scope":"thermostat_cycle","error":err.to_string()})); } let devices = state.db.list_devices()?; state.log( "info", "house.preset", &format!("House preset set to {}", input.preset), json!({ "preset": input.preset, "failed": failed.len(), }), ); Ok(Json(json!({ "preset": input.preset, "zones": zones, "devices": devices, "failed": failed, }))) } #[derive(Debug, Deserialize)] struct ScheduleTemplateRequest { template: String, } async fn apply_schedule_template( State(state): State, Path(id): Path, Json(input): Json, ) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _schedule_guard = state.lock_schedule_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; let zone = state .db .get_zone(&id)? .ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let mut items: Vec = Vec::new(); let mut add = |name: &str, days: Vec, start: &str, end: &str, preset: &str| { items.push(Schedule { id: Uuid::new_v4().to_string(), zone_id: id.clone(), name: name.into(), enabled: true, weekdays: days, start_time: start.into(), end_time: end.into(), preset: preset.into(), setpoint: zone.setpoint, created_at: Utc::now(), updated_at: Utc::now(), flow_id: None, flow_node_id: None, }); }; let all = vec![1, 2, 3, 4, 5, 6, 7]; match input.template.as_str() { "family" => { add("Comfort", all.clone(), "06:30", "22:30", "comfort"); add("Sleep", all, "22:30", "06:30", "sleep"); } "child" => { add("Comfort", all.clone(), "06:30", "20:30", "comfort"); add("Sleep", all, "20:30", "06:30", "sleep"); } "bedroom" => { add("Comfort", all.clone(), "06:30", "22:00", "comfort"); add("Sleep", all, "22:00", "06:30", "sleep"); } "workday" => { let weekdays = vec![1, 2, 3, 4, 5]; let weekend = vec![6, 7]; add("Morning", weekdays.clone(), "06:30", "08:00", "comfort"); add("Away", weekdays.clone(), "08:00", "16:00", "away"); add("Evening", weekdays.clone(), "16:00", "22:30", "comfort"); add("Sleep", weekdays, "22:30", "06:30", "sleep"); add("Weekend", weekend, "08:00", "23:00", "comfort"); // Saturday can sleep until the Sunday weekend block starts at 08:00. add("Saturday sleep", vec![6], "23:00", "08:00", "sleep"); // Sunday must hand over at 06:30 so it never overlaps Monday morning. add("Sunday sleep", vec![7], "23:00", "06:30", "sleep"); } "always" => add("Comfort", all, "00:00", "00:00", "comfort"), _ => return Err(AppError::BadRequest("unknown schedule template".into())), } validate_schedule_set(&items)?; state.db.replace_schedules_for_zone(&id, &items)?; refresh_zone_override_boundary(&state, &id).await?; state.broadcast( "schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()}), ); state.wake_zone_control(); Ok(Json(json!({"zone": zone, "schedules": items}))) } async fn update_home_assistant_zone_control( State(state): State, Path(id): Path, Json(patch): Json, ) -> Result, AppError> { Ok(Json( apply_zone_control_patch(&state, &id, patch, "home_assistant.zone_thermostat").await?, )) } async fn delete_zone( State(state): State, Path(id): Path, ) -> Result { let _configuration_guard = state.lock_configuration_operation().await; let _automation_guard = state.lock_automation_operation().await; let _house_guard = state.lock_house_operation().await; let _schedule_guard = state.lock_schedule_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; let zone_guard = state.lock_zone_operation(&id).await; let zone = state .db .get_zone(&id)? .ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let mut removed = std::collections::HashSet::new(); removed.insert(id.clone()); ensure_zone_removal_safe(&state, &removed)?; ensure_device_stopped_for_detach(&state, &zone.device_id, "zone.deleted").await?; if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); } // Group control locks group first and zone second. Release the zone lock before taking // group locks so deletion cannot form the inverse zone -> group lock order. drop(zone_guard); remove_zone_ids_from_groups_locked(&state, &removed).await?; state.broadcast("zone.deleted", json!({"id": id})); Ok(StatusCode::NO_CONTENT) }