This commit is contained in:
Mateusz Gruszczyński
2026-09-01 12:07:04 +02:00
parent 6207bc4de6
commit e496f911da
25 changed files with 227 additions and 309 deletions
-18
View File
@@ -66,15 +66,6 @@ async fn run_automations(state: &AppState) -> Result<()> {
}));
continue;
}
if item.action_group_id.is_none() && device_blocked_by_disabled_group(&item.action_device_id, &zones, &groups) {
// Group power-off is authoritative for normal controller-owned zones. A manual
// takeover is filtered above and therefore remains higher priority than the group.
state.log("info", "automation.blocked_by_group", &format!("Automation {} suppressed by disabled group", item.name), json!({
"automation_id": item.id, "device_id": item.action_device_id
}));
continue;
}
if item.action_group_id.is_none()
&& device_has_enabled_thermostat_zone(&item.action_device_id, &zones)
&& automation_action_conflicts_with_thermostat(&item.action)
@@ -166,15 +157,6 @@ fn device_blocked_by_local_thermostat(device_id: &str, zones: &[Zone]) -> bool {
zones.iter().any(|zone| zone.device_id == device_id && zone.local_thermostat_power.is_some())
}
fn device_blocked_by_disabled_group(device_id: &str, zones: &[Zone], groups: &[crate::models::ClimateGroup]) -> bool {
let zone_ids: std::collections::HashSet<&str> = zones.iter()
.filter(|zone| zone.device_id == device_id)
.map(|zone| zone.id.as_str())
.collect();
if zone_ids.is_empty() { return false; }
groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id.as_str())))
}
fn find_temperature(devices: &[Device], device_id: Option<&str>) -> Option<f64> {
let id = device_id?;
// Never fire a temperature automation from stale cached data of an offline/disabled unit.
+5 -8
View File
@@ -18,14 +18,12 @@ 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);
let blocked_by_group = zone.local_thermostat_power != Some(true)
&& groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
refresh_control_ownership(&mut zone, settings.house_power_enabled, blocked_by_group);
refresh_control_ownership(&mut zone, settings.house_power_enabled);
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 {
manual_device_mode.unwrap_or(configured_effective_mode)
} else if zone.local_thermostat_power == Some(false) || blocked_by_group {
} else if zone.local_thermostat_power == Some(false) {
"off"
} else {
configured_effective_mode
@@ -48,8 +46,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
house_events.push(event);
}
let effective_enabled = zone.enabled
&& zone.local_thermostat_power != Some(false)
&& (!blocked_by_group || zone.device_manual_override);
&& zone.local_thermostat_power != Some(false);
zones_out.push(ZoneControlPlan {
zone_id: zone.id.clone(),
zone_name: zone.name.clone(),
@@ -71,7 +68,7 @@ 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 && !blocked_by_group,
desired_power: settings.house_power_enabled && 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() }),
@@ -88,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 blocked_by_group { Some("group_off".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 !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 },
lockout_until: zone.lockout_until,
current_schedule_id: active.map(|item| item.id.clone()),
current_schedule_name: active.map(|item| item.name.clone()),
+121 -47
View File
@@ -26,7 +26,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
// is being applied. Group and zone locks then make the member update atomic.
let _house_guard = state.lock_house_operation().await;
// Group state participates in thermostat arbitration. Exclude an already-running cycle
// while changing the group gate/profile so no cycle can act on a stale group snapshot.
// while changing group control/profile state so no cycle can act on a stale group snapshot.
// Lock order stays house -> cycle -> group -> zone -> device.
let _cycle_guard = state.lock_zone_control_cycle().await;
let _group_guard = state.lock_group_operation(group_id).await;
@@ -55,6 +55,23 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
let schedules = state.db.list_schedules()?;
let custom_setpoint = patch.setpoint.map(|value| (value * 10.0).round() / 10.0);
let climate_change = patch.mode.is_some() || patch.preset.is_some() || custom_setpoint.is_some();
let resulting_control_enabled = patch.power.unwrap_or(group.power_enabled);
if climate_change && !resulting_control_enabled {
if source == "automation.group" {
state.log("info", "automation.group_control_disabled", &format!("Group automation suppressed because group control is disabled 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": state.settings.read().await.house_power_enabled,
"suppressed": true,
}));
}
return Err(AppError::BadRequest("enable group control before changing group mode, preset or setpoint".into()));
}
if let Some(power) = patch.power {
group.power_enabled = power;
}
@@ -62,27 +79,39 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
state.db.save_group(&group)?;
state.broadcast("group.updated", serde_json::to_value(&group)?);
// Explicit group ON is a conscious request to run this group. Resume the global
// master without changing the gates of any other groups. This makes group ON work
// even after a previous whole-house OFF while preserving multi-group OFF priority.
if patch.power == Some(true) && source != "automation.group" {
let mut settings = state.settings.write().await;
if !settings.house_power_enabled {
settings.house_power_enabled = true;
state.db.save_runtime_settings(&settings)?;
state.broadcast("house.power_changed", json!({"house_power_enabled": true}));
state.log("info", "house.power_resumed_by_group", &format!("Whole-house master resumed by group {}", group.name), json!({
"group_id": group.id, "source": source
}));
}
}
// Group power is a scoped bulk power action. OFF powers member thermostats down but
// releases group ownership: members are represented as individually OFF, not as
// "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 mut zones = Vec::new();
let mut failed = Vec::new();
let mut forced_off_devices = std::collections::HashSet::new();
for zone_id in &group.zone_ids {
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; };
let temporary_owns_zone = temporary_quick_thermostat_is_active(&zone, Utc::now());
let mut temporary_owns_zone = temporary_quick_thermostat_is_active(&zone, Utc::now());
// A user/HA group power click is an explicit scoped takeover. End any older direct
// or temporary ownership so all members react consistently to the bulk command.
// Scheduled automations deliberately do not do this: manual/local ownership keeps
// its higher priority and the automation can only affect currently free members.
if explicit_group_power {
if zone.temporary_quick_thermostat.is_some() {
if temporary_owns_zone {
finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode);
} else {
zone.temporary_quick_thermostat = None;
}
temporary_owns_zone = false;
}
if zone.device_manual_override {
reset_device_manual_override(&mut zone);
}
}
if temporary_owns_zone {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
if let Some(mode) = patch.mode.as_deref() { session.deferred_mode = Some(mode.to_string()); }
@@ -127,48 +156,93 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
}
}
if !temporary_owns_zone && !zone.device_manual_override && zone.local_thermostat_power.is_none() {
zone.control_owner = "automation".into();
zone.control_source = format!("group:{}", group.id);
zone.control_since = Some(Utc::now());
zone.control_reason = format!("Controlled by group {}", group.name);
// Power OFF is persisted as an ordinary per-zone thermostat OFF, with no automatic
// 15-minute hand-back. This keeps the physical unit off while removing group ownership
// and, importantly, still lets the user turn that thermostat back on independently.
// Power ON clears this scoped OFF and returns the member to group arbitration.
let mut force_power_off_after_save = false;
let mut applied_group_power: Option<bool> = None;
if let Some(power) = patch.power {
let automatic_power_blocked = source == "automation.group"
&& (zone.device_manual_override || zone.local_thermostat_power.is_some() || temporary_owns_zone);
if automatic_power_blocked {
failed.push(json!({
"scope": "ownership",
"zone_id": zone.id,
"device_id": zone.device_id,
"error": "manual/local thermostat ownership has priority over group automation",
}));
} else if power {
zone.local_thermostat_power = None;
zone.local_thermostat_resume_at = None;
zone.local_thermostat_restore_zone_enabled = None;
applied_group_power = Some(true);
} else {
zone.local_thermostat_power = Some(false);
zone.local_thermostat_resume_at = None;
zone.local_thermostat_restore_zone_enabled = None;
zone.demand = false;
zone.demand_since = None;
zone.effective_mode = "off".into();
zone.device_setpoint = None;
force_power_off_after_save = forced_off_devices.insert(zone.device_id.clone());
applied_group_power = Some(false);
}
}
if !temporary_owns_zone && !zone.device_manual_override {
if applied_group_power == Some(false) {
zone.control_owner = "local_thermostat".into();
zone.control_source = "local_thermostat".into();
zone.control_since = Some(Utc::now());
zone.control_resume_at = None;
zone.control_reason = format!("Group {} powered the thermostat off; group ownership released", group.name);
} else if group.power_enabled && zone.local_thermostat_power.is_none() {
zone.control_owner = "automation".into();
zone.control_source = format!("group:{}", group.id);
zone.control_since = Some(Utc::now());
zone.control_resume_at = zone.manual_override_until;
zone.control_reason = format!("Controlled by group {}", group.name);
} else if !group.power_enabled && zone.control_source == format!("group:{}", group.id) {
zone.control_owner = "automation".into();
zone.control_source = "automation".into();
zone.control_since = Some(Utc::now());
zone.control_resume_at = zone.manual_override_until;
zone.control_reason = format!("Group {} ownership released; zone automation resumed", group.name);
}
}
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)?);
if force_power_off_after_save {
if let Err(err) = force_power_off_device_locked(state, &zone.device_id).await {
state.log("error", "group.power_off_error", &err.to_string(), json!({
"group_id": group.id,
"zone_id": zone.id,
"device_id": zone.device_id,
"source": source,
}));
failed.push(json!({
"scope": "device",
"zone_id": zone.id,
"device_id": zone.device_id,
"error": err.to_string(),
}));
}
}
zones.push(zone);
}
let runtime = state.settings.read().await.clone();
let master_power_enabled = runtime.house_power_enabled;
let should_command_power = patch.power.is_some();
let desired_power = group.power_enabled;
// A zone may intentionally belong to more than one group. Power-off is authoritative:
// turning one group on must never briefly wake a member that is still blocked by another group.
let mut failed = Vec::new();
if should_command_power && !desired_power {
let mut seen = std::collections::HashSet::new();
for zone in &zones {
if !seen.insert(zone.device_id.clone()) { continue; }
// Group OFF is an immediate safety transition. Group ON never emits a bare
// power=true frame; the thermostat arbiter starts the unit with mode/target.
let Some(device) = state.db.get_device(&zone.device_id)? else { continue; };
if !device.enabled { continue; }
match send_group_power_if_current(state, &group.id, &zone.id, &device.id, false).await {
Ok(_) => {}
Err(err) => {
state.log("error", "group.power_error", &err.to_string(), json!({
"group_id": group.id, "device_id": device.id, "device_name": device.name,
"power": false, "source": source,
}));
failed.push(json!({"device_id": device.id, "device_name": device.name, "error": err.to_string()}));
}
}
}
}
let control_toggled_on = patch.power == Some(true);
let control_enabled = group.power_enabled;
let run_immediately = desired_power && (should_command_power || climate_change);
// OFF is already a forced, per-member physical transition performed under zone -> device
// locks above. ON/profile/setpoint changes use the thermostat arbiter so target, hysteresis,
// compressor protection and mode-change safety are applied consistently before returning.
let run_immediately = control_toggled_on || (control_enabled && climate_change);
let zones = if run_immediately {
// Do not just wake the background loop: a group ON/profile/setpoint action is expected
// to arbitrate every member before the HTTP request completes. Release member/domain
+1 -4
View File
@@ -1,4 +1,4 @@
pub fn refresh_control_ownership(zone: &mut Zone, house_power_enabled: bool, blocked_by_group: 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())
@@ -24,9 +24,6 @@ pub fn refresh_control_ownership(zone: &mut Zone, house_power_enabled: bool, blo
"Local thermostat owns the zone".into()
};
("local_thermostat", source, resume_at, reason)
} else if blocked_by_group {
let source = if zone.control_source.starts_with("group:") { zone.control_source.clone() } else { "group".into() };
("automation", source, None, "Zone is blocked by a disabled group".to_string())
} else {
let source = if zone.control_source.starts_with("group:") { zone.control_source.clone() } else { "automation".into() };
("automation", source, zone.manual_override_until, "Automatic thermostat/schedule control".to_string())
+2 -61
View File
@@ -22,20 +22,7 @@ async fn thermostat_ownership_is_current(state: &AppState, zone_id: &str, device
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); }
if zone.local_thermostat_power == Some(true) { return Ok(true); }
let blocked = state.db.list_groups()?.iter().any(|group| {
!group.power_enabled && group.zone_ids.iter().any(|member| member == zone_id)
});
Ok(!blocked)
}
async fn group_off_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.is_some() { return Ok(false); }
Ok(state.db.list_groups()?.iter().any(|group| {
!group.power_enabled && group.zone_ids.iter().any(|member| member == zone_id)
}))
Ok(true)
}
async fn send_zone_command_if_owned(
@@ -43,57 +30,16 @@ async fn send_zone_command_if_owned(
zone_id: &str,
device_id: &str,
command: DeviceCommand,
require_group_block: bool,
) -> Result<Option<Device>, AppError> {
// Ownership must be checked after acquiring the same per-device lock used by polling.
// Otherwise polling could detect a remote takeover while this task is waiting for the lock,
// and a stale thermostat decision would still be sent immediately afterwards.
let _device_guard = state.lock_device_operation(device_id).await;
let owned = if require_group_block {
group_off_ownership_is_current(state, zone_id, device_id).await?
} else {
thermostat_ownership_is_current(state, zone_id, device_id).await?
};
let owned = thermostat_ownership_is_current(state, zone_id, device_id).await?;
if !owned { return Ok(None); }
send_command_locked(state, device_id, command).await.map(Some)
}
async fn send_group_power_if_current(
state: &AppState,
group_id: &str,
zone_id: &str,
device_id: &str,
desired_power: bool,
) -> Result<Option<Device>, AppError> {
let _device_guard = state.lock_device_operation(device_id).await;
let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(None); };
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { return Ok(None); }
let groups = state.db.list_groups()?;
let Some(group) = groups.iter().find(|group| group.id == group_id) else { return Ok(None); };
if group.power_enabled != desired_power || !group.zone_ids.iter().any(|member| member == zone_id) { return Ok(None); }
if desired_power {
let settings = state.settings.read().await;
if !settings.house_power_enabled { return Ok(None); }
let effective_mode = if zone.inherit_house_mode { settings.house_mode.as_str() } else { zone.mode.as_str() };
if effective_mode == "off" { return Ok(None); }
if groups.iter().any(|other| {
other.id != group_id && !other.power_enabled && other.zone_ids.iter().any(|member| member == zone_id)
}) {
return Ok(None);
}
}
let Some(device) = state.db.get_device(device_id)? else { return Ok(None); };
if !device.enabled { return Ok(None); }
let command = DeviceCommand { power: Some(desired_power), ..Default::default() };
if desired_power {
send_command_locked(state, device_id, command).await.map(Some)
} else {
// A deliberate group OFF is a one-shot safety transition. Send it even if the
// cached state already says OFF; the regulator itself will not keep repeating it.
send_command_locked_forced(state, device_id, command).await.map(Some)
}
}
async fn send_automatic_device_command_if_owned(
state: &AppState,
device_id: &str,
@@ -105,7 +51,6 @@ async fn send_automatic_device_command_if_owned(
if device_blocked_by_disabled_zone(device_id, &zones)
|| device_blocked_by_manual_override(device_id, &zones)
|| device_blocked_by_local_thermostat(device_id, &zones)
|| device_blocked_by_disabled_group(device_id, &zones, &state.db.list_groups()?)
{
return Ok(None);
}
@@ -132,10 +77,6 @@ async fn apply_automatic_device_action(
if !settings.house_power_enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() {
return Ok(None);
}
let groups = state.db.list_groups()?;
if groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|member| member == &zone.id)) {
return Ok(None);
}
// A power-on automation is an explicit domain transition and may re-enable a zone that
// a previous power automation disabled. Other actions still respect a disabled zone gate.
if !zone.enabled && command.power != Some(true) { return Ok(None); }
+3 -30
View File
@@ -1,7 +1,6 @@
async fn control_zones(state: &AppState) -> Result<()> {
let _cycle_guard = state.lock_zone_control_cycle().await;
let schedules = state.db.list_schedules()?;
let groups = state.db.list_groups()?;
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
@@ -119,9 +118,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
// remains authoritative and is checked before this loop.
let effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
zone.effective_mode = effective_mode_owned.clone();
let ownership_blocked_by_group = zone.local_thermostat_power != Some(true)
&& groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
refresh_control_ownership(&mut zone, settings.house_power_enabled, ownership_blocked_by_group);
refresh_control_ownership(&mut zone, settings.house_power_enabled);
let effective_mode = effective_mode_owned.as_str();
let previous_source = zone.control_temperature_source.clone();
@@ -289,30 +286,6 @@ async fn control_zones(state: &AppState) -> Result<()> {
continue;
}
let blocked_by_group = ownership_blocked_by_group;
if blocked_by_group {
zone.effective_mode = "off".into();
zone.demand = false;
zone.demand_since = None;
zone.device_setpoint = None;
if device.online && device.communication_failures == 0 && device.power {
match send_zone_command_if_owned(
state,
&zone.id,
&zone.device_id,
DeviceCommand { power: Some(false), ..Default::default() },
true,
).await {
Ok(_) => {}
Err(err) => state.log("error", "group.power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id})),
}
}
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;
}
if discrepancy && previous_source != "device_discrepancy_fallback" {
state.log("warn", "zone.sensor_discrepancy", &format!("Zone {} sensors differ by more than {:.1} C; using GREE sensor", zone.name, zone.max_sensor_difference), json!({
"zone_id": zone.id,
@@ -464,7 +437,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue;
}
match send_zone_command_if_owned(state, &zone.id, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }, false).await {
match send_zone_command_if_owned(state, &zone.id, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await {
Ok(Some(_)) => {
zone.last_power_change_at = Some(now);
zone.lockout_until = Some(now + chrono::Duration::seconds(zone.min_off_seconds as i64));
@@ -516,7 +489,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
sleep: desired_sleep,
..Default::default()
};
match send_zone_command_if_owned(state, &zone.id, &zone.device_id, command, false).await {
match send_zone_command_if_owned(state, &zone.id, &zone.device_id, command).await {
Ok(Some(updated_device)) => {
let transition_at = Utc::now();
if device.power != updated_device.power { zone.last_power_change_at = Some(transition_at); }