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
+30 -8
View File
@@ -128,9 +128,15 @@ async fn get_zone(State(state): State<AppState>, Path(id): Path<String>) -> Resu
state.db.get_zone(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("zone {id}")))
}
async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>) -> Result<(StatusCode, Json<Zone>), AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let _reference_guard = state.lock_automation_operation().await;
input.validate()?;
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
validate_zone_device_assignment(&state, &input.device_id, None)?;
// Creating thermostat ownership must not overlap a poll of the device. Otherwise a poll
// that started before the zone existed could apply its old physical-control snapshot to
// the newly created zone without participating in the zone operation lock.
let _device_guard = state.lock_device_operation(&input.device_id).await;
let mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now());
let settings = state.settings.read().await.clone();
canonicalize_zone_ha_entity(&mut zone, &settings);
@@ -139,6 +145,8 @@ async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>
Ok((StatusCode::CREATED, Json(zone)))
}
async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ZoneInput>) -> Result<Json<Zone>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let _reference_guard = state.lock_automation_operation().await;
input.validate()?;
let _zone_guard = state.lock_zone_operation(&id).await;
let mut existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
@@ -150,10 +158,16 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
validate_zone_device_assignment(&state, &input.device_id, Some(&id))?;
let device_changed = existing.device_id != input.device_id;
// Serialize a normal zone edit with polling/manual-takeover detection for its device.
// Device reassignment uses ensure_device_stopped_for_detach below, which acquires the
// old device lock itself while this zone lock is held.
let _device_guard = if !device_changed { Some(state.lock_device_operation(&existing.device_id).await) } else { None };
// Keep the zone lock while taking all involved device locks in stable order. This makes a
// reassignment atomic against polling of both the old and the new unit and preserves the
// global zone -> device ordering used by live control paths.
let mut locked_device_ids = vec![existing.device_id.clone(), input.device_id.clone()];
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);
}
if !device_changed {
// Polling may have updated takeover/runtime state while we were waiting for the
// device lock. Re-read under both locks before building the replacement Zone.
@@ -203,7 +217,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
} else {
// A new physical unit starts with a clean ownership/runtime state. Never transfer
// demand, sensor cache or remote-control takeover from the previous device.
ensure_device_stopped_for_detach(&state, &existing.device_id, "zone.device_reassigned").await?;
ensure_device_stopped_for_detach_locked(&state, &existing.device_id, "zone.device_reassigned").await?;
zone.revision = existing.revision.saturating_add(1);
}
let settings = state.settings.read().await.clone();
@@ -223,7 +237,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
}
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
drop(_device_guard);
drop(device_guards);
if power_off_device {
power_off_zone_device(&state, &zone, "zone.disabled").await;
}
@@ -473,6 +487,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
} else { None },
deferred_mode: existing_session.as_ref().and_then(|session| session.deferred_mode.clone()),
deferred_preset: existing_session.as_ref().and_then(|session| session.deferred_preset.clone()),
deferred_setpoint: existing_session.as_ref().and_then(|session| session.deferred_setpoint),
safety_expires_at: if immediate_activation && is_temperature_condition {
safety_duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64))
} else { safety_expires_at },
@@ -546,6 +561,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
session.deferred_preset = Some(value.to_string());
if value != "custom" { session.deferred_setpoint = None; }
}
} else {
match value {
@@ -571,6 +587,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
session.deferred_preset = Some("auto".into());
session.deferred_setpoint = None;
}
} else {
zone.manual_preset = None;
@@ -639,20 +656,25 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> {
async fn ensure_device_stopped_for_detach_locked(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> {
let Some(device) = state.db.get_device(device_id)? else { return Ok(()); };
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()));
}
// Force one OFF transition even when the cached state already says OFF. A remote change
// may not have been polled yet and detaching must not leave a running unit without owner.
engine::force_power_off_device(state, device_id).await?;
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": source
}));
Ok(())
}
async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> {
let _device_guard = state.lock_device_operation(device_id).await;
ensure_device_stopped_for_detach_locked(state, device_id, source).await
}
async fn power_off_zone_device(state: &AppState, zone: &Zone, source: &str) {
let Ok(Some(device)) = state.db.get_device(&zone.device_id) else { return; };
if !device.enabled { return; }