#[derive(Debug, Deserialize)] struct HouseControlPatch { mode: String } async fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> { let mut group_ids: Vec = state.db.list_groups()?.into_iter().map(|group| group.id).collect(); group_ids.sort(); group_ids.dedup(); let mut _group_guards = Vec::with_capacity(group_ids.len()); for group_id in &group_ids { _group_guards.push(state.lock_group_operation(group_id).await); } for mut group in state.db.list_groups()? { if group.power_enabled == power { continue; } group.power_enabled = power; group.updated_at = Utc::now(); state.db.save_group(&group)?; state.broadcast("group.updated", serde_json::to_value(&group)?); } Ok(()) } fn clear_all_local_thermostat_overrides(state: &AppState) -> Result { let mut cleared = 0; for mut zone in state.db.list_zones()? { if zone.local_thermostat_power.is_none() && zone.local_thermostat_resume_at.is_none() && zone.temporary_quick_thermostat.is_none() { continue; } let temporary_was_active = engine::temporary_quick_thermostat_is_active(&zone, Utc::now()); let temporary_restore = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.restore_zone_enabled); zone.temporary_quick_thermostat = None; engine::reset_local_thermostat_override(&mut zone); if temporary_was_active { if let Some(enabled) = temporary_restore { zone.enabled = enabled; } } zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); cleared += 1; } Ok(cleared) } async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result, AppError> { let mut failed = Vec::new(); let enabled_zone_devices: std::collections::HashSet = if power { state.db.list_zones()?.into_iter() .filter(|zone| zone.enabled && !zone.device_manual_override && zone.local_thermostat_power != Some(false)) .map(|zone| zone.device_id) .collect() } else { std::collections::HashSet::new() }; for device in state.db.list_devices()? { if !device.enabled { continue; } // Whole-house ON only operates thermostat-managed, enabled zones. Devices with // a disabled zone (or no zone at all) remain manual/technical Devices controls. if power && !enabled_zone_devices.contains(&device.id) { continue; } // Do not trust the pre-loop power snapshot for deciding whether to send. The engine // reloads state under the per-device lock and turns an already-matching command into // a no-op. This closes the polling/command race without extra UDP frames. let result = if power { engine::send_command(state, &device.id, DeviceCommand { power: Some(true), ..Default::default() }).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 update_house_control(State(state): State, Json(input): Json) -> Result, AppError> { let _house_guard = state.lock_house_operation().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 payload = { let mut settings = state.settings.write().await; settings.house_mode = mode.clone(); // Choosing a real whole-house operating mode is an explicit request to run the // house climate. It therefore clears a previous global power-off. "off" keeps // its separate meaning: do not control, without changing master power. if activate_all { settings.house_power_enabled = true; } state.db.save_runtime_settings(&settings)?; public_settings(&settings) }; state.broadcast("settings.updated", payload.clone()); if activate_all { set_all_groups_power(&state, true).await?; // Never send a bare power=true frame. Wake the thermostat arbiter so every unit // starts only with a valid effective Heat/Cool mode and compressor lockout policy. state.wake_zone_control(); } state.log("info", "house.mode", &format!("House mode set to {}", mode), json!({"mode": mode, "master_power_enabled": activate_all})); Ok(Json(payload)) } #[derive(Debug, Deserialize)] struct HousePowerPatch { power: bool } async fn update_house_power(State(state): State, Json(input): Json) -> Result, AppError> { let _house_guard = state.lock_house_operation().await; // Whole-house power is independent from the thermostat mode. Publish/persist the master // first so the regulator becomes passive before the one-shot OFF cascade starts. { let mut settings = state.settings.write().await; if settings.house_power_enabled != input.power { settings.house_power_enabled = input.power; state.db.save_runtime_settings(&settings)?; let payload = public_settings(&settings); state.broadcast("settings.updated", payload); } } // Global power is a true cascade across group gates. OFF clears the current takeover // markers once, then each enabled device is re-cleared atomically with its OFF command. // A later pilot action is therefore not erased by subsequent controller cycles. set_all_groups_power(&state, input.power).await?; if !input.power { let zone_snapshot = state.db.list_zones()?; let mut zone_ids: Vec = zone_snapshot.iter().map(|zone| zone.id.clone()).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 device_ids: Vec = state.db.list_zones()?.into_iter().map(|zone| zone.device_id).collect(); device_ids.sort(); device_ids.dedup(); let mut device_guards = Vec::with_capacity(device_ids.len()); for device_id in &device_ids { device_guards.push(state.lock_device_operation(device_id).await); } engine::clear_all_device_manual_overrides(&state, "house_power_off")?; clear_all_local_thermostat_overrides(&state)?; drop(device_guards); drop(zone_guards); } let failed = if input.power { state.wake_zone_control(); Vec::new() } else { command_all_enabled_devices_power(&state, false, "house_power").await? }; let devices = state.db.list_devices()?; let groups = state.db.list_groups()?; let settings = state.settings.read().await; let settings_payload = public_settings(&settings); drop(settings); state.log("info", "house.power_all", if input.power { "Whole-house automation enabled; thermostat arbiter resumed" } else { "Whole-house power disabled; all groups and enabled devices powered off" }, json!({ "power": input.power, "failed": failed.len(), })); Ok(Json(json!({ "power": input.power, "devices": devices, "groups": groups, "settings": settings_payload, "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; 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 whole-house profile is also an explicit whole-house activation. This mirrors // selecting cooling/heating and makes the separate master-power control intuitive. let settings_payload = { let mut settings = state.settings.write().await; settings.house_power_enabled = true; state.db.save_runtime_settings(&settings)?; public_settings(&settings) }; state.broadcast("settings.updated", settings_payload.clone()); set_all_groups_power(&state, true).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; }; if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { if let Some(session) = zone.temporary_quick_thermostat.as_mut() { session.deferred_preset = Some(input.preset.clone()); session.deferred_setpoint = None; } } 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); } // As with house mode/power ON, the central thermostat arbiter performs the physical // start with a valid mode/target. This prevents unmanaged power-on while house mode=off. let failed: Vec = Vec::new(); state.wake_zone_control(); let devices = state.db.list_devices()?; state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({ "preset": input.preset, "master_power_enabled": true, "failed": failed.len(), })); Ok(Json(json!({ "preset": input.preset, "zones": zones, "devices": devices, "settings": settings_payload, "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 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(), }); }; 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()})); 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 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) }