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();
-1
View File
@@ -12,7 +12,6 @@ fn device_has_enabled_thermostat_zone(device_id: &str, zones: &[Zone]) -> bool {
}
async fn run_automations(state: &AppState) -> Result<()> {
if !state.settings.read().await.house_power_enabled { return Ok(()); }
let devices = state.db.list_devices()?;
let mut automations = state.db.list_automations()?;
// Stable arbitration for same-cycle conflicts: the oldest configured rule wins, then ID.
+5 -5
View File
@@ -9,7 +9,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
zones.iter().all(|zone| zone.manual_preset.as_deref().unwrap_or("auto") == first_preset)
.then(|| first_preset.to_string())
});
let house_power = settings.house_power_enabled;
let house_power = true;
let now = Local::now();
let night_active = night_mode_active(&settings.night_mode, now.time());
let mut zones_out = Vec::new();
@@ -18,7 +18,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
for mut zone in zones {
let device = devices.iter().find(|item| item.id == zone.device_id);
let configured_effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
refresh_control_ownership(&mut zone, settings.house_power_enabled);
refresh_control_ownership(&mut zone, true);
let configured_effective_mode = configured_effective_mode_owned.as_str();
let manual_device_mode = device.map(|item| if item.power { item.mode.as_str() } else { "off" });
let effective_mode = if zone.device_manual_override {
@@ -68,12 +68,12 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
zone.effective_setpoint.or(Some(resolved_target))
},
device_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature),
desired_power: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override,
desired_power: zone.enabled && effective_mode != "off" && !zone.device_manual_override,
desired_mode: effective_mode.to_string(),
actual_power: device.map(|item| item.power),
actual_mode: device.map(|item| if item.power { item.mode.clone() } else { "off".into() }),
actual_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature),
demand: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && zone.demand,
demand: zone.enabled && effective_mode != "off" && !zone.device_manual_override && zone.demand,
control_source: zone.control_temperature_source.clone(),
manual_override_until: zone.manual_override_until,
local_thermostat_power: zone.local_thermostat_power,
@@ -85,7 +85,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
control_since: zone.control_since,
resume_at: zone.control_resume_at,
control_reason: zone.control_reason.clone(),
blocked_reason: if !settings.house_power_enabled { Some("global_off".into()) } else if zone.device_manual_override { Some("manual_override".into()) } else if zone.lockout_until.map(|until| until > Utc::now()).unwrap_or(false) { Some(zone.lockout_reason.clone().unwrap_or_else(|| "lockout".into())) } else if !zone.enabled { Some("zone_disabled".into()) } else if device.map(|d| !d.online || d.communication_failures > 0).unwrap_or(true) { Some("offline".into()) } else { None },
blocked_reason: if zone.device_manual_override { Some("manual_override".into()) } else if zone.lockout_until.map(|until| until > Utc::now()).unwrap_or(false) { Some(zone.lockout_reason.clone().unwrap_or_else(|| "lockout".into())) } else if !zone.enabled { Some("zone_disabled".into()) } else if device.map(|d| !d.online || d.communication_failures > 0).unwrap_or(true) { Some("offline".into()) } else { None },
lockout_until: zone.lockout_until,
current_schedule_id: active.map(|item| item.id.clone()),
current_schedule_name: active.map(|item| item.name.clone()),
+13 -18
View File
@@ -32,19 +32,6 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
let _group_guard = state.lock_group_operation(group_id).await;
let mut group = state.db.get_group(group_id)?
.ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?;
if source == "automation.group" && !state.settings.read().await.house_power_enabled {
state.log("info", "automation.blocked_by_house_power", &format!("Group automation suppressed while whole-house power is off for {}", group.name), json!({
"group_id": group.id, "source": source
}));
return Ok(json!({
"group": group,
"zones": [],
"devices": state.db.list_devices()?,
"failed": [],
"master_power_enabled": false,
"suppressed": true,
}));
}
let mut locked_zone_ids = group.zone_ids.clone();
locked_zone_ids.sort();
locked_zone_ids.dedup();
@@ -66,7 +53,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
"zones": [],
"devices": state.db.list_devices()?,
"failed": [],
"master_power_enabled": state.settings.read().await.house_power_enabled,
"master_power_enabled": true,
"suppressed": true,
}));
}
@@ -84,6 +71,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
// "blocked by a disabled group". ON clears that scoped OFF and lets group arbitration
// take ownership again. The whole-house master remains independent.
let explicit_group_power = patch.power.is_some() && source != "automation.group";
let manual_group_control = source != "automation.group";
let mut zones = Vec::new();
let mut failed = Vec::new();
@@ -148,7 +136,11 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
} else {
zone.manual_preset = Some(preset.to_string());
if preset != "custom" { zone.manual_setpoint = None; }
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
zone.manual_override_until = if manual_group_control {
None
} else {
next_schedule_boundary_utc(&zone.id, &schedules, Local::now())
};
}
}
if let Some(setpoint) = custom_setpoint {
@@ -156,7 +148,11 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
zone.manual_preset = Some("custom".into());
zone.manual_setpoint = Some(setpoint);
zone.effective_setpoint = Some(setpoint);
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
zone.manual_override_until = if manual_group_control {
None
} else {
next_schedule_boundary_utc(&zone.id, &schedules, Local::now())
};
}
}
// Power OFF is persisted as an ordinary per-zone thermostat OFF, with no automatic
@@ -237,8 +233,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
zones.push(zone);
}
let runtime = state.settings.read().await.clone();
let master_power_enabled = runtime.house_power_enabled;
let master_power_enabled = true;
let control_toggled_on = patch.power == Some(true);
let control_enabled = group.power_enabled;
+64 -21
View File
@@ -1,8 +1,6 @@
pub fn refresh_control_ownership(zone: &mut Zone, house_power_enabled: bool) {
pub fn refresh_control_ownership(zone: &mut Zone, _house_power_enabled: bool) {
let now = Utc::now();
let (owner, source, resume_at, reason) = if !house_power_enabled {
("global_off", "global".to_string(), None, "Whole-house power is disabled".to_string())
} else if zone.device_manual_override {
let (owner, source, resume_at, reason) = if zone.device_manual_override {
let source = match zone.control_source.as_str() {
"home_assistant_direct" | "web_direct" | "external" => zone.control_source.clone(),
_ => "external".into(),
@@ -157,7 +155,8 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec<Str
zone.control_source = normalized_direct_source(source).into();
zone.control_reason = "Direct/manual device control has priority".into();
// Direct/pilot control is an explicit ownership takeover, not a temporary preset.
// Keep it manual until the user chooses Resume automation (or a global safety OFF).
// Keep it manual until the user explicitly chooses Resume automation. A one-shot global
// ON/OFF command changes physical power only and must never erase manual ownership.
// A schedule boundary must never silently take the unit back from the person controlling it.
zone.device_manual_override_until = None;
zone.control_resume_at = None;
@@ -200,7 +199,7 @@ async fn detect_external_device_control(state: &AppState, before: &Device, after
).await;
// Once direct/pilot ownership has been detected, merely returning the device to a
// previous physical state must not silently hand control back to schedules. Only the
// explicit Resume automation action (or global OFF safety reset) ends manual ownership.
// explicit Resume automation action ends manual ownership.
if fields.is_empty() { continue; }
// A disabled zone is outside controller ownership. When its manually operated unit is
// switched off there is no takeover left to display or remember.
@@ -262,10 +261,10 @@ pub async fn send_manual_command(state: &AppState, device_id: &str, command: Dev
Ok(updated)
}
pub async fn force_house_power_off_device(state: &AppState, device_id: &str, source: &str) -> Result<Device, AppError> {
// Preserve the global zone -> device lock order even for the whole-house safety path.
// Otherwise a zone edit could hold its zone lock while waiting for this device lock as
// this function saved a stale zone snapshot without owning the corresponding zone lock.
pub async fn force_house_power_off_device(state: &AppState, device_id: &str, _source: &str) -> Result<Device, AppError> {
// Whole-house OFF is a one-shot physical action. Preserve every zone/manual/group owner,
// while still taking zone -> device locks so a concurrent local/manual action cannot race
// the forced OFF frame. Controllers are free to make a later independent decision.
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter()
.filter(|zone| zone.device_id == device_id)
.map(|zone| zone.id)
@@ -277,20 +276,64 @@ pub async fn force_house_power_off_device(state: &AppState, device_id: &str, sou
_zone_guards.push(state.lock_zone_operation(zone_id).await);
}
let _device_guard = state.lock_device_operation(device_id).await;
for zone_id in &zone_ids {
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
if !reset_device_manual_override(&mut zone) { continue; }
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
state.log("info", "zone.device_manual_override_cleared", &format!("Automation resumed for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "source": source
}));
}
// The device lock is already held, so use the locked forced-command variant directly.
force_power_off_device_locked(state, device_id).await
}
pub async fn one_shot_house_power_on_device(state: &AppState, device_id: &str) -> Result<Device, AppError> {
// Global ON is also one-shot and must not take thermostat ownership. For thermostat-managed
// units it still respects compressor protection; a protected start is stored as a visible
// queue item and executed at the protection deadline unless a newer intent cancels/replaces it.
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter()
.filter(|zone| zone.device_id == device_id)
.map(|zone| zone.id)
.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 _device_guard = state.lock_device_operation(device_id).await;
let device = state.db.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); }
if device.power {
return Ok(device);
}
let settings = state.settings.read().await.clone();
if settings.compressor_protection_enabled {
if let Some(zone_id) = zone_ids.first() {
if let Some(mut zone) = state.db.get_zone(zone_id)? {
let now = Utc::now();
let protection = chrono::Duration::seconds(settings.compressor_protection_seconds as i64);
if let Some(last_change) = zone.last_power_change_at {
let until = last_change + protection;
if until > now {
rearm_compressor_queue(&mut zone);
queue_compressor_action(&mut zone, "global_power_on".into(), until, "minimum_off_before_global_start");
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = now;
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
state.log("info", "zone.compressor_queue_queued", &format!("Queued global ON for {} behind compressor protection", zone.name), json!({
"zone_id": zone.id,
"device_id": zone.device_id,
"action": "global_power_on",
"resume_at": zone.compressor_pending_until,
}));
state.wake_zone_control();
return Ok(device);
}
}
}
}
}
// The device lock is already held. This is physical one-shot power only: no manual marker.
send_command_locked(state, device_id, DeviceCommand { power: Some(true), ..Default::default() }).await
}
pub async fn force_power_off_device(state: &AppState, device_id: &str) -> Result<Device, AppError> {
let _device_guard = state.lock_device_operation(device_id).await;
send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await
+4 -1
View File
@@ -50,7 +50,10 @@ fn effective_zone_mode(zone: &Zone, house_mode: &str) -> String {
}
}
let configured = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() };
if zone.local_thermostat_power == Some(true) && configured == "off" {
let scoped_manual = zone.local_thermostat_power == Some(true) || zone.control_source.starts_with("group:");
if scoped_manual && configured == "off" {
// Explicit local/group control is independent from house "Do not control". Reuse the
// zone's last concrete heat/cool mode instead of turning a manual action into a no-op.
zone.mode.clone()
} else {
configured.to_string()
+34
View File
@@ -439,6 +439,25 @@ mod tests {
assert_eq!(effective_zone_mode(&zone, "off"), "cool");
}
#[test]
fn group_manual_control_can_run_when_house_mode_is_off() {
let mut zone = test_zone("device");
zone.inherit_house_mode = true;
zone.mode = "cool".into();
zone.control_source = "group:downstairs".into();
assert_eq!(effective_zone_mode(&zone, "off"), "cool");
}
#[test]
fn legacy_global_power_flag_does_not_take_ownership() {
let mut zone = test_zone("device");
zone.local_thermostat_power = Some(true);
zone.control_source = "web_thermostat".into();
refresh_control_ownership(&mut zone, false);
assert_eq!(zone.control_owner, "local_thermostat");
assert_ne!(zone.control_source, "global");
}
#[test]
fn indefinite_local_off_does_not_create_or_rearm_handback() {
let mut zone = test_zone("device");
@@ -712,6 +731,21 @@ mod tests {
}
#[test]
fn global_one_shot_queue_does_not_change_zone_ownership() {
let mut zone = test_zone("device");
zone.local_thermostat_power = Some(true);
zone.control_source = "web_thermostat".into();
refresh_control_ownership(&mut zone, true);
let owner = zone.control_owner.clone();
let source = zone.control_source.clone();
let until = Utc::now() + chrono::Duration::seconds(180);
queue_compressor_action(&mut zone, "global_power_on".into(), until, "minimum_off_before_global_start");
assert_eq!(zone.control_owner, owner);
assert_eq!(zone.control_source, source);
assert_eq!(zone.compressor_pending_action.as_deref(), Some("global_power_on"));
}
#[test]
fn compressor_queue_helpers_track_and_clear_pending_intent() {
let mut zone = test_zone("device");
+1 -4
View File
@@ -19,7 +19,6 @@ fn persist_zone_cycle(state: &AppState, computed: &Zone, cycle_started_at: DateT
}
async fn thermostat_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result<bool, AppError> {
if !state.settings.read().await.house_power_enabled { return Ok(false); }
let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); };
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power == Some(false) { return Ok(false); }
Ok(true)
@@ -46,7 +45,6 @@ async fn send_automatic_device_command_if_owned(
command: DeviceCommand,
) -> Result<Option<Device>, AppError> {
let _device_guard = state.lock_device_operation(device_id).await;
if !state.settings.read().await.house_power_enabled { return Ok(None); }
let zones = state.db.list_zones()?;
if device_blocked_by_disabled_zone(device_id, &zones)
|| device_blocked_by_manual_override(device_id, &zones)
@@ -73,8 +71,7 @@ async fn apply_automatic_device_action(
let _zone_guard = state.lock_zone_operation(&zone_id).await;
let _device_guard = state.lock_device_operation(device_id).await;
let mut zone = state.db.get_zone(&zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?;
let settings = state.settings.read().await.clone();
if !settings.house_power_enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() {
if zone.device_manual_override || zone.local_thermostat_power.is_some() {
return Ok(None);
}
// A power-on automation is an explicit domain transition and may re-enable a zone that
+51 -15
View File
@@ -11,7 +11,7 @@ pub(crate) fn clear_compressor_pending(zone: &mut Zone, clear_cancelled: bool) {
if clear_cancelled { zone.compressor_cancelled_action = None; }
}
fn queue_compressor_action(zone: &mut Zone, action: String, until: DateTime<Utc>, reason: &str) {
pub(crate) fn queue_compressor_action(zone: &mut Zone, action: String, until: DateTime<Utc>, reason: &str) {
let now = Utc::now();
if zone.compressor_pending_action.as_deref() != Some(action.as_str()) {
zone.compressor_pending_since = Some(now);
@@ -35,12 +35,11 @@ async fn control_zones(state: &AppState) -> Result<()> {
let schedules = state.db.list_schedules()?;
let settings = state.settings.read().await.clone();
let mut zone_snapshot = state.db.list_zones()?;
// Local quick-thermostat OFF is intentionally temporary. Expire the ownership marker
// before the house-power early return so the hand-back still happens while the master
// is off; no physical state is restored here, only automation ownership.
// Local/temporary thermostat ownership is independent from one-shot whole-house power
// commands. Expire/activate sessions on their own deadlines.
expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode, settings.house_power_enabled).await?;
activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode, true).await?;
// Outdoor temperature is deliberately optional. Prefer the configured Home
// Assistant entity, but keep the dashboard/assist useful by falling back to the
@@ -84,13 +83,6 @@ async fn control_zones(state: &AppState) -> Result<()> {
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
if !settings.house_power_enabled {
// Whole-house OFF is a one-shot action performed by the API endpoint. While the
// master remains off the regulator stays passive. A later physical/remote change
// is therefore detected as manual takeover and is not erased or forced OFF again.
return Ok(());
}
// Read all per-zone Home Assistant sensors concurrently. A down HA instance should cost
// one request timeout per cycle, not one timeout multiplied by the number of zones.
let room_sensor_reads = futures_util::future::join_all(zone_snapshot.iter().filter_map(|zone| {
@@ -146,11 +138,11 @@ async fn control_zones(state: &AppState) -> Result<()> {
// A zone explicitly switched to heat/cool remains independent and may still run.
// Local Quick Thermostat is an explicit per-zone request. If the inherited house
// climate mode is "off" (no automatic climate control), use the zone's last local
// heat/cool mode while local ownership is ON. The separate whole-house master power
// remains authoritative and is checked before this loop.
// heat/cool mode while local ownership is ON. Global ON/OFF is intentionally not a
// persistent gate: later thermostat/group/manual intent may act independently.
let effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
zone.effective_mode = effective_mode_owned.clone();
refresh_control_ownership(&mut zone, settings.house_power_enabled);
refresh_control_ownership(&mut zone, true);
let effective_mode = effective_mode_owned.as_str();
let previous_source = zone.control_temperature_source.clone();
@@ -197,6 +189,50 @@ async fn control_zones(state: &AppState) -> Result<()> {
zone.control_temperature_source = control_source;
zone.updated_at = Utc::now();
// A queued whole-house ON is a delayed one-shot physical action, not thermostat
// ownership. It must survive local/group/manual state while compressor protection is
// active, then execute once and hand control straight back to the existing owner.
if zone.compressor_pending_action.as_deref() == Some("global_power_on") {
let now = Utc::now();
if device.power {
clear_compressor_pending(&mut zone, true);
zone.updated_at = now;
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue;
}
let due = !settings.compressor_protection_enabled
|| zone.compressor_pending_until.map(|until| until <= now).unwrap_or(true);
if due {
let _device_guard = state.lock_device_operation(&zone.device_id).await;
match send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(true), ..Default::default() }).await {
Ok(updated_device) => {
if !device.power && updated_device.power { zone.last_power_change_at = Some(Utc::now()); }
clear_compressor_pending(&mut zone, true);
zone.last_action_at = Some(Utc::now());
state.log("info", "house.power_one_shot_executed", &format!("Executed queued global ON for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id
}));
}
Err(err) => {
// Keep the user-visible task and retry on a bounded deadline instead of
// spinning immediately or silently dropping a one-shot request.
zone.compressor_pending_until = Some(Utc::now() + chrono::Duration::seconds(10));
zone.lockout_until = zone.compressor_pending_until;
zone.lockout_reason = Some("global_start_retry".into());
state.log("error", "house.power_one_shot_error", &err.to_string(), json!({
"zone_id": zone.id, "device_id": zone.device_id
}));
}
}
}
zone.updated_at = Utc::now();
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue;
}
// A disabled thermostat zone is completely outside normal controller ownership.
// Keep its sensors fresh, but do not let group state, schedules or thermostat
// modulation touch the unit. Manual control from the technical Devices view may
+4
View File
@@ -34,6 +34,10 @@ async fn main() -> Result<()> {
runtime_settings.discovery_broadcast = config.discovery_broadcast.clone();
}
config.apply_runtime_env_overrides(&mut runtime_settings);
// v0.9.3: whole-house ON/OFF is a one-shot physical action, not a persistent master gate.
// Normalize databases upgraded from older releases so a historical OFF cannot suppress
// thermostats, groups or automations after restart.
runtime_settings.house_power_enabled = true;
db.save_runtime_settings(&runtime_settings)?;
if config.simulate && config.auto_seed && db.count_devices()? == 0 {
+1 -1
View File
@@ -95,7 +95,7 @@ pub struct ControlPlan {
pub house_mode: String,
/// Uniform whole-house preset when every zone uses the same override; None for a mixed state.
pub house_preset: Option<String>,
/// Whole-house master power state.
/// Compatibility field. Global ON/OFF is a one-shot action; this is always true in v0.9.3+.
pub house_power: bool,
pub outdoor_temperature: Option<f64>,
pub control_strategy: String,
+2 -1
View File
@@ -9,7 +9,8 @@ pub struct RuntimeSettings {
/// Global seasonal mode. Zones follow this by default. Values: cool/heat/off; off pauses house-level thermostat control.
#[serde(default = "default_house_mode")]
pub house_mode: String,
/// Whole-house master power. False is authoritative and suppresses zone/automation restarts.
/// Legacy compatibility flag. Since v0.9.3 global ON/OFF is a one-shot command, not an
/// automation gate. The controller normalizes this field to true on load/import.
#[serde(default = "default_true")]
pub house_power_enabled: bool,
/// `setpoint` keeps units powered and modulates compressor demand by changing target temperature.
+2 -2
View File
@@ -86,8 +86,8 @@ pub struct Zone {
pub manual_setpoint: Option<f64>,
#[serde(default)]
pub manual_override_until: Option<DateTime<Utc>>,
/// Local quick-thermostat power override. None follows group/global power gates;
/// Some(true) runs this zone locally through the full thermostat; Some(false) keeps it locally off.
/// Local quick-thermostat power override. None follows group/house rules; Some(true) runs
/// this zone locally through the full thermostat; Some(false) keeps it locally off.
#[serde(default)]
pub local_thermostat_power: Option<bool>,
/// Automatic hand-back deadline after the local quick thermostat is switched OFF.