This commit is contained in:
Mateusz Gruszczyński
2026-09-01 14:18:00 +02:00
parent 97c26c67b6
commit d0346dd797
33 changed files with 327 additions and 270 deletions
+1 -1
View File
@@ -256,7 +256,7 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
"zone_ids": group.zone_ids,
"zone_names": zone_names,
"power_enabled": group.power_enabled,
"effective_power": settings.house_power_enabled && group.power_enabled,
"effective_power": group.power_enabled,
"mode": home_assistant_group_mode(&members),
"preset": home_assistant_group_preset(&members),
"custom_setpoint": home_assistant_group_custom_setpoint(&members),
+51 -112
View File
@@ -19,66 +19,37 @@ async fn rearm_all_compressor_queues(state: &AppState) -> Result<(), AppError> {
Ok(())
}
async fn clear_group_control_sources(state: &AppState, reason: &str) -> Result<(), AppError> {
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();
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); }
for zone_id in &zone_ids {
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
if !zone.control_source.starts_with("group:") { continue; }
zone.control_source = "automation".into();
zone.control_since = Some(Utc::now());
zone.control_reason = reason.to_string();
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; };
let scoped_manual = zone.device_manual_override
|| zone.local_thermostat_power.is_some()
|| zone.control_source.starts_with("group:")
|| engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
if scoped_manual { 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)?);
}
drop(guards);
Ok(())
}
fn clear_all_local_thermostat_overrides(state: &AppState) -> Result<usize, AppError> {
let mut cleared = 0;
for mut zone in state.db.list_zones()? {
if zone.local_thermostat_power.is_none() && zone.local_thermostat_resume_at.is_none() && zone.temporary_quick_thermostat.is_none() { continue; }
let temporary_was_active = engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
let temporary_restore = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.restore_zone_enabled);
zone.temporary_quick_thermostat = None;
engine::reset_local_thermostat_override(&mut zone);
if temporary_was_active {
if let Some(enabled) = temporary_restore { zone.enabled = enabled; }
}
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
cleared += 1;
}
Ok(cleared)
}
async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result<Vec<Value>, AppError> {
let mut failed = Vec::new();
let enabled_zone_devices: std::collections::HashSet<String> = if power {
state.db.list_zones()?.into_iter()
.filter(|zone| zone.enabled && !zone.device_manual_override && zone.local_thermostat_power != Some(false))
.map(|zone| zone.device_id)
.collect()
} else {
std::collections::HashSet::new()
};
for device in state.db.list_devices()? {
if !device.enabled { continue; }
// Whole-house ON only operates thermostat-managed, enabled zones. Devices with
// a disabled zone (or no zone at all) remain manual/technical Devices controls.
if power && !enabled_zone_devices.contains(&device.id) { continue; }
// Do not trust the pre-loop power snapshot for deciding whether to send. The engine
// reloads state under the per-device lock and turns an already-matching command into
// a no-op. This closes the polling/command race without extra UDP frames.
// 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.
let result = if power {
engine::send_command(state, &device.id, DeviceCommand { power: Some(true), ..Default::default() }).await
engine::one_shot_house_power_on_device(state, &device.id).await
} else {
engine::force_house_power_off_device(state, &device.id, source).await
};
@@ -113,16 +84,14 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
let payload = {
let mut settings = state.settings.write().await;
settings.house_mode = mode.clone();
// Choosing a real whole-house operating mode is an explicit request to run the
// house climate. It therefore clears a previous global power-off. "off" keeps
// its separate meaning: do not control, without changing master power.
if activate_all { settings.house_power_enabled = true; }
settings.house_power_enabled = true;
state.db.save_runtime_settings(&settings)?;
public_settings(&settings)
};
state.broadcast("settings.updated", payload.clone());
clear_group_control_sources(&state, "Whole-house mode control took ownership").await?;
rearm_all_compressor_queues(&state).await?;
// House rules do not steal explicit local/group/manual ownership. Free zones follow the
// new mode immediately; scoped manual controls continue independently.
rearm_house_automation_compressor_queues(&state).await?;
// run_zone_control_now takes the same cycle lock, so release the mutation window first.
drop(cycle_guard);
if activate_all {
@@ -132,7 +101,7 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
} else {
state.wake_zone_control();
}
state.log("info", "house.mode", &format!("House mode set to {}", mode), json!({"mode": mode, "master_power_enabled": activate_all}));
state.log("info", "house.mode", &format!("House mode set to {}", mode), json!({"mode": mode}));
Ok(Json(payload))
}
@@ -141,74 +110,42 @@ struct HousePowerPatch { power: bool }
async fn update_house_power(State(state): State<AppState>, Json(input): Json<HousePowerPatch>) -> Result<Json<Value>, AppError> {
let _house_guard = state.lock_house_operation().await;
let cycle_guard = state.lock_zone_control_cycle().await;
// Whole-house power is independent from the thermostat mode. Publish/persist the master
// first so the regulator becomes passive before the one-shot OFF cascade starts.
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.
{
let mut settings = state.settings.write().await;
if settings.house_power_enabled != input.power {
settings.house_power_enabled = input.power;
if !settings.house_power_enabled {
settings.house_power_enabled = true;
state.db.save_runtime_settings(&settings)?;
let payload = public_settings(&settings);
state.broadcast("settings.updated", payload);
state.broadcast("settings.updated", public_settings(&settings));
}
}
// Whole-house power is independent from group-control enablement. OFF clears current
// ownership markers and powers devices down, but preserves which groups the user has
// enabled for future group actions. A later house ON therefore does not silently turn
// disabled group control back on.
clear_group_control_sources(&state, if input.power { "Whole-house power control resumed automation" } else { "Whole-house power disabled" }).await?;
// 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?;
if !input.power {
let zone_snapshot = state.db.list_zones()?;
let mut zone_ids: Vec<String> = zone_snapshot.iter().map(|zone| zone.id.clone()).collect();
zone_ids.sort();
zone_ids.dedup();
let mut zone_guards = Vec::with_capacity(zone_ids.len());
for zone_id in &zone_ids {
zone_guards.push(state.lock_zone_operation(zone_id).await);
}
let mut device_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.device_id).collect();
device_ids.sort();
device_ids.dedup();
let mut device_guards = Vec::with_capacity(device_ids.len());
for device_id in &device_ids {
device_guards.push(state.lock_device_operation(device_id).await);
}
engine::clear_all_device_manual_overrides(&state, "house_power_off")?;
clear_all_local_thermostat_overrides(&state)?;
drop(device_guards);
drop(zone_guards);
}
let failed = if input.power {
// The immediate cycle acquires this lock itself.
drop(cycle_guard);
match engine::run_zone_control_now(&state).await {
Ok(()) => Vec::new(),
Err(err) => {
state.log("error", "house.immediate_control_error", &err.to_string(), json!({"source":"house_power"}));
vec![json!({"scope":"thermostat_cycle","error":err.to_string()})]
}
}
} else {
// Keep the cycle excluded through the one-shot safety OFF cascade.
let failed = command_all_enabled_devices_power(&state, false, "house_power").await?;
drop(cycle_guard);
failed
};
let failed = command_all_enabled_devices_power(&state, input.power, "house_power_one_shot").await?;
let devices = state.db.list_devices()?;
let groups = state.db.list_groups()?;
let settings = state.settings.read().await;
let settings_payload = public_settings(&settings);
drop(settings);
state.log("info", "house.power_all", if input.power { "Whole-house automation enabled; thermostat arbiter resumed" } else { "Whole-house power disabled; enabled devices powered off while group-control preferences were preserved" }, json!({
state.log("info", "house.power_all", if input.power {
"One-shot whole-house ON sent; thermostat/group/manual ownership preserved"
} else {
"One-shot whole-house OFF sent; thermostat/group/manual ownership preserved"
}, json!({
"power": input.power,
"failed": failed.len(),
"one_shot": true,
}));
Ok(Json(json!({
"power": input.power,
"one_shot": true,
"devices": devices,
"groups": groups,
"settings": settings_payload,
@@ -226,8 +163,6 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into()));
}
// A whole-house profile is also an explicit whole-house activation. This mirrors
// selecting cooling/heating and makes the separate master-power control intuitive.
let settings_payload = {
let mut settings = state.settings.write().await;
settings.house_power_enabled = true;
@@ -235,8 +170,9 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
public_settings(&settings)
};
state.broadcast("settings.updated", settings_payload.clone());
clear_group_control_sources(&state, "Whole-house preset control took ownership").await?;
rearm_all_compressor_queues(&state).await?;
// A house profile applies to free house-controlled zones. Explicit local/group/direct
// owners remain higher priority and are not cleared or re-armed by changing house rules.
rearm_house_automation_compressor_queues(&state).await?;
let schedules = state.db.list_schedules()?;
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
@@ -251,11 +187,15 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; };
let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await;
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
session.deferred_preset = Some(input.preset.clone());
session.deferred_setpoint = None;
}
let scoped_manual = zone.device_manual_override
|| zone.local_thermostat_power.is_some()
|| zone.control_source.starts_with("group:")
|| engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
if scoped_manual {
// House rules never overwrite explicit manual/group/local ownership. The scoped
// controller keeps its own target/profile until the user releases it.
zones.push(zone);
continue;
} else if input.preset == "auto" {
zone.manual_preset = None;
zone.manual_setpoint = None;
@@ -286,7 +226,6 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
let devices = state.db.list_devices()?;
state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({
"preset": input.preset,
"master_power_enabled": true,
"failed": failed.len(),
}));
Ok(Json(json!({
+1 -1
View File
@@ -7,7 +7,7 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
"discovery_timeout_ms": settings.discovery_timeout_ms,
"discovery_broadcast": settings.discovery_broadcast,
"house_mode": settings.house_mode,
"house_power_enabled": settings.house_power_enabled,
"house_power_enabled": true,
"control_strategy": settings.control_strategy,
"outdoor_assist_enabled": settings.outdoor_assist_enabled,
"history_retention_days": settings.history_retention_days,
+7 -4
View File
@@ -8,11 +8,13 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
let _house_guard = state.lock_house_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await;
let old = state.settings.read().await.clone();
if input.house_power_enabled != old.house_power_enabled || input.house_mode != old.house_mode {
if input.house_mode != old.house_mode {
return Err(AppError::BadRequest(
"house_power_enabled and house_mode must be changed through the House Control API".into(),
"house_mode must be changed through the House Control API".into(),
));
}
// Compatibility-only field: global ON/OFF is one-shot and never disables automation.
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);
input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000);
@@ -274,6 +276,8 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App
fn sanitize_configuration_runtime(export: &mut ConfigurationExport) {
let now = Utc::now();
// Do not restore the pre-v0.9.3 persistent global-OFF gate from backups.
export.settings.house_power_enabled = true;
for device in &mut export.devices {
device.power = false;
device.mode = "cool".into();
@@ -420,8 +424,7 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
let controllable_devices: std::collections::HashSet<String> = export.zones.iter()
.filter(|zone| {
let effective_mode = if zone.inherit_house_mode { export.settings.house_mode.as_str() } else { zone.mode.as_str() };
export.settings.house_power_enabled
&& zone.enabled
zone.enabled
&& effective_mode != "off"
})
.map(|zone| zone.device_id.clone())
+2 -6
View File
@@ -346,10 +346,6 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
(requested_start_kind.to_string(), started_at, None)
};
if !editing_active && start_kind == "now" && !runtime.house_power_enabled {
return Err(AppError::BadRequest("temporary thermostat cannot start while whole-house automation is off; enable house power or schedule it for later".into()));
}
let finish_kind = request.finish_kind.as_str();
if !matches!(finish_kind, "duration" | "until" | "temperature_reached" | "temperature_stable" | "schedule_boundary") {
return Err(AppError::BadRequest("unsupported temporary thermostat finish kind".into()));
@@ -414,7 +410,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
} else { None };
let starts_now = start_kind == "now";
let immediate_activation = !editing_active && starts_now && runtime.house_power_enabled && !zone.device_manual_override;
let immediate_activation = !editing_active && starts_now && !zone.device_manual_override;
let restore_zone_enabled = if editing_active {
existing_session.as_ref().and_then(|session| session.restore_zone_enabled)
} else if immediate_activation {
@@ -636,7 +632,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false };
let runtime = state.settings.read().await.clone();
let house_mode = runtime.house_mode.clone();
engine::refresh_control_ownership(&mut zone, runtime.house_power_enabled);
engine::refresh_control_ownership(&mut zone, true);
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();