This commit is contained in:
Mateusz Gruszczyński
2026-09-21 22:58:30 +02:00
parent 3b969c8754
commit d80005de33
33 changed files with 722 additions and 94 deletions
+122
View File
@@ -106,6 +106,38 @@ async fn command_all_enabled_devices_power(
Ok(failed)
}
async fn clear_all_automation_compressor_queues(state: &AppState) -> Result<usize, AppError> {
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 changed = 0usize;
for zone_id in zone_ids {
let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(mut zone) = state.db.get_zone(&zone_id)? else {
continue;
};
if zone.compressor_pending_action.is_none()
&& zone.compressor_cancelled_action.is_none()
&& zone.lockout_until.is_none()
&& zone.lockout_reason.is_none()
{
continue;
}
engine::rearm_compressor_queue(&mut zone);
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)?);
changed += 1;
}
Ok(changed)
}
async fn update_house_control(
State(state): State<AppState>,
Json(input): Json<HouseControlPatch>,
@@ -155,6 +187,96 @@ struct HousePowerPatch {
power: bool,
}
#[derive(Debug, Deserialize)]
struct HouseEmergencyStopPatch {
active: bool,
}
async fn update_house_emergency_stop(
State(state): State<AppState>,
Json(input): Json<HouseEmergencyStopPatch>,
) -> Result<Json<Value>, AppError> {
// Automation execution already uses this lock before committing an action. Taking it first
// gives the emergency stop a clean barrier: an in-flight action finishes, then no newer
// automatic action can cross the persisted safety flag.
let _automation_guard = state.lock_automation_operation().await;
let _house_guard = state.lock_house_operation().await;
// Persist the safety gate while automatic control is serialized. Once the flag is stored,
// neither the background regulator nor an immediate thermostat run can emit automation
// commands until the user explicitly resumes normal operation.
let cycle_guard = state.lock_zone_control_cycle().await;
let (changed, emergency_stop_since) = {
let mut settings = state.settings.write().await;
let changed = settings.emergency_stop_enabled != input.active;
if changed {
settings.emergency_stop_enabled = input.active;
settings.emergency_stop_since = if input.active { Some(Utc::now()) } else { None };
state.db.save_runtime_settings(&settings)?;
}
(changed, settings.emergency_stop_since.clone())
};
// OFF is intentionally a one-shot side effect of activating the emergency stop. The
// persistent flag survives restarts, but startup never replays physical commands from it.
let mut failed = Vec::new();
let mut cleared_queues = 0usize;
if input.active && changed {
cleared_queues = clear_all_automation_compressor_queues(&state).await?;
failed = command_all_enabled_devices_power(&state, false, "house_emergency_stop").await?;
}
let payload = json!({
"active": input.active,
"since": emergency_stop_since,
"changed": changed,
"failed": failed,
"cleared_queues": cleared_queues,
});
state.broadcast("house.emergency_stop_changed", payload.clone());
drop(cycle_guard);
state.wake_zone_control();
// Resuming does not force any unit ON. It only releases the persistent safety gate and
// immediately re-evaluates current schedules, temperatures and ownership from fresh state.
if !input.active
&& changed
&& state.initial_device_sync_complete.load(Ordering::Acquire)
{
if let Err(err) = engine::run_zone_control_now(&state).await {
state.log(
"error",
"house.emergency_resume_control_error",
&err.to_string(),
json!({"active": false}),
);
}
}
state.log(
"info",
if input.active {
"house.emergency_stop_activated"
} else {
"house.emergency_stop_released"
},
if input.active {
"Emergency stop activated; automatic climate control paused"
} else {
"Emergency stop released; automatic climate control resumed"
},
json!({
"active": input.active,
"changed": changed,
"failed_devices": payload["failed"].as_array().map(Vec::len).unwrap_or(0),
"cleared_queues": cleared_queues,
"persistent_across_restart": true,
"off_replayed_on_restart": false,
}),
);
Ok(Json(payload))
}
async fn update_house_power(
State(state): State<AppState>,
Json(input): Json<HousePowerPatch>,