struct ConfigurationIds<'a> { devices: std::collections::HashSet<&'a str>, zones: std::collections::HashSet<&'a str>, device_groups: std::collections::HashSet<&'a str>, schedules: std::collections::HashSet<&'a str>, automations: std::collections::HashSet<&'a str>, flows: std::collections::HashSet<&'a str>, } struct ConfigurationResourceGuards { _zones: Vec>, _devices: Vec>, } async fn export_configuration( State(state): State, ) -> Result, AppError> { let settings = state.settings.read().await.clone(); let mut export = state.db.export_configuration(settings)?; sanitize_configuration_runtime(&mut export); // Cloud credentials are account-scoped secrets and must never be returned to the frontend, // including configuration exports. Import can reuse the already configured password on the // target controller when account/region match. export.settings.gree_cloud.password.clear(); Ok(Json(export)) } fn validate_configuration_header(export: &ConfigurationExport) -> Result<(), AppError> { if export.format_version != 3 { return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.2".into())); } if export.settings.control_strategy != "setpoint" { return Err(AppError::BadRequest( "import contains an unsupported control strategy".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(), )); } Ok(()) } fn collect_configuration_ids( export: &ConfigurationExport, ) -> Result, AppError> { let ids = ConfigurationIds { devices: export.devices.iter().map(|item| item.id.as_str()).collect(), zones: export.zones.iter().map(|item| item.id.as_str()).collect(), device_groups: export.device_groups.iter().map(|item| item.id.as_str()).collect(), schedules: export .schedules .iter() .map(|item| item.id.as_str()) .collect(), automations: export .automations .iter() .map(|item| item.id.as_str()) .collect(), flows: export.flows.iter().map(|item| item.id.as_str()).collect(), }; let duplicate_or_empty = ids.devices.len() != export.devices.len() || ids.zones.len() != export.zones.len() || ids.device_groups.len() != export.device_groups.len() || ids.schedules.len() != export.schedules.len() || ids.automations.len() != export.automations.len() || ids.flows.len() != export.flows.len() || ids.devices.contains("") || ids.zones.contains("") || ids.device_groups.contains("") || ids.schedules.contains("") || ids.automations.contains("") || ids.flows.contains(""); if duplicate_or_empty { return Err(AppError::BadRequest( "import contains duplicate or empty resource IDs".into(), )); } Ok(ids) } fn validate_configuration_flows(export: &ConfigurationExport) -> Result<(), AppError> { let draft_flows: std::collections::HashSet<&str> = export .flows .iter() .filter(|item| item.draft) .map(|item| item.id.as_str()) .collect(); let executable_draft = export.flows.iter().any(|item| { item.draft && (item.enabled || !item.compiled_schedule_ids.is_empty() || !item.compiled_automation_ids.is_empty()) }) || export.schedules.iter().any(|item| { item.flow_id .as_deref() .is_some_and(|id| draft_flows.contains(id)) }) || export.automations.iter().any(|item| { item.flow_id .as_deref() .is_some_and(|id| draft_flows.contains(id)) }); if executable_draft { return Err(AppError::BadRequest( "import contains an executable Flow draft".into(), )); } Ok(()) } fn validate_configuration_devices_and_zones( export: &ConfigurationExport, ids: &ConfigurationIds<'_>, ) -> Result<(), AppError> { // The same physical unit may intentionally exist once as Local and once as GREE Cloud. // Reject duplicates only within the same explicit transport. let device_transport_macs: std::collections::HashSet<(ConnectionType, &str)> = export .devices .iter() .map(|item| (item.connection_type, item.mac.as_str())) .collect(); if device_transport_macs.len() != export.devices.len() { return Err(AppError::BadRequest( "import contains duplicate device MAC addresses for the same connection type".into(), )); } if export .zones .iter() .any(|item| !ids.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(), )); } } let mut installation_devices = std::collections::HashSet::new(); for group in &export.device_groups { if group.name.trim().is_empty() || group.device_ids.is_empty() { return Err(AppError::BadRequest( "import contains an empty split/multisplit installation".into(), )); } if group.kind == DeviceGroupKind::Split && group.device_ids.len() != 1 { return Err(AppError::BadRequest( "import contains a split installation with more than one unit".into(), )); } for device_id in &group.device_ids { if !ids.devices.contains(device_id.as_str()) { return Err(AppError::BadRequest( "import contains an installation referencing a missing device".into(), )); } if !installation_devices.insert(device_id.as_str()) { return Err(AppError::BadRequest( "import assigns one device to more than one split/multisplit installation".into(), )); } } if group.energy_device_id.as_deref().is_some_and(|id| !group.device_ids.iter().any(|member| member == id)) { return Err(AppError::BadRequest( "import contains an installation energy source outside the installation".into(), )); } if group.energy_source == EnergySourcePreference::GreeCloud && group.energy_device_id.is_none() { return Err(AppError::BadRequest( "import contains a GREE Cloud installation without an energy source device".into(), )); } if group.energy_source == EnergySourcePreference::HomeAssistant && group.ha_energy_entity_id.as_deref().map_or(true, |value| value.trim().is_empty()) { return Err(AppError::BadRequest( "import contains a Home Assistant installation without an energy entity".into(), )); } if let Some(energy_device_id) = group.energy_device_id.as_deref() { let energy_device = export.devices.iter().find(|device| device.id == energy_device_id).ok_or_else(|| { AppError::BadRequest("import contains an installation energy source referencing a missing device".into()) })?; if energy_device.connection_type != ConnectionType::GreeCloud || !energy_device.capabilities.energy_meter { return Err(AppError::BadRequest( "import contains an installation energy source without a GREE Cloud energy meter".into(), )); } } if group.ha_energy_entity_id.as_deref().is_some_and(|value| !value.trim().is_empty()) { if group.ha_energy_device_class.as_deref() != Some("energy") || !matches!(group.ha_energy_state_class.as_deref(), Some("total" | "total_increasing")) || !matches!(group.ha_energy_unit.as_deref().map(str::to_ascii_lowercase).as_deref(), Some("wh" | "kwh")) { return Err(AppError::BadRequest( "import contains an invalid Home Assistant cumulative energy entity".into(), )); } } if group.outdoor_temperature_device_id.as_deref().is_some_and(|id| !group.device_ids.iter().any(|member| member == id)) { return Err(AppError::BadRequest( "import contains an installation outdoor-temperature source outside the installation".into(), )); } } Ok(()) } fn validate_configuration_schedules( export: &ConfigurationExport, ids: &ConfigurationIds<'_>, ) -> Result<(), AppError> { if export .schedules .iter() .any(|item| !ids.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 .flow_id .as_deref() .is_some_and(|flow_id| !ids.flows.contains(flow_id)) { return Err(AppError::BadRequest( "import contains a Flow-generated schedule referencing a missing Flow".into(), )); } 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)?; Ok(()) } fn validate_configuration_groups<'a>( export: &'a ConfigurationExport, ids: &ConfigurationIds<'_>, ) -> Result, AppError> { 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| !ids.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(), )); } Ok(groups) } fn validate_configuration_automation_trigger( item: &Automation, ids: &ConfigurationIds<'_>, ) -> Result<(), AppError> { 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 !ids.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()) })?; } "flow" => { if item .flow_id .as_deref() .filter(|id| ids.flows.contains(*id)) .is_none() || item.flow_conditions.is_empty() { return Err(AppError::BadRequest( "import contains an invalid Flow-generated automation".into(), )); } } _ => { return Err(AppError::BadRequest( "import contains an unsupported automation trigger".into(), )) } } Ok(()) } fn validate_configuration_zone_automation( item: &Automation, ids: &ConfigurationIds<'_>, ) -> Result<(), AppError> { let Some(zone_id) = item .action_zone_id .as_deref() .filter(|value| !value.is_empty()) else { return Ok(()); }; if !ids.zones.contains(zone_id) { return Err(AppError::BadRequest( "import contains a Flow automation referencing a missing zone".into(), )); } if let Some(preset) = item.action_zone_preset.as_deref() { if !matches!(preset, "auto" | "custom" | "comfort" | "sleep" | "away") { return Err(AppError::BadRequest( "import contains an invalid Flow thermostat preset".into(), )); } } if item.action_zone_preset.as_deref() == Some("custom") && item .action .target_temperature .is_some_and(|value| !(8.0..=30.0).contains(&value)) { return Err(AppError::BadRequest( "import contains an invalid Flow thermostat target".into(), )); } Ok(()) } fn validate_configuration_group_automation( item: &Automation, groups: &std::collections::HashSet<&str>, ) -> Result<(), AppError> { let Some(group_id) = item .action_group_id .as_deref() .filter(|value| !value.is_empty()) else { return Ok(()); }; 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(), )); } } let flow_custom_group = item.flow_id.is_some() && item.action_preset.as_deref() == Some("custom"); if let Some(preset) = item.action_preset.as_deref() { if !matches!(preset, "auto" | "comfort" | "sleep" | "away") && !(flow_custom_group && preset == "custom") { return Err(AppError::BadRequest( "import contains an invalid group automation preset".into(), )); } } if flow_custom_group { let Some(target) = item.action.target_temperature else { return Err(AppError::BadRequest( "import contains a Flow custom group preset without a target".into(), )); }; if !(8.0..=30.0).contains(&target) { return Err(AppError::BadRequest( "import contains an invalid Flow group target".into(), )); } } else if item.action.target_temperature.is_some() { return Err(AppError::BadRequest( "import contains unsupported target temperature in a group automation".into(), )); } if 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 device fields in a group automation".into(), )); } if item.action.power.is_none() && item.action.mode.is_none() && item .action_preset .as_deref() .filter(|value| !value.is_empty()) .is_none() { return Err(AppError::BadRequest( "import contains an empty group automation action".into(), )); } Ok(()) } fn validate_configuration_shared_inputs( export: &ConfigurationExport, ids: &ConfigurationIds<'_>, groups: &std::collections::HashSet<&str>, ) -> Result<(), AppError> { for item in &export.settings.home_assistant.flow_inputs { let Some(reference) = shared_input_resource_reference(&item.kind, &item.config)? else { continue; }; let exists = match reference { SharedInputResourceReference::Device(id) => ids.devices.contains(id.as_str()), SharedInputResourceReference::Zone(id) => ids.zones.contains(id.as_str()), SharedInputResourceReference::Group(id) => groups.contains(id.as_str()), }; if !exists { return Err(AppError::BadRequest( "import contains a shared Flow input referencing a missing resource".into(), )); } } Ok(()) } fn validate_configuration_automations( export: &ConfigurationExport, ids: &ConfigurationIds<'_>, groups: &std::collections::HashSet<&str>, ) -> Result<(), AppError> { for item in &export.automations { validate_configuration_automation_trigger(item, ids)?; if item .action_zone_id .as_deref() .is_some_and(|value| !value.is_empty()) { validate_configuration_zone_automation(item, ids)?; } else if item .action_group_id .as_deref() .is_some_and(|value| !value.is_empty()) { validate_configuration_group_automation(item, groups)?; } else { if !ids.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 validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> { validate_configuration_header(export)?; validate_configuration_flows(export)?; let ids = collect_configuration_ids(export)?; validate_configuration_devices_and_zones(export, &ids)?; validate_configuration_schedules(export, &ids)?; let groups = validate_configuration_groups(export, &ids)?; validate_configuration_shared_inputs(export, &ids, &groups)?; validate_configuration_automations(export, &ids, &groups) } fn sanitize_imported_device(device: &mut Device, now: chrono::DateTime) { 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; } fn sanitize_imported_zone(zone: &mut Zone, now: chrono::DateTime) { 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.compressor_pending_action = None; zone.compressor_pending_since = None; zone.compressor_pending_until = None; zone.compressor_cancelled_action = 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; } fn sanitize_configuration_runtime(export: &mut ConfigurationExport) { let now = Utc::now(); for device in &mut export.devices { sanitize_imported_device(device, now.clone()); } for zone in &mut export.zones { sanitize_imported_zone(zone, now.clone()); } for automation in &mut export.automations { automation.last_fired_at = None; automation.updated_at = now.clone(); } } fn normalize_imported_runtime_settings(settings: &mut RuntimeSettings) -> Result<(), AppError> { let gree = normalize_gree_settings(gree_settings(settings))?; settings.controller_id = gree.controller_id; settings.poll_interval_seconds = gree.poll_interval_seconds; settings.zone_interval_seconds = gree.zone_interval_seconds; settings.discovery_timeout_ms = gree.discovery_timeout_ms; settings.discovery_broadcast = gree.discovery_broadcast; settings.ping_metrics_enabled = gree.ping_metrics_enabled; settings.ping_interval_seconds = gree.ping_interval_seconds; settings.ping_sample_count = gree.ping_sample_count; settings.suppress_device_beep = gree.suppress_device_beep; settings.compressor_protection_enabled = gree.compressor_protection_enabled; settings.compressor_protection_seconds = gree.compressor_protection_seconds; if crate::protocol::gree_cloud::region_base_url(settings.gree_cloud.region.trim()).is_none() { return Err(AppError::BadRequest("unsupported GREE Cloud region".into())); } settings.gree_cloud.polling_interval_seconds = settings.gree_cloud.polling_interval_seconds.clamp(30, 3600); settings.gree_cloud.connectivity_metrics_interval_seconds = settings.gree_cloud.connectivity_metrics_interval_seconds.clamp(30, 3600); settings.gree_cloud.connectivity_metrics_sample_count = settings.gree_cloud.connectivity_metrics_sample_count.clamp(1, 10); if settings.gree_cloud.account_id.trim().is_empty() { settings.gree_cloud.account_id = "default".into(); } settings.history_retention_days = settings.history_retention_days.clamp(1, 3650); settings.event_log_retention_days = settings.event_log_retention_days.clamp(1, 3650); settings.influxdb.history_threshold_days = settings.influxdb.history_threshold_days.clamp(1, 3650); influxdb::validate(&settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?; let current_notifications = settings.notifications.clone(); settings.notifications = apply_notification_update( ¤t_notifications, NotificationSettingsUpdate { enabled: current_notifications.enabled, mode: current_notifications.mode.clone(), provider: current_notifications.provider.clone(), pushover_app_token: Some(current_notifications.pushover_app_token.clone()), pushover_user_key: Some(current_notifications.pushover_user_key.clone()), slack_webhook_url: Some(current_notifications.slack_webhook_url.clone()), discord_webhook_url: Some(current_notifications.discord_webhook_url.clone()), cooldown_seconds: current_notifications.cooldown_seconds, communication_failure_threshold: current_notifications.communication_failure_threshold, target_timeout_minutes: current_notifications.target_timeout_minutes, alert_types: current_notifications.alert_types.clone(), }, )?; settings.home_assistant.sensor_stale_after_seconds = settings .home_assistant .sensor_stale_after_seconds .clamp(30, 86_400); normalize_sensor_aliases(&mut settings.home_assistant); normalize_flow_shared_inputs(&mut settings.home_assistant)?; canonicalize_home_assistant_entities(&mut settings.home_assistant); validate_home_assistant_url(&settings.home_assistant)?; validate_night_mode(&mut settings.night_mode)?; Ok(()) } fn prepare_configuration_import(export: &mut ConfigurationExport) -> Result<(), AppError> { normalize_imported_runtime_settings(&mut export.settings)?; for zone in &mut export.zones { canonicalize_zone_ha_entity(zone, &export.settings.home_assistant); } Ok(()) } async fn hydrate_imported_cloud_device_keys( state: &AppState, export: &mut ConfigurationExport, ) -> Result<(), AppError> { if export.settings.gree_cloud.password.trim().is_empty() { let current = state.settings.read().await.gree_cloud.clone(); if current.region.eq_ignore_ascii_case(&export.settings.gree_cloud.region) && current.username.eq_ignore_ascii_case(&export.settings.gree_cloud.username) && !current.password.trim().is_empty() { export.settings.gree_cloud.password = current.password; } } let needs_cloud_keys = export.devices.iter().any(|device| { device.connection_type == ConnectionType::GreeCloud && device.key.as_deref().unwrap_or_default().is_empty() }); if !needs_cloud_keys { return Ok(()); } let settings = &export.settings.gree_cloud; let mut api = crate::protocol::gree_cloud::GreeCloudApi::for_region( state.http.clone(), &settings.region, &settings.username, &settings.password, ) .map_err(|err| AppError::BadRequest(format!( "cannot restore GREE Cloud device secrets from account: {err}" )))?; api.login().await.map_err(|err| { AppError::BadRequest(format!( "cannot restore GREE Cloud device secrets: account login failed: {err}" )) })?; let discovered = api.get_all_devices().await.map_err(|err| { AppError::BadRequest(format!( "cannot restore GREE Cloud device secrets: discovery failed: {err}" )) })?; for device in export .devices .iter_mut() .filter(|device| device.connection_type == ConnectionType::GreeCloud) { if device.key.as_deref().is_some_and(|key| !key.is_empty()) { continue; } let cloud_id = device.cloud_device_id.as_deref().unwrap_or(&device.mac); let Some(found) = discovered .iter() .find(|candidate| candidate.mac.eq_ignore_ascii_case(cloud_id)) else { return Err(AppError::BadRequest(format!( "cannot restore GREE Cloud device {}: it is not present in the configured account", device.name ))); }; device.key = Some(found.key.clone()); let normalized_cloud_mac = found.mac.replace([':', '-'], "").to_ascii_uppercase(); device.cloud_device_id = Some(normalized_cloud_mac.clone()); device.cloud_parent_mac = Some(crate::protocol::gree_cloud::parent_mac(&normalized_cloud_mac)); device.cloud_account_id = Some(settings.account_id.clone()); if device.model.trim().is_empty() { device.model = found.model.clone().unwrap_or_default(); } if device.firmware.trim().is_empty() { device.firmware = found.version.clone().unwrap_or_default(); } } Ok(()) } async fn lock_configuration_resources( state: &AppState, current_zones: &[Zone], current_devices: &[Device], export: &ConfigurationExport, ) -> ConfigurationResourceGuards { let mut zone_ids: Vec = current_zones .iter() .map(|zone| zone.id.clone()) .chain(export.zones.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 = current_devices .iter() .map(|device| device.id.clone()) .chain(export.devices.iter().map(|device| device.id.clone())) .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); } ConfigurationResourceGuards { _zones: zone_guards, _devices: device_guards, } } async fn power_off_detached_devices( state: &AppState, current_zones: &[Zone], export: &ConfigurationExport, ) -> Result<(), AppError> { let imported_zone_map: std::collections::HashMap = export .zones .iter() .map(|zone| (zone.id.clone(), zone.device_id.clone())) .collect(); let detach_devices: std::collections::HashSet = current_zones .iter() .filter(|current| { imported_zone_map.get(¤t.id).map(String::as_str) != Some(current.device_id.as_str()) }) .map(|current| current.device_id.clone()) .collect(); 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" }), ); } Ok(()) } async fn reconcile_imported_devices( state: &AppState, export: &ConfigurationExport, ) -> Result<(), AppError> { 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() }; 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", "configuration.import_reconcile_error", &err.to_string(), json!({"device_id": device.id}), ); return Err(err); } } engine::poll_all_locked(state).await?; Ok(()) } async fn import_configuration( State(state): State, Json(mut export): Json, ) -> Result, AppError> { validate_configuration_export(&export)?; prepare_configuration_import(&mut export)?; hydrate_imported_cloud_device_keys(&state, &mut export).await?; 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 _resource_guards = lock_configuration_resources(&state, ¤t_zones, ¤t_devices, &export).await; power_off_detached_devices(&state, ¤t_zones, &export).await?; 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 .debug_cloud_requests .store(export.settings.debug.cloud_requests, Ordering::Relaxed); state .debug_cloud_mqtt .store(export.settings.debug.cloud_mqtt, Ordering::Relaxed); *state.settings.write().await = export.settings.clone(); reconcile_imported_devices(&state, &export).await?; state .initial_device_sync_complete .store(true, Ordering::Release); state.wake_zone_control(); state.log( "info", "configuration.imported", "Application configuration imported", json!({"format_version": export.format_version}), ); state.broadcast("configuration.imported", json!({"at": Utc::now()})); Ok(Json(json!({"ok": true}))) }