v0.9.4-fix
This commit is contained in:
+34
-17
@@ -20,25 +20,36 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
let configured_effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
|
||||
refresh_control_ownership(&mut zone, true);
|
||||
let configured_effective_mode = configured_effective_mode_owned.as_str();
|
||||
let target_mode = if configured_effective_mode == "off" { zone.mode.as_str() } else { configured_effective_mode };
|
||||
let active_for_target = active_schedule_for_zone(&zone, &schedules, now);
|
||||
let has_intent_source = zone_has_thermostat_intent_source(&zone, active_for_target, Utc::now());
|
||||
let automation_idle = zone.enabled
|
||||
&& !zone.device_manual_override
|
||||
&& zone.local_thermostat_power != Some(false)
|
||||
&& configured_effective_mode != "off"
|
||||
&& !has_intent_source;
|
||||
let automatic_active = zone.enabled
|
||||
&& !zone.device_manual_override
|
||||
&& zone.local_thermostat_power != Some(false)
|
||||
&& configured_effective_mode != "off"
|
||||
&& has_intent_source;
|
||||
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) {
|
||||
"off"
|
||||
} else {
|
||||
} else if automatic_active {
|
||||
configured_effective_mode
|
||||
} else {
|
||||
"off"
|
||||
};
|
||||
|
||||
// Keep the thermostat target readable even while the zone/group/house control is off.
|
||||
// Home Assistant climate entities otherwise expose target_temperature as unknown.
|
||||
let target_mode = if configured_effective_mode == "off" { zone.mode.as_str() } else { configured_effective_mode };
|
||||
let active_for_target = active_schedule_for_zone(&zone, &schedules, now);
|
||||
let (resolved_preset, resolved_target) = resolve_zone_target(&zone, active_for_target, target_mode);
|
||||
let active = if effective_mode == "off" { None } else { active_for_target };
|
||||
let next_events = if effective_mode == "off" {
|
||||
let active = if automatic_active { active_for_target } else { None };
|
||||
// Future schedule events remain visible while the zone is currently idle between
|
||||
// windows; idle must not be confused with a disabled schedule system.
|
||||
let next_events = if configured_effective_mode == "off" {
|
||||
Vec::new()
|
||||
} else {
|
||||
next_schedule_events(&zone, &schedules, effective_mode, now, 8)
|
||||
next_schedule_events(&zone, &schedules, configured_effective_mode, now, 8)
|
||||
};
|
||||
for event in next_events.iter().take(2) {
|
||||
let mut event = event.clone();
|
||||
@@ -57,23 +68,25 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
mode: effective_mode.to_string(),
|
||||
configured_mode: zone.mode.clone(),
|
||||
inherit_house_mode: zone.inherit_house_mode,
|
||||
preset: resolved_preset,
|
||||
preset: if has_intent_source { resolved_preset } else { "auto".into() },
|
||||
preset_override: zone.manual_preset.clone(),
|
||||
current_temperature: zone.current_temperature,
|
||||
target_temperature: if zone.device_manual_override || !zone.enabled || effective_mode == "off" {
|
||||
target_temperature: if !has_intent_source {
|
||||
None
|
||||
} else if zone.device_manual_override || !zone.enabled || effective_mode == "off" {
|
||||
// A remote/manual takeover may leave a physical standby target persisted in the
|
||||
// zone runtime snapshot. Never publish that value as the thermostat target.
|
||||
// zone runtime snapshot. Publish only the underlying explicit thermostat target.
|
||||
Some(resolved_target)
|
||||
} else {
|
||||
zone.effective_setpoint.or(Some(resolved_target))
|
||||
},
|
||||
device_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature),
|
||||
desired_power: zone.enabled && effective_mode != "off" && !zone.device_manual_override,
|
||||
desired_power: automatic_active,
|
||||
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: zone.enabled && effective_mode != "off" && !zone.device_manual_override && zone.demand,
|
||||
demand: automatic_active && zone.demand,
|
||||
control_source: zone.control_temperature_source.clone(),
|
||||
manual_override_until: zone.manual_override_until,
|
||||
local_thermostat_power: zone.local_thermostat_power,
|
||||
@@ -84,8 +97,12 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
control_command_source: zone.control_source.clone(),
|
||||
control_since: zone.control_since,
|
||||
resume_at: zone.control_resume_at,
|
||||
control_reason: zone.control_reason.clone(),
|
||||
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 },
|
||||
control_reason: if automation_idle {
|
||||
"Automation idle: no active schedule or explicit thermostat request".into()
|
||||
} else {
|
||||
zone.control_reason.clone()
|
||||
},
|
||||
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 if automation_idle { Some("no_active_thermostat_intent".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()),
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
pub const LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES: i64 = 15;
|
||||
|
||||
/// Apply the whole-house bulk power state to a thermostat without creating a persistent
|
||||
/// global gate. OFF leaves the zone explicitly/indefinitely off; ON releases that local OFF
|
||||
/// so the zone can immediately return to its group/manual/automatic controller.
|
||||
/// Apply the whole-house bulk power state without creating a blocking global gate. OFF leaves
|
||||
/// the zone explicitly/indefinitely off. ON releases that local OFF and records a non-manual
|
||||
/// whole-house thermostat intent so an explicit Global ON remains effective without stealing
|
||||
/// local/group/direct ownership.
|
||||
pub fn set_house_bulk_thermostat_power(zone: &mut Zone, power: bool) -> bool {
|
||||
if power {
|
||||
if zone.local_thermostat_power != Some(false) { return false; }
|
||||
zone.local_thermostat_power = None;
|
||||
zone.local_thermostat_resume_at = None;
|
||||
zone.local_thermostat_restore_zone_enabled = None;
|
||||
true
|
||||
let mut changed = false;
|
||||
if zone.local_thermostat_power == Some(false) {
|
||||
zone.local_thermostat_power = None;
|
||||
zone.local_thermostat_resume_at = None;
|
||||
zone.local_thermostat_restore_zone_enabled = None;
|
||||
changed = true;
|
||||
}
|
||||
// Whole-house ON is itself an explicit thermostat intent, but it is not manual/local
|
||||
// ownership. Mark only otherwise-free zones so group/local/direct owners keep priority.
|
||||
if !zone.device_manual_override
|
||||
&& zone.local_thermostat_power.is_none()
|
||||
&& !zone.control_source.starts_with("group:")
|
||||
&& zone.control_source != "house_power"
|
||||
{
|
||||
zone.control_source = "house_power".into();
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
} else {
|
||||
let changed = zone.local_thermostat_power != Some(false)
|
||||
|| zone.local_thermostat_resume_at.is_some()
|
||||
|
||||
@@ -23,7 +23,13 @@ pub fn refresh_control_ownership(zone: &mut Zone, _house_power_enabled: bool) {
|
||||
};
|
||||
("local_thermostat", source, resume_at, reason)
|
||||
} else {
|
||||
let source = if zone.control_source.starts_with("group:") { zone.control_source.clone() } else { "automation".into() };
|
||||
let source = if zone.control_source.starts_with("group:")
|
||||
|| matches!(zone.control_source.as_str(), "automation.device" | "house_power")
|
||||
{
|
||||
zone.control_source.clone()
|
||||
} else {
|
||||
"automation".into()
|
||||
};
|
||||
("automation", source, zone.manual_override_until, "Automatic thermostat/schedule control".to_string())
|
||||
};
|
||||
if zone.control_owner != owner || zone.control_source != source {
|
||||
|
||||
+52
-4
@@ -24,6 +24,8 @@ fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) ->
|
||||
(item.preset.clone(), profile_setpoint(zone, &item.preset, mode))
|
||||
}
|
||||
} else {
|
||||
// Comfort is only a fallback target for an explicit controller (local/group/etc.).
|
||||
// The absence of a schedule must not by itself create thermostat demand.
|
||||
("comfort".into(), profile_setpoint(zone, "comfort", mode))
|
||||
};
|
||||
// Quick +/- temperature adjustments are independent from the selected preset.
|
||||
@@ -31,16 +33,62 @@ fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) ->
|
||||
(preset, zone.manual_setpoint.unwrap_or(base_target))
|
||||
}
|
||||
|
||||
/// Return whether some thermostat source currently asks this zone to participate in climate
|
||||
/// control. Heat/Cool configuration plus the implicit Comfort fallback is intentionally not a
|
||||
/// source on its own.
|
||||
fn zone_has_thermostat_intent_source(zone: &Zone, schedule: Option<&Schedule>, now: DateTime<Utc>) -> bool {
|
||||
zone.local_thermostat_power == Some(true)
|
||||
|| temporary_quick_thermostat_is_active(zone, now)
|
||||
|| zone.manual_preset.is_some()
|
||||
|| zone.manual_setpoint.is_some()
|
||||
|| zone.control_source.starts_with("group:")
|
||||
|| matches!(zone.control_source.as_str(), "automation.device" | "house_power")
|
||||
|| schedule.is_some()
|
||||
}
|
||||
|
||||
/// Automatic thermostat arbitration may run only when an explicit source exists and no higher
|
||||
/// priority OFF/manual gate blocks it. This keeps a resumed/global/group-OFF zone physically OFF
|
||||
/// while it waits for the next schedule window or another explicit thermostat request.
|
||||
fn zone_has_active_thermostat_intent(zone: &Zone, schedule: Option<&Schedule>, now: DateTime<Utc>) -> bool {
|
||||
zone.enabled
|
||||
&& !zone.device_manual_override
|
||||
&& zone.local_thermostat_power != Some(false)
|
||||
&& zone_has_thermostat_intent_source(zone, schedule, now)
|
||||
}
|
||||
|
||||
pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) {
|
||||
let configured_mode = effective_zone_mode(zone, house_mode);
|
||||
zone.effective_mode = configured_mode.clone();
|
||||
let target_mode = if configured_mode == "off" { zone.mode.as_str() } else { configured_mode.as_str() };
|
||||
let schedule = active_schedule_for_zone(zone, schedules, Local::now());
|
||||
let has_intent_source = zone_has_thermostat_intent_source(zone, schedule, Utc::now());
|
||||
|
||||
// During a physical/manual takeover keep effective_mode owned by the real device state.
|
||||
// We may still refresh an underlying explicit thermostat target for the Resume UI.
|
||||
if zone.device_manual_override {
|
||||
if has_intent_source {
|
||||
let (preset, target) = resolve_zone_target(zone, schedule, target_mode);
|
||||
zone.active_preset = preset;
|
||||
zone.effective_setpoint = Some(target);
|
||||
} else {
|
||||
zone.effective_setpoint = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if !zone.enabled || zone.local_thermostat_power == Some(false) || !has_intent_source {
|
||||
zone.effective_mode = "off".into();
|
||||
zone.effective_setpoint = None;
|
||||
zone.device_setpoint = None;
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.target_alerted_at = None;
|
||||
return;
|
||||
}
|
||||
|
||||
zone.effective_mode = configured_mode;
|
||||
let (preset, target) = resolve_zone_target(zone, schedule, target_mode);
|
||||
zone.active_preset = preset;
|
||||
if !zone.device_manual_override {
|
||||
zone.effective_setpoint = Some(target);
|
||||
}
|
||||
zone.effective_setpoint = Some(target);
|
||||
}
|
||||
|
||||
fn effective_zone_mode(zone: &Zone, house_mode: &str) -> String {
|
||||
|
||||
+84
-2
@@ -634,6 +634,81 @@ mod tests {
|
||||
assert_eq!(target, 22.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_thermostat_without_active_intent_stays_idle() {
|
||||
let zone = test_zone("device");
|
||||
assert!(!zone_has_thermostat_intent_source(&zone, None, Utc::now()));
|
||||
assert!(!zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_thermostat_sources_activate_control_without_schedule() {
|
||||
let mut zone = test_zone("device");
|
||||
|
||||
zone.manual_setpoint = Some(22.0);
|
||||
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
||||
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_preset = Some("sleep".into());
|
||||
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
||||
|
||||
zone.manual_preset = None;
|
||||
zone.local_thermostat_power = Some(true);
|
||||
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
||||
|
||||
zone.local_thermostat_power = None;
|
||||
zone.control_source = "group:upstairs".into();
|
||||
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
||||
|
||||
zone.control_source = "automation.device".into();
|
||||
refresh_control_ownership(&mut zone, true);
|
||||
assert_eq!(zone.control_owner, "automation");
|
||||
assert_eq!(zone.control_source, "automation.device");
|
||||
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_off_blocks_even_an_active_schedule_until_resume() {
|
||||
let mut zone = test_zone("device");
|
||||
let schedule = test_schedule("active", vec![1,2,3,4,5,6,7], "00:00", "00:00");
|
||||
zone.local_thermostat_power = Some(false);
|
||||
assert!(zone_has_thermostat_intent_source(&zone, Some(&schedule), Utc::now()));
|
||||
assert!(!zone_has_active_thermostat_intent(&zone, Some(&schedule), Utc::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_target_refresh_has_no_implicit_comfort_activation() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.inherit_house_mode = false;
|
||||
zone.mode = "cool".into();
|
||||
zone.effective_setpoint = Some(23.0);
|
||||
|
||||
refresh_zone_runtime_target(&mut zone, &[], "cool");
|
||||
|
||||
assert_eq!(zone.effective_mode, "off");
|
||||
assert_eq!(zone.effective_setpoint, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resuming_local_off_without_schedule_stays_off() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.inherit_house_mode = false;
|
||||
zone.mode = "cool".into();
|
||||
zone.demand = true;
|
||||
set_local_thermostat_power(&mut zone, false, Utc::now());
|
||||
|
||||
assert!(reset_local_thermostat_override(&mut zone));
|
||||
refresh_control_ownership(&mut zone, true);
|
||||
refresh_zone_runtime_target(&mut zone, &[], "cool");
|
||||
|
||||
assert!(zone.local_thermostat_power.is_none());
|
||||
assert_eq!(zone.control_source, "automation");
|
||||
assert_eq!(zone.effective_mode, "off");
|
||||
assert_eq!(zone.effective_setpoint, None);
|
||||
assert!(!zone.demand);
|
||||
assert!(!zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_target_refresh_applies_manual_profile_immediately() {
|
||||
let mut zone = test_zone("device");
|
||||
@@ -800,14 +875,21 @@ mod tests {
|
||||
assert!(set_house_bulk_thermostat_power(&mut zone, true));
|
||||
assert!(zone.local_thermostat_power.is_none());
|
||||
assert!(zone.local_thermostat_resume_at.is_none());
|
||||
assert_eq!(zone.control_source, "house_power");
|
||||
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_house_on_does_not_create_a_local_on_override() {
|
||||
fn whole_house_on_creates_automatic_intent_without_local_ownership() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.local_thermostat_power = None;
|
||||
assert!(!set_house_bulk_thermostat_power(&mut zone, true));
|
||||
assert!(set_house_bulk_thermostat_power(&mut zone, true));
|
||||
assert!(zone.local_thermostat_power.is_none());
|
||||
assert_eq!(zone.control_source, "house_power");
|
||||
refresh_control_ownership(&mut zone, true);
|
||||
assert_eq!(zone.control_owner, "automation");
|
||||
assert_eq!(zone.control_source, "house_power");
|
||||
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -385,6 +385,60 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
}
|
||||
|
||||
let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now());
|
||||
|
||||
// Heat/Cool configuration alone is not an instruction to run forever on the implicit
|
||||
// Comfort profile. After local/global/group OFF is handed back to automation, a zone
|
||||
// with no active schedule, quick/manual target, temporary session or active scoped
|
||||
// controller stays physically OFF. This also makes gaps between schedule windows true
|
||||
// OFF periods instead of silently falling back to Comfort and recreating demand.
|
||||
if !zone_has_active_thermostat_intent(&zone, active_schedule, Utc::now()) {
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
zone.effective_mode = "off".into();
|
||||
zone.effective_setpoint = None;
|
||||
zone.device_setpoint = None;
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.target_alerted_at = None;
|
||||
if zone.control_owner == "automation" {
|
||||
zone.control_reason = "Automation idle: no active schedule or explicit thermostat request".into();
|
||||
zone.control_resume_at = 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() },
|
||||
).await {
|
||||
Ok(Some(updated_device)) => {
|
||||
let transition_at = Utc::now();
|
||||
if device.power != updated_device.power {
|
||||
zone.last_power_change_at = Some(transition_at);
|
||||
}
|
||||
zone.last_action_at = Some(transition_at);
|
||||
state.log("info", "zone.automation_idle_off", &format!("Zone {} remains OFF: no active thermostat intent", zone.name), json!({
|
||||
"zone_id": zone.id,
|
||||
"device_id": zone.device_id,
|
||||
"active_schedule": false,
|
||||
"manual_preset": zone.manual_preset,
|
||||
"manual_setpoint": zone.manual_setpoint,
|
||||
"control_source": zone.control_source,
|
||||
}));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => state.log("error", "zone.automation_idle_off_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;
|
||||
}
|
||||
|
||||
let (preset, target) = resolve_zone_target(&zone, active_schedule, effective_mode);
|
||||
zone.active_preset = preset;
|
||||
zone.effective_setpoint = Some(target);
|
||||
|
||||
Reference in New Issue
Block a user