v0.12.0-preety_code

This commit is contained in:
Mateusz Gruszczyński
2026-09-03 15:33:37 +02:00
parent be67932b47
commit 07be4b85d9
87 changed files with 11691 additions and 3172 deletions
+347 -111
View File
@@ -11,7 +11,9 @@ struct ConfigurationResourceGuards {
_devices: Vec<tokio::sync::OwnedMutexGuard<()>>,
}
async fn export_configuration(State(state): State<AppState>) -> Result<Json<ConfigurationExport>, AppError> {
async fn export_configuration(
State(state): State<AppState>,
) -> Result<Json<ConfigurationExport>, AppError> {
let settings = state.settings.read().await.clone();
let mut export = state.db.export_configuration(settings)?;
sanitize_configuration_runtime(&mut export);
@@ -23,21 +25,36 @@ fn validate_configuration_header(export: &ConfigurationExport) -> Result<(), App
return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.12.x".into()));
}
if export.settings.control_strategy != "setpoint" {
return Err(AppError::BadRequest("import contains an unsupported control strategy".into()));
return Err(AppError::BadRequest(
"import contains an unsupported control strategy".into(),
));
}
influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
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()));
return Err(AppError::BadRequest(
"import contains an invalid house mode".into(),
));
}
Ok(())
}
fn collect_configuration_ids(export: &ConfigurationExport) -> Result<ConfigurationIds<'_>, AppError> {
fn collect_configuration_ids(
export: &ConfigurationExport,
) -> Result<ConfigurationIds<'_>, 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(),
schedules: export.schedules.iter().map(|item| item.id.as_str()).collect(),
automations: export.automations.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()
@@ -51,22 +68,38 @@ fn collect_configuration_ids(export: &ConfigurationExport) -> Result<Configurati
|| ids.automations.contains("")
|| ids.flows.contains("");
if duplicate_or_empty {
return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into()));
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()
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)));
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()));
return Err(AppError::BadRequest(
"import contains an executable Flow draft".into(),
));
}
Ok(())
}
@@ -75,23 +108,44 @@ fn validate_configuration_devices_and_zones(
export: &ConfigurationExport,
ids: &ConfigurationIds<'_>,
) -> Result<(), AppError> {
let device_macs: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.mac.as_str()).collect();
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()));
return Err(AppError::BadRequest(
"import contains duplicate device MAC addresses".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()));
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()));
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()));
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 !matches!(
zone.sensor_source.as_str(),
"device" | "home_assistant" | "combined"
) {
return Err(AppError::BadRequest(
"import contains an invalid zone sensor source".into(),
));
}
}
Ok(())
@@ -101,25 +155,48 @@ 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()));
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
.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()));
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()));
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()));
return Err(AppError::BadRequest(
"import contains an invalid schedule setpoint".into(),
));
}
}
validate_schedule_set(&export.schedules)?;
@@ -131,17 +208,27 @@ fn validate_configuration_groups<'a>(
ids: &ConfigurationIds<'_>,
) -> Result<std::collections::HashSet<&'a str>, AppError> {
if export.groups.iter().any(|group| {
let members: std::collections::HashSet<&str> = group.zone_ids.iter().map(String::as_str).collect();
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()))
|| 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()));
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();
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()));
return Err(AppError::BadRequest(
"import contains duplicate group IDs".into(),
));
}
Ok(groups)
}
@@ -153,42 +240,78 @@ fn validate_configuration_automation_trigger(
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()));
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()));
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()))?;
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()));
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())),
_ => {
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(()); };
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()));
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()));
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))
&& 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()));
return Err(AppError::BadRequest(
"import contains an invalid Flow thermostat target".into(),
));
}
Ok(())
}
@@ -197,30 +320,51 @@ 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(()); };
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()));
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()));
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");
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 !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()));
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()));
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()));
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()
@@ -233,13 +377,21 @@ fn validate_configuration_group_automation(
|| item.action.health.is_some()
|| item.action.sleep.is_some()
{
return Err(AppError::BadRequest("import contains unsupported device fields in a group automation".into()));
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()
&& item
.action_preset
.as_deref()
.filter(|value| !value.is_empty())
.is_none()
{
return Err(AppError::BadRequest("import contains an empty group automation action".into()));
return Err(AppError::BadRequest(
"import contains an empty group automation action".into(),
));
}
Ok(())
}
@@ -250,14 +402,18 @@ fn validate_configuration_shared_inputs(
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 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()));
return Err(AppError::BadRequest(
"import contains a shared Flow input referencing a missing resource".into(),
));
}
}
Ok(())
@@ -270,17 +426,29 @@ fn validate_configuration_automations(
) -> 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()) {
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()) {
} 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()));
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()));
return Err(AppError::BadRequest(
"import contains an empty automation action".into(),
));
}
}
}
@@ -366,8 +534,12 @@ fn sanitize_imported_zone(zone: &mut Zone, now: chrono::DateTime<Utc>) {
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 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();
@@ -387,25 +559,32 @@ fn normalize_imported_runtime_settings(settings: &mut RuntimeSettings) -> Result
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);
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(&current_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.notifications = apply_notification_update(
&current_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);
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);
@@ -428,23 +607,34 @@ async fn lock_configuration_resources(
current_devices: &[Device],
export: &ConfigurationExport,
) -> ConfigurationResourceGuards {
let mut zone_ids: Vec<String> = current_zones.iter().map(|zone| zone.id.clone())
let mut zone_ids: Vec<String> = 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); }
for zone_id in &zone_ids {
zone_guards.push(state.lock_zone_operation(zone_id).await);
}
let mut device_ids: Vec<String> = current_devices.iter().map(|device| device.id.clone())
let mut device_ids: Vec<String> = 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); }
for device_id in &device_ids {
device_guards.push(state.lock_device_operation(device_id).await);
}
ConfigurationResourceGuards { _zones: zone_guards, _devices: device_guards }
ConfigurationResourceGuards {
_zones: zone_guards,
_devices: device_guards,
}
}
async fn power_off_detached_devices(
@@ -452,37 +642,71 @@ async fn power_off_detached_devices(
current_zones: &[Zone],
export: &ConfigurationExport,
) -> Result<(), AppError> {
let imported_zone_map: std::collections::HashMap<String, String> = export.zones.iter()
let imported_zone_map: std::collections::HashMap<String, String> = export
.zones
.iter()
.map(|zone| (zone.id.clone(), zone.device_id.clone()))
.collect();
let detach_devices: std::collections::HashSet<String> = current_zones.iter()
.filter(|current| imported_zone_map.get(&current.id).map(String::as_str) != Some(current.device_id.as_str()))
let detach_devices: std::collections::HashSet<String> = current_zones
.iter()
.filter(|current| {
imported_zone_map.get(&current.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; };
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"
}));
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<String> = export.zones.iter()
async fn reconcile_imported_devices(
state: &AppState,
export: &ConfigurationExport,
) -> Result<(), AppError> {
let controllable_devices: std::collections::HashSet<String> = export
.zones
.iter()
.filter(|zone| {
let effective_mode = if zone.inherit_house_mode { export.settings.house_mode.as_str() } else { zone.mode.as_str() };
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)) {
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}));
state.log(
"error",
"configuration.import_reconcile_error",
&err.to_string(),
json!({"device_id": device.id}),
);
return Err(err);
}
}
@@ -504,18 +728,30 @@ async fn import_configuration(
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, &current_zones, &current_devices, &export).await;
let _resource_guards =
lock_configuration_resources(&state, &current_zones, &current_devices, &export).await;
power_off_detached_devices(&state, &current_zones, &export).await?;
sanitize_configuration_runtime(&mut export);
state.initial_device_sync_complete.store(false, Ordering::Release);
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_gree_frames
.store(export.settings.debug.gree_frames, 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
.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.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})))
}