v0.9.4-fix

This commit is contained in:
Mateusz Gruszczyński
2026-09-01 14:59:00 +02:00
parent 3c490b3064
commit 150cf6cb7f
13 changed files with 285 additions and 62 deletions
+34 -17
View File
@@ -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()),
+22 -8
View File
@@ -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()
+7 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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()));
}
}
+54
View File
@@ -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);