async fn get_settings(State(state): State) -> Json { let settings = state.settings.read().await; Json(public_settings(&*settings)) } async fn update_settings(State(state): State, Json(mut input): Json) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _house_guard = state.lock_house_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; let old = state.settings.read().await.clone(); if input.house_power_enabled != old.house_power_enabled || input.house_mode != old.house_mode { return Err(AppError::BadRequest( "house_power_enabled and house_mode must be changed through the House Control API".into(), )); } input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600); input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600); input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000); if !matches!(input.house_mode.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); } if input.control_strategy != "setpoint" { input.control_strategy = "setpoint".into(); } if !(input.discovery_broadcast.eq_ignore_ascii_case("auto") || input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:")) { input.discovery_broadcast.parse::() .map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?; } if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; } if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; } input.home_assistant.sensor_stale_after_seconds = input.home_assistant.sensor_stale_after_seconds.clamp(30, 86_400); if input.notifications.pushover_app_token.trim().is_empty() { input.notifications.pushover_app_token = old.notifications.pushover_app_token; } if input.notifications.pushover_user_key.trim().is_empty() { input.notifications.pushover_user_key = old.notifications.pushover_user_key; } if input.notifications.slack_webhook_url.trim().is_empty() { input.notifications.slack_webhook_url = old.notifications.slack_webhook_url; } if input.notifications.discord_webhook_url.trim().is_empty() { input.notifications.discord_webhook_url = old.notifications.discord_webhook_url; } input.notifications.cooldown_seconds = input.notifications.cooldown_seconds.clamp(30, 86_400); input.notifications.communication_failure_threshold = input.notifications.communication_failure_threshold.clamp(2, 100); input.notifications.target_timeout_minutes = input.notifications.target_timeout_minutes.clamp(5, 24 * 60); if !matches!(input.notifications.mode.as_str(), "problems" | "important") { return Err(AppError::BadRequest("notification mode must be problems or important".into())); } if !matches!(input.notifications.provider.as_str(), "pushover" | "slack" | "discord") { return Err(AppError::BadRequest("unsupported notification provider".into())); } input.history_retention_days = input.history_retention_days.clamp(1, 3650); input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650); normalize_sensor_aliases(&mut input); canonicalize_home_assistant_entities(&mut input); validate_night_mode(&mut input)?; input.influxdb.history_threshold_days = input.influxdb.history_threshold_days.clamp(1, 3650); if input.influxdb.token.trim().is_empty() { input.influxdb.token = old.influxdb.token; } if input.influxdb.password.trim().is_empty() { input.influxdb.password = old.influxdb.password; } influxdb::validate(&input.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?; if !input.home_assistant.url.trim().is_empty() { let parsed = url::Url::parse(&input.home_assistant.url).map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?; if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); } } state.db.save_runtime_settings(&input)?; canonicalize_saved_zone_entities(&state, &input).await?; state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed); *state.settings.write().await = input.clone(); state.log("info", "settings.updated", "Settings updated", json!({})); state.broadcast("settings.updated", public_settings(&input)); state.wake_zone_control(); Ok(Json(public_settings(&input))) } fn normalize_sensor_aliases(settings: &mut RuntimeSettings) { settings.home_assistant.sensor_aliases = settings.home_assistant.sensor_aliases .iter() .filter_map(|(entity, alias)| { let entity = entity.trim(); let alias = alias.trim(); if entity.is_empty() || alias.is_empty() { return None; } Some((entity.chars().take(160).collect::(), alias.chars().take(80).collect::())) }) .collect(); } fn canonicalize_home_assistant_entities(settings: &mut RuntimeSettings) { let default_entity = settings.home_assistant.default_entity_id.clone(); if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&default_entity)) { settings.home_assistant.default_entity_id = entity_id; } let outdoor_entity = settings.home_assistant.outdoor_entity_id.clone(); if !outdoor_entity.trim().is_empty() { if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&outdoor_entity)) { settings.home_assistant.outdoor_entity_id = entity_id; } } } fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &RuntimeSettings) { let Some(configured) = zone.ha_entity_id.clone() else { return; }; zone.ha_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&configured)); } async fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> 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(snapshot) = state.db.get_zone(&zone_id)? else { continue; }; let _device_guard = state.lock_device_operation(&snapshot.device_id).await; let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; let previous = zone.ha_entity_id.clone(); canonicalize_zone_ha_entity(&mut zone, settings); if zone.ha_entity_id != previous { zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); } } Ok(()) } fn validate_night_mode(settings: &mut RuntimeSettings) -> Result<(), AppError> { NaiveTime::parse_from_str(&settings.night_mode.start_time, "%H:%M") .map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?; NaiveTime::parse_from_str(&settings.night_mode.end_time, "%H:%M") .map_err(|_| AppError::BadRequest("night mode end time must use HH:MM".into()))?; settings.night_mode.max_fan_speed = settings.night_mode.max_fan_speed.clamp(1, 5); Ok(()) } async fn export_settings(State(state): State) -> Result, AppError> { let settings = state.settings.read().await.clone(); let mut export = state.db.export_configuration(settings)?; // Backups are configuration snapshots, not a way to resurrect transient ownership, // timers or a stale physical device state after restore (K9). sanitize_configuration_runtime(&mut export); Ok(Json(export)) } fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> { if export.format_version != 1 { return Err(AppError::BadRequest("unsupported configuration export version".into())); } influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?; if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("import contains an invalid house mode".into())); } let devices: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.id.as_str()).collect(); let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect(); let schedules: std::collections::HashSet<&str> = export.schedules.iter().map(|item| item.id.as_str()).collect(); let automations: std::collections::HashSet<&str> = export.automations.iter().map(|item| item.id.as_str()).collect(); if devices.len() != export.devices.len() || zones.len() != export.zones.len() || schedules.len() != export.schedules.len() || automations.len() != export.automations.len() || devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("") { return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into())); } let device_macs: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.mac.as_str()).collect(); if device_macs.len() != export.devices.len() { return Err(AppError::BadRequest("import contains duplicate device MAC addresses".into())); } if export.zones.iter().any(|item| !devices.contains(item.device_id.as_str())) { return Err(AppError::BadRequest("import contains a zone referencing a missing device".into())); } let mut zone_devices = std::collections::HashSet::new(); for zone in &export.zones { if !zone_devices.insert(zone.device_id.as_str()) { return Err(AppError::BadRequest("import assigns one device to more than one thermostat zone".into())); } if !matches!(zone.mode.as_str(), "cool" | "heat") { return Err(AppError::BadRequest("import contains an invalid zone mode".into())); } if !matches!(zone.sensor_source.as_str(), "device" | "home_assistant" | "combined") { return Err(AppError::BadRequest("import contains an invalid zone sensor source".into())); } } if export.schedules.iter().any(|item| !zones.contains(item.zone_id.as_str())) { return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into())); } for item in &export.schedules { if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) { return Err(AppError::BadRequest("import contains invalid schedule weekdays".into())); } NaiveTime::parse_from_str(&item.start_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule start time".into()))?; NaiveTime::parse_from_str(&item.end_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule end time".into()))?; if !matches!(item.preset.as_str(), "comfort" | "sleep" | "away" | "custom") { return Err(AppError::BadRequest("import contains an invalid schedule preset".into())); } if item.preset == "custom" && !(8.0..=30.0).contains(&item.setpoint) { return Err(AppError::BadRequest("import contains an invalid schedule setpoint".into())); } } validate_schedule_set(&export.schedules)?; if export.groups.iter().any(|group| { let members: std::collections::HashSet<&str> = group.zone_ids.iter().map(String::as_str).collect(); group.id.trim().is_empty() || group.zone_ids.is_empty() || members.len() != group.zone_ids.len() || group.zone_ids.iter().any(|zone_id| !zones.contains(zone_id.as_str())) }) { return Err(AppError::BadRequest("import contains an invalid group, duplicate members or a missing zone reference".into())); } let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect(); if groups.len() != export.groups.len() { return Err(AppError::BadRequest("import contains duplicate group IDs".into())); } for item in &export.automations { match item.trigger_kind.as_str() { "temperature_above" | "temperature_below" => { let Some(trigger_id) = item.trigger_device_id.as_deref() else { return Err(AppError::BadRequest("import contains a temperature automation without a trigger device".into())); }; if !devices.contains(trigger_id) || item.threshold.is_none() { return Err(AppError::BadRequest("import contains an invalid temperature automation trigger".into())); } } "time" => { let at = item.at_time.as_deref().ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?; NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?; } _ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())), } if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) { if !groups.contains(group_id) { return Err(AppError::BadRequest("import contains an automation referencing a missing group".into())); } if let Some(mode) = item.action.mode.as_deref() { if !matches!(mode, "auto" | "house" | "cool" | "heat") { return Err(AppError::BadRequest("import contains an invalid group automation mode".into())); } } if let Some(preset) = item.action_preset.as_deref() { if !matches!(preset, "auto" | "comfort" | "sleep" | "away") { return Err(AppError::BadRequest("import contains an invalid group automation preset".into())); } } if item.action.target_temperature.is_some() || item.action.fan_speed.is_some() || item.action.swing_vertical.is_some() || item.action.swing_horizontal.is_some() || item.action.quiet.is_some() || item.action.turbo.is_some() || item.action.light.is_some() || item.action.air.is_some() || item.action.xfan.is_some() || item.action.health.is_some() || item.action.sleep.is_some() { return Err(AppError::BadRequest("import contains unsupported fields in a group automation".into())); } if item.action.power.is_none() && item.action.mode.is_none() && item.action_preset.as_deref().filter(|v| !v.is_empty()).is_none() { return Err(AppError::BadRequest("import contains an empty group automation action".into())); } } else { if !devices.contains(item.action_device_id.as_str()) { return Err(AppError::BadRequest("import contains an automation referencing a missing device".into())); } engine::validate_command(&item.action)?; if item.action.is_empty() { return Err(AppError::BadRequest("import contains an empty automation action".into())); } } } Ok(()) } fn sanitize_configuration_runtime(export: &mut ConfigurationExport) { let now = Utc::now(); for device in &mut export.devices { device.power = false; device.mode = "cool".into(); device.target_temperature = 23.0; device.fan_speed = 0; device.swing_vertical = false; device.swing_horizontal = false; device.quiet = false; device.turbo = false; device.light = false; device.air = false; device.xfan = false; device.health = false; device.sleep = false; device.current_temperature = None; device.outdoor_temperature = None; device.online = false; device.response_time_ms = None; device.last_seen = None; device.last_error = None; device.communication_failures = 0; device.updated_at = now; } for zone in &mut export.zones { zone.device_temperature = None; zone.external_temperature = None; zone.current_temperature = None; zone.control_temperature_source = "device".into(); zone.active_preset = "comfort".into(); zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; zone.local_thermostat_power = None; zone.local_thermostat_resume_at = None; zone.local_thermostat_restore_zone_enabled = None; zone.temporary_quick_thermostat = None; 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; zone.control_owner = "automation".into(); zone.control_source = "automation".into(); zone.control_since = None; zone.control_resume_at = None; zone.control_reason = "Imported configuration; runtime ownership reset".into(); zone.last_power_change_at = None; zone.last_mode_change_at = None; zone.lockout_until = None; zone.lockout_reason = None; zone.effective_mode.clear(); zone.effective_setpoint = None; zone.device_setpoint = None; zone.demand = false; zone.demand_since = None; zone.target_alerted_at = None; zone.last_action_at = None; zone.revision = 0; zone.updated_at = now; } for automation in &mut export.automations { automation.last_fired_at = None; automation.updated_at = now; } } async fn import_settings(State(state): State, Json(mut export): Json) -> Result, AppError> { validate_configuration_export(&export)?; export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650); export.settings.event_log_retention_days = export.settings.event_log_retention_days.clamp(1, 3650); normalize_sensor_aliases(&mut export.settings); canonicalize_home_assistant_entities(&mut export.settings); for zone in &mut export.zones { canonicalize_zone_ha_entity(zone, &export.settings); } validate_night_mode(&mut export.settings)?; export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650); // Configuration replacement is the broadest mutation in the application. Serialize it // against every structural editor and every live owner that can write zone/device state. // Global lock order: configuration -> automation -> house -> schedule -> zones -> devices. 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 current_zones = state.db.list_zones()?; let current_devices = state.db.list_devices()?; let mut locked_zone_ids: Vec = current_zones.iter().map(|zone| zone.id.clone()) .chain(export.zones.iter().map(|zone| zone.id.clone())) .collect(); locked_zone_ids.sort(); locked_zone_ids.dedup(); let mut _zone_guards = Vec::with_capacity(locked_zone_ids.len()); for zone_id in &locked_zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); } let mut locked_device_ids: Vec = current_devices.iter().map(|device| device.id.clone()) .chain(export.devices.iter().map(|device| device.id.clone())) .collect(); 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); } // Before replacing ownership, safely stop every currently managed device whose zone is // removed or rewired by the imported configuration. Otherwise an orphaned physical unit // could keep running after its database owner disappears. let imported_zone_map: std::collections::HashMap = export.zones.iter() .map(|zone| (zone.id.clone(), zone.device_id.clone())) .collect(); let mut detach_devices = std::collections::HashSet::new(); for current in ¤t_zones { if imported_zone_map.get(¤t.id).map(String::as_str) != Some(current.device_id.as_str()) { detach_devices.insert(current.device_id.clone()); } } for device_id in detach_devices { let Some(device) = state.db.get_device(&device_id)? else { continue; }; 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())); } 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": "configuration.import" })); } // Configuration import never restores ephemeral owners/timers or cached physical state. // Imported devices are reconciled from a fresh poll and current house/zone gates. sanitize_configuration_runtime(&mut export); state.initial_device_sync_complete.store(false, Ordering::Release); state.db.replace_configuration(&export)?; state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed); *state.settings.write().await = export.settings.clone(); let controllable_devices: std::collections::HashSet = export.zones.iter() .filter(|zone| { let effective_mode = if zone.inherit_house_mode { export.settings.house_mode.as_str() } else { zone.mode.as_str() }; export.settings.house_power_enabled && zone.enabled && effective_mode != "off" }) .map(|zone| zone.device_id.clone()) .collect(); for device in export.devices.iter().filter(|device| device.enabled && !controllable_devices.contains(&device.id)) { if let Err(err) = engine::force_power_off_device_locked(&state, &device.id).await { state.log("error", "settings.import_reconcile_error", &err.to_string(), json!({"device_id": device.id})); return Err(err); } } // Rebuild live device snapshots before allowing the thermostat loop to make decisions. // Network failures are represented in device health by poll_one rather than reviving // imported cache values. engine::poll_all_locked(&state).await?; state.initial_device_sync_complete.store(true, Ordering::Release); state.wake_zone_control(); state.log("info", "settings.imported", "Application configuration imported", json!({"format_version": export.format_version})); state.broadcast("configuration.imported", json!({"at": Utc::now()})); Ok(Json(json!({"ok": true}))) }