v0.8.19
This commit is contained in:
+30
-8
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user