This commit is contained in:
Mateusz Gruszczyński
2026-09-01 10:20:19 +02:00
parent 1a5c1305dc
commit 3479ed750d
34 changed files with 575 additions and 113 deletions
+54 -8
View File
@@ -4,6 +4,8 @@ async fn get_settings(State(state): State<AppState>) -> Json<Value> {
}
async fn update_settings(State(state): State<AppState>, Json(mut input): Json<RuntimeSettings>) -> Result<Json<Value>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let _house_guard = state.lock_house_operation().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(
@@ -46,7 +48,7 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
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)?;
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!({}));
@@ -84,8 +86,15 @@ fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &RuntimeSettings) {
zone.ha_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&configured));
}
fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> Result<(), AppError> {
for mut zone in state.db.list_zones()? {
async fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> Result<(), AppError> {
let mut zone_ids: Vec<String> = 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 {
@@ -314,6 +323,36 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
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 current_zones = state.db.list_zones()?;
let current_devices = state.db.list_devices()?;
let mut locked_zone_ids: Vec<String> = 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<String> = 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.
@@ -321,13 +360,20 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
.map(|zone| (zone.id.clone(), zone.device_id.clone()))
.collect();
let mut detach_devices = std::collections::HashSet::new();
for current in state.db.list_zones()? {
for current in &current_zones {
if imported_zone_map.get(&current.id).map(String::as_str) != Some(current.device_id.as_str()) {
detach_devices.insert(current.device_id);
detach_devices.insert(current.device_id.clone());
}
}
for device_id in detach_devices {
ensure_device_stopped_for_detach(&state, &device_id, "configuration.import").await?;
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.
@@ -353,7 +399,7 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
.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(&state, &device.id).await {
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);
}
@@ -361,7 +407,7 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
// 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(&state).await?;
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}));