This commit is contained in:
Mateusz Gruszczyński
2026-09-01 13:05:44 +02:00
parent e496f911da
commit 7e0160834d
32 changed files with 486 additions and 67 deletions
+75
View File
@@ -109,6 +109,7 @@ impl ZoneInput {
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None,
revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "zone created".into(),
last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None,
compressor_pending_action: None, compressor_pending_since: None, compressor_pending_until: None, compressor_cancelled_action: None,
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None,
created_at, updated_at: Utc::now(),
@@ -208,6 +209,10 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
zone.last_mode_change_at = existing.last_mode_change_at;
zone.lockout_until = existing.lockout_until;
zone.lockout_reason = existing.lockout_reason;
zone.compressor_pending_action = existing.compressor_pending_action;
zone.compressor_pending_since = existing.compressor_pending_since;
zone.compressor_pending_until = existing.compressor_pending_until;
zone.compressor_cancelled_action = existing.compressor_cancelled_action;
zone.effective_mode = existing.effective_mode;
zone.effective_setpoint = existing.effective_setpoint;
zone.device_setpoint = existing.device_setpoint;
@@ -234,6 +239,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
}
engine::reset_local_thermostat_override(&mut zone);
engine::reset_device_manual_override(&mut zone);
engine::rearm_compressor_queue(&mut zone);
zone.enabled = false;
}
state.db.save_zone(&zone)?;
@@ -263,6 +269,13 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
let mut zone = state.db.get_zone(id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let was_enabled = zone.enabled;
let schedules = state.db.list_schedules()?;
let rearm_compressor = patch.power.is_some() || patch.setpoint.is_some() || patch.mode.is_some()
|| patch.enabled.is_some() || patch.preset.is_some() || patch.clear_override.unwrap_or(false)
|| patch.clear_device_manual_override.unwrap_or(false) || patch.clear_local_thermostat_override.unwrap_or(false)
|| patch.temporary_quick_thermostat.is_some() || patch.clear_temporary_quick_thermostat.unwrap_or(false);
if rearm_compressor {
engine::rearm_compressor_queue(&mut zone);
}
let resume_device_takeover = patch.clear_device_manual_override.unwrap_or(false);
let resume_local_thermostat = patch.clear_local_thermostat_override.unwrap_or(false);
let stop_temporary_quick_thermostat = patch.clear_temporary_quick_thermostat.unwrap_or(false);
@@ -692,3 +705,65 @@ async fn power_off_zone_device(state: &AppState, zone: &Zone, source: &str) {
}
}
async fn cancel_zone_compressor_queue(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, AppError> {
// Serialize cancellation with the thermostat cycle. If cancellation wins this lock, the
// pending action cannot expire and execute between the UI click and the persisted marker.
let _cycle_guard = state.lock_zone_control_cycle().await;
let _zone_guard = state.lock_zone_operation(&id).await;
let mut zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let cancelled = zone.compressor_pending_action.clone();
if let Some(action) = cancelled.clone() {
zone.compressor_cancelled_action = Some(action);
zone.compressor_pending_action = None;
zone.compressor_pending_since = None;
zone.compressor_pending_until = None;
zone.lockout_until = None;
zone.lockout_reason = None;
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
state.log("info", "zone.compressor_queue_cancelled", &format!("Cancelled compressor-protection task for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "action": cancelled.clone()
}));
}
if cancelled.is_some() { state.wake_zone_control(); }
Ok(Json(json!({"cancelled": cancelled.is_some(), "action": cancelled, "zone": zone})))
}
async fn cancel_all_compressor_queues(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
// Stop the thermostat arbiter while taking all zone locks, so a task cannot expire and
// execute between discovering it and persisting the cancellation marker.
let _cycle_guard = state.lock_zone_control_cycle().await;
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
zone_ids.sort();
zone_ids.dedup();
let mut guards = Vec::with_capacity(zone_ids.len());
for zone_id in &zone_ids { guards.push(state.lock_zone_operation(zone_id).await); }
let mut cancelled = 0usize;
let mut zones = Vec::new();
for zone_id in &zone_ids {
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
let Some(action) = zone.compressor_pending_action.clone() else { continue; };
zone.compressor_cancelled_action = Some(action.clone());
zone.compressor_pending_action = None;
zone.compressor_pending_since = None;
zone.compressor_pending_until = None;
zone.lockout_until = None;
zone.lockout_reason = None;
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
state.log("info", "zone.compressor_queue_cancelled", &format!("Cancelled compressor-protection task for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "action": action, "source": "cancel_all"
}));
cancelled += 1;
zones.push(zone);
}
if cancelled > 0 { state.wake_zone_control(); }
Ok(Json(json!({"cancelled": cancelled, "zones": zones})))
}