This commit is contained in:
Mateusz Gruszczyński
2026-09-01 14:33:45 +02:00
parent d0346dd797
commit 3c490b3064
22 changed files with 158 additions and 111 deletions
+34 -29
View File
@@ -1,24 +1,6 @@
#[derive(Debug, Deserialize)]
struct HouseControlPatch { mode: String }
async fn rearm_all_compressor_queues(state: &AppState) -> Result<(), AppError> {
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
zone_ids.sort();
zone_ids.dedup();
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)?);
}
Ok(())
}
async fn rearm_house_automation_compressor_queues(state: &AppState) -> Result<(), AppError> {
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
@@ -42,12 +24,31 @@ async fn rearm_house_automation_compressor_queues(state: &AppState) -> Result<()
}
Ok(())
}
async fn set_all_thermostat_power_state(state: &AppState, power: bool) -> 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; };
engine::rearm_compressor_queue(&mut zone);
if engine::set_house_bulk_thermostat_power(&mut zone, power) { changed += 1; }
engine::refresh_control_ownership(&mut zone, true);
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)?);
}
Ok(changed)
}
async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result<Vec<Value>, AppError> {
let mut failed = Vec::new();
for device in state.db.list_devices()? {
if !device.enabled { continue; }
// Global ON/OFF is deliberately a one-shot physical command. It does not mutate
// thermostat/group/manual ownership, so those controllers may make a later decision.
// The per-zone thermostat power state is persisted before these physical commands.
// OFF is immediate; ON still respects compressor protection.
let result = if power {
engine::one_shot_house_power_on_device(state, &device.id).await
} else {
@@ -112,9 +113,10 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
let _house_guard = state.lock_house_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await;
// v0.9.3: this endpoint is a one-shot physical action, not a persistent automation gate.
// Preserve every zone/group/manual owner exactly as-is. A later thermostat, group, rule or
// manual action is therefore free to issue its own command independently.
// Global power is a bulk thermostat action, not a persistent master gate. OFF stores every
// thermostat as an indefinite local OFF so the next regulator cycle cannot immediately
// resurrect demand. ON releases that OFF state. Later explicit local/group/manual actions
// remain independent and can re-enable only the selected scope.
{
let mut settings = state.settings.write().await;
if !settings.house_power_enabled {
@@ -124,10 +126,11 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
}
}
// Drop stale protection tasks from the intent that existed before this global physical
// command. They may be recreated by a later thermostat cycle if demand still exists.
rearm_all_compressor_queues(&state).await?;
let failed = command_all_enabled_devices_power(&state, input.power, "house_power_one_shot").await?;
// Persist the thermostat power intent before touching devices. The cycle lock held by this
// handler guarantees that no setpoint-modulation cycle can race between the marker and OFF.
let changed_zones = set_all_thermostat_power_state(&state, input.power).await?;
let failed = command_all_enabled_devices_power(&state, input.power, "house_power_bulk").await?;
state.wake_zone_control();
let devices = state.db.list_devices()?;
let groups = state.db.list_groups()?;
@@ -135,13 +138,15 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
let settings_payload = public_settings(&settings);
drop(settings);
state.log("info", "house.power_all", if input.power {
"One-shot whole-house ON sent; thermostat/group/manual ownership preserved"
"Whole-house ON sent; local OFF state released for all thermostats"
} else {
"One-shot whole-house OFF sent; thermostat/group/manual ownership preserved"
"Whole-house OFF sent; all thermostats left locally OFF until explicitly re-enabled"
}, json!({
"power": input.power,
"failed": failed.len(),
"changed_zones": changed_zones,
"one_shot": true,
"persistent_global_gate": false,
}));
Ok(Json(json!({
"power": input.power,
+1 -1
View File
@@ -13,7 +13,7 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
"house_mode must be changed through the House Control API".into(),
));
}
// Compatibility-only field: global ON/OFF is one-shot and never disables automation.
// Compatibility-only field: global ON/OFF no longer uses a persistent master automation gate.
input.house_power_enabled = true;
input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600);
input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600);