v0.8.2
This commit is contained in:
+5
-27
@@ -23,24 +23,6 @@ async fn clear_group_control_sources(state: &AppState, reason: &str) -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> {
|
||||
let mut group_ids: Vec<String> = state.db.list_groups()?.into_iter().map(|group| group.id).collect();
|
||||
group_ids.sort();
|
||||
group_ids.dedup();
|
||||
let mut _group_guards = Vec::with_capacity(group_ids.len());
|
||||
for group_id in &group_ids {
|
||||
_group_guards.push(state.lock_group_operation(group_id).await);
|
||||
}
|
||||
for mut group in state.db.list_groups()? {
|
||||
if group.power_enabled == power { continue; }
|
||||
group.power_enabled = power;
|
||||
group.updated_at = Utc::now();
|
||||
state.db.save_group(&group)?;
|
||||
state.broadcast("group.updated", serde_json::to_value(&group)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_all_local_thermostat_overrides(state: &AppState) -> Result<usize, AppError> {
|
||||
let mut cleared = 0;
|
||||
for mut zone in state.db.list_zones()? {
|
||||
@@ -123,9 +105,6 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
|
||||
};
|
||||
state.broadcast("settings.updated", payload.clone());
|
||||
clear_group_control_sources(&state, "Whole-house mode control took ownership").await?;
|
||||
if activate_all {
|
||||
set_all_groups_power(&state, true).await?;
|
||||
}
|
||||
// run_zone_control_now takes the same cycle lock, so release the mutation window first.
|
||||
drop(cycle_guard);
|
||||
if activate_all {
|
||||
@@ -157,10 +136,10 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
|
||||
}
|
||||
}
|
||||
|
||||
// Global power is a true cascade across group gates. OFF clears the current takeover
|
||||
// markers once, then each enabled device is re-cleared atomically with its OFF command.
|
||||
// A later pilot action is therefore not erased by subsequent controller cycles.
|
||||
set_all_groups_power(&state, input.power).await?;
|
||||
// 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?;
|
||||
if !input.power {
|
||||
let zone_snapshot = state.db.list_zones()?;
|
||||
@@ -205,7 +184,7 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
|
||||
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; all groups and enabled devices powered off" }, json!({
|
||||
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!({
|
||||
"power": input.power,
|
||||
"failed": failed.len(),
|
||||
}));
|
||||
@@ -237,7 +216,6 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
|
||||
public_settings(&settings)
|
||||
};
|
||||
state.broadcast("settings.updated", settings_payload.clone());
|
||||
set_all_groups_power(&state, true).await?;
|
||||
clear_group_control_sources(&state, "Whole-house preset control took ownership").await?;
|
||||
|
||||
let schedules = state.db.list_schedules()?;
|
||||
|
||||
+1
-6
@@ -380,24 +380,19 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
|
||||
}
|
||||
|
||||
// Configuration import never restores ephemeral owners/timers or cached physical state.
|
||||
// Imported devices are reconciled from a fresh poll and current house/group/zone gates.
|
||||
// Imported devices are reconciled from a fresh poll and current house/zone gates.
|
||||
sanitize_configuration_runtime(&mut export);
|
||||
state.initial_device_sync_complete.store(false, Ordering::Release);
|
||||
state.db.replace_configuration(&export)?;
|
||||
state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed);
|
||||
*state.settings.write().await = export.settings.clone();
|
||||
|
||||
let disabled_group_zones: std::collections::HashSet<String> = export.groups.iter()
|
||||
.filter(|group| !group.power_enabled)
|
||||
.flat_map(|group| group.zone_ids.iter().cloned())
|
||||
.collect();
|
||||
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
|
||||
&& effective_mode != "off"
|
||||
&& !disabled_group_zones.contains(&zone.id)
|
||||
})
|
||||
.map(|zone| zone.device_id.clone())
|
||||
.collect();
|
||||
|
||||
+1
-3
@@ -618,9 +618,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();
|
||||
let blocked_by_group = zone.local_thermostat_power != Some(true)
|
||||
&& state.db.list_groups()?.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
|
||||
engine::refresh_control_ownership(&mut zone, runtime.house_power_enabled, blocked_by_group);
|
||||
engine::refresh_control_ownership(&mut zone, runtime.house_power_enabled);
|
||||
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 +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())
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -28,7 +28,7 @@ pub struct ZoneControlPlan {
|
||||
pub device_name: String,
|
||||
/// Configured per-zone enable switch. Group/master gates are reported separately.
|
||||
pub enabled: bool,
|
||||
/// True when the configured zone is not currently blocked by a disabled climate group.
|
||||
/// True when the configured zone is eligible for thermostat control. Disabled group control does not block the zone.
|
||||
pub effective_enabled: bool,
|
||||
/// Effective mode currently used by the controller.
|
||||
pub mode: String,
|
||||
|
||||
@@ -180,6 +180,8 @@ pub struct ClimateGroup {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub zone_ids: Vec<String>,
|
||||
/// Group bulk-power/control state. False powers member thermostats off while releasing
|
||||
/// group ownership; each member can still be turned back on independently.
|
||||
#[serde(default = "default_group_power_enabled")]
|
||||
pub power_enabled: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
|
||||
Reference in New Issue
Block a user