This commit is contained in:
Mateusz Gruszczyński
2026-08-27 14:16:56 +02:00
parent 584bdca7c6
commit 7102d05cb3
21 changed files with 305 additions and 65 deletions
+53 -7
View File
@@ -47,7 +47,10 @@ pub fn start(state: AppState) {
tracing::error!(error=?err, "automation cycle failed");
}
let seconds = control_state.settings.read().await.zone_interval_seconds.max(2);
sleep(Duration::from_secs(seconds)).await;
tokio::select! {
_ = sleep(Duration::from_secs(seconds)) => {},
_ = control_state.zone_control_wakeup.notified() => {},
}
}
});
@@ -728,6 +731,9 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
}
}
if desired_power && should_command_power {
state.wake_zone_control();
}
state.log("info", source, &format!("Updated group {}", group.name), json!({
"group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset,
"zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled,
@@ -1023,8 +1029,19 @@ async fn control_zones(state: &AppState) -> Result<()> {
// automation control. Continue sensor/history updates, but reflect the unit's real state
// instead of sending corrective frames that would fight the person holding the remote.
if zone.device_manual_override {
// Manual/remote takeover pauses commands, but it must not erase the thermostat's
// selected profile/target. Keep the intended target visible and report the physical
// unit target separately through device_setpoint. This makes Resume/Profile actions
// deterministic and avoids a standby device target (for example 25 C) masquerading
// as the zone's Sleep/Comfort target.
let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode };
let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now());
let (preset, target) = resolve_zone_target(&zone, active_schedule, target_mode);
zone.active_preset = preset;
zone.effective_setpoint = Some(target);
// Keep effective_mode's existing meaning during takeover: it reflects the physical
// unit, while effective_setpoint above remains the thermostat intent.
zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() };
zone.effective_setpoint = if device.power { Some(device.target_temperature) } else { None };
zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None };
zone.demand = false;
zone.demand_since = None;
@@ -1529,6 +1546,18 @@ fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) ->
(preset, zone.manual_setpoint.unwrap_or(base_target))
}
pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) {
let configured_mode = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() };
zone.effective_mode = configured_mode.to_string();
let target_mode = if configured_mode == "off" { zone.mode.as_str() } else { configured_mode };
let schedule = active_schedule_for_zone(zone, schedules, Local::now());
let (preset, target) = resolve_zone_target(zone, schedule, target_mode);
zone.active_preset = preset;
if !zone.device_manual_override {
zone.effective_setpoint = Some(target);
}
}
fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> {
schedules.iter()
.filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now))
@@ -1677,20 +1706,23 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
event.label = format!("{}: {}", zone.name, event.label);
house_events.push(event);
}
let effective_enabled = zone.enabled && (!blocked_by_group || zone.device_manual_override);
zones_out.push(ZoneControlPlan {
zone_id: zone.id.clone(),
zone_name: zone.name.clone(),
device_id: zone.device_id.clone(),
device_name: device.map(|item| item.name.clone()).unwrap_or_else(|| zone.device_id.clone()),
enabled: zone.enabled && (!blocked_by_group || zone.device_manual_override),
enabled: zone.enabled,
effective_enabled,
mode: effective_mode.to_string(),
configured_mode: zone.mode.clone(),
inherit_house_mode: zone.inherit_house_mode,
preset: if zone.active_preset.is_empty() { resolved_preset } else { zone.active_preset.clone() },
preset: resolved_preset,
preset_override: zone.manual_preset.clone(),
current_temperature: zone.current_temperature,
target_temperature: if zone.device_manual_override {
device.filter(|item| item.power).map(|item| item.target_temperature).or(Some(resolved_target))
} else if !zone.enabled || effective_mode == "off" {
target_temperature: 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.
Some(resolved_target)
} else {
zone.effective_setpoint.or(Some(resolved_target))
@@ -2231,6 +2263,20 @@ mod tests {
assert_eq!(target, 22.5);
}
#[test]
fn runtime_target_refresh_applies_manual_profile_immediately() {
let mut zone = test_zone("device");
zone.inherit_house_mode = false;
zone.mode = "cool".into();
zone.manual_preset = Some("sleep".into());
zone.active_preset = "comfort".into();
zone.effective_setpoint = Some(25.0);
refresh_zone_runtime_target(&mut zone, &[], "cool");
assert_eq!(zone.active_preset, "sleep");
assert_eq!(zone.effective_setpoint, Some(24.5));
assert_eq!(zone.effective_mode, "cool");
}
#[test]
fn legacy_zone_keeps_old_comfort_setpoint() {
let mut zone = test_zone("device");