This commit is contained in:
Mateusz Gruszczyński committed 2026-09-21 22:58:30 +02:00
1 parent 3b969c8754
commit d80005de33
33 files changed
+722 -94

No files matched your search

+12
View File
@@ -22,6 +22,10 @@ async fn export_configuration(
// including configuration exports. Import can reuse the already configured password on the
// target controller when account/region match.
export.settings.gree_cloud.password.clear();
// Emergency STOP is live safety state, not configuration. Never transfer it in backups:
// exporting/importing configuration must not arm or release the controller safety gate.
export.settings.emergency_stop_enabled = false;
export.settings.emergency_stop_since = None;
Ok(Json(export))
}
@@ -958,6 +962,14 @@ async fn import_configuration(
let _resource_guards =
lock_configuration_resources(&state, &current_zones, &current_devices, &export).await;
// Keep the operational safety gate independent from configuration replacement. The locks
// above serialize this snapshot against the emergency-stop endpoint.
{
let settings = state.settings.read().await;
export.settings.emergency_stop_enabled = settings.emergency_stop_enabled;
export.settings.emergency_stop_since = settings.emergency_stop_since.clone();
}
power_off_detached_devices(&state, &current_zones, &export).await?;
sanitize_configuration_runtime(&mut export);
state
+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>,
+14 -3
View File
@@ -1,6 +1,8 @@
#[derive(Debug, serde::Serialize)]
struct HouseSnapshot {
mode: String,
emergency_stop_enabled: bool,
emergency_stop_since: Option<chrono::DateTime<Utc>>,
}
#[derive(Debug, serde::Serialize)]
@@ -140,9 +142,14 @@ async fn bootstrap(State(state): State<AppState>) -> Result<Json<BootstrapRespon
}
async fn build_bootstrap(state: &AppState) -> Result<BootstrapResponse, AppError> {
let (settings, house_mode) = {
let (settings, house_mode, emergency_stop_enabled, emergency_stop_since) = {
let settings = state.settings.read().await;
(settings_snapshot(&settings), settings.house_mode.clone())
(
settings_snapshot(&settings),
settings.house_mode.clone(),
settings.emergency_stop_enabled,
settings.emergency_stop_since.clone(),
)
};
let devices = state.db.list_devices()?;
let device_groups = state.db.list_device_groups()?;
@@ -166,7 +173,11 @@ async fn build_bootstrap(state: &AppState) -> Result<BootstrapResponse, AppError
flows: state.db.list_flows()?,
access_tokens: state.db.list_api_tokens()?,
settings,
house: HouseSnapshot { mode: house_mode },
house: HouseSnapshot {
mode: house_mode,
emergency_stop_enabled,
emergency_stop_since,
},
outdoor_temperature: *state.outdoor_temperature.read().await,
control_plan: control_plan.plan.as_ref().clone(),
control_plan_revision: control_plan.revision,