v0.9.3
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user