v0.15.15
This commit is contained in:
1 parent
3b969c8754
commit
d80005de33
33 files changed
+722
-94
No files matched your search
@@ -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, ¤t_zones, ¤t_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, ¤t_zones, &export).await?;
|
||||
sanitize_configuration_runtime(&mut export);
|
||||
state
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user