v0.8.3
This commit is contained in:
+211
-8
@@ -7,7 +7,7 @@ use crate::{
|
||||
error::AppError,
|
||||
home_assistant,
|
||||
influxdb,
|
||||
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
|
||||
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading},
|
||||
state::{AppState, PendingControllerCommand},
|
||||
};
|
||||
|
||||
@@ -48,10 +48,10 @@ pub fn start(state: AppState) {
|
||||
}
|
||||
let seconds = control_state.settings.read().await.zone_interval_seconds.max(2);
|
||||
let normal_delay = Duration::from_secs(seconds);
|
||||
let resume_delay = match next_local_thermostat_resume_delay(&control_state) {
|
||||
let resume_delay = match next_zone_control_deadline_delay(&control_state) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
tracing::warn!(error=?err, "cannot calculate local thermostat resume deadline");
|
||||
tracing::warn!(error=?err, "cannot calculate thermostat control deadline");
|
||||
None
|
||||
}
|
||||
};
|
||||
@@ -622,19 +622,123 @@ fn local_thermostat_handback_is_active(zone: &Zone) -> bool {
|
||||
}
|
||||
|
||||
pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool {
|
||||
let restore_zone_enabled = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.restore_zone_enabled);
|
||||
let changed = zone.local_thermostat_power.is_some()
|
||||
|| zone.local_thermostat_resume_at.is_some()
|
||||
|| zone.temporary_quick_thermostat.is_some()
|
||||
|| zone.manual_preset.is_some()
|
||||
|| zone.manual_setpoint.is_some()
|
||||
|| zone.manual_override_until.is_some();
|
||||
zone.local_thermostat_power = None;
|
||||
zone.local_thermostat_resume_at = None;
|
||||
zone.temporary_quick_thermostat = None;
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
if let Some(enabled) = restore_zone_enabled {
|
||||
zone.enabled = enabled;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn temporary_quick_thermostat_hard_deadline(session: &TemporaryQuickThermostat) -> Option<DateTime<Utc>> {
|
||||
match (session.expires_at.clone(), session.safety_expires_at.clone()) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(Some(a), None) => Some(a),
|
||||
(None, Some(b)) => Some(b),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn temporary_quick_thermostat_next_deadline(session: &TemporaryQuickThermostat) -> Option<DateTime<Utc>> {
|
||||
let hard = temporary_quick_thermostat_hard_deadline(session);
|
||||
let hold = if session.finish_kind == "temperature_stable" && session.hold_seconds > 0 {
|
||||
session.condition_started_at.clone().map(|started| started + chrono::Duration::seconds(session.hold_seconds as i64))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match (hard, hold) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(Some(a), None) => Some(a),
|
||||
(None, Some(b)) => Some(b),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result<Vec<String>, AppError> {
|
||||
let now = Utc::now();
|
||||
let mut restored_disabled_zones = Vec::new();
|
||||
for zone in zones.iter_mut() {
|
||||
let Some(session) = zone.temporary_quick_thermostat.as_ref() else { continue; };
|
||||
let Some(deadline) = temporary_quick_thermostat_hard_deadline(session) else { continue; };
|
||||
if deadline > now { continue; }
|
||||
let finish_kind = session.finish_kind.clone();
|
||||
let restores_disabled = session.restore_zone_enabled == Some(false);
|
||||
reset_local_thermostat_override(zone);
|
||||
refresh_zone_runtime_target(zone, schedules, house_mode);
|
||||
zone.updated_at = now.clone();
|
||||
state.db.save_zone(zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
||||
state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": "deadline"
|
||||
}));
|
||||
if restores_disabled { restored_disabled_zones.push(zone.id.clone()); }
|
||||
}
|
||||
Ok(restored_disabled_zones)
|
||||
}
|
||||
|
||||
async fn ensure_device_off_after_temporary_disabled_restore(state: &AppState, zone: &Zone, device: &Device) {
|
||||
if zone.enabled || !device.enabled || !device.online || device.communication_failures > 0 || !device.power { return; }
|
||||
let _device_guard = state.lock_device_operation(&zone.device_id).await;
|
||||
let should_stop = state.db.get_zone(&zone.id).ok().flatten()
|
||||
.map(|latest| !latest.enabled && latest.temporary_quick_thermostat.is_none() && latest.local_thermostat_power.is_none())
|
||||
.unwrap_or(false);
|
||||
if !should_stop { return; }
|
||||
if let Err(err) = send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await {
|
||||
state.log("error", "zone.temporary_quick_thermostat_poweroff_error", &err.to_string(), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn temporary_temperature_condition_met(zone: &Zone, session: &TemporaryQuickThermostat) -> bool {
|
||||
let (Some(current), Some(target)) = (zone.current_temperature, session.temperature_target) else { return false; };
|
||||
let tolerance = session.tolerance_c.max(0.0);
|
||||
match session.temperature_operator.as_deref().unwrap_or("within") {
|
||||
"at_or_below" => current <= target + tolerance,
|
||||
"at_or_above" => current >= target - tolerance,
|
||||
_ => (current - target).abs() <= tolerance,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a temperature-based temporary session from the freshly selected room sensor.
|
||||
/// Returns a completion reason when ownership should be handed back immediately.
|
||||
fn evaluate_temporary_quick_thermostat_condition(zone: &mut Zone, now: DateTime<Utc>) -> Option<String> {
|
||||
let met = zone.temporary_quick_thermostat.as_ref()
|
||||
.filter(|session| matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable"))
|
||||
.map(|session| temporary_temperature_condition_met(zone, session))?;
|
||||
|
||||
let session = zone.temporary_quick_thermostat.as_mut()?;
|
||||
match session.finish_kind.as_str() {
|
||||
"temperature_reached" => {
|
||||
if met { return Some("temperature_reached".into()); }
|
||||
session.condition_started_at = None;
|
||||
}
|
||||
"temperature_stable" => {
|
||||
if !met {
|
||||
session.condition_started_at = None;
|
||||
return None;
|
||||
}
|
||||
let started = session.condition_started_at.get_or_insert(now.clone()).clone();
|
||||
if session.hold_seconds == 0 || now.signed_duration_since(started).num_seconds() >= session.hold_seconds as i64 {
|
||||
return Some("temperature_stable".into());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result<(), AppError> {
|
||||
let now = Utc::now();
|
||||
for zone in zones.iter_mut() {
|
||||
@@ -669,11 +773,19 @@ fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], sched
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_local_thermostat_resume_delay(state: &AppState) -> Result<Option<Duration>, AppError> {
|
||||
fn next_zone_control_deadline_delay(state: &AppState) -> Result<Option<Duration>, AppError> {
|
||||
let now = Utc::now();
|
||||
Ok(state.db.list_zones()?.into_iter()
|
||||
.filter(local_thermostat_handback_is_active)
|
||||
.filter_map(|zone| zone.local_thermostat_resume_at)
|
||||
.filter_map(|zone| {
|
||||
let local = if local_thermostat_handback_is_active(&zone) { zone.local_thermostat_resume_at.clone() } else { None };
|
||||
let temporary = zone.temporary_quick_thermostat.as_ref().and_then(temporary_quick_thermostat_next_deadline);
|
||||
match (local, temporary) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(Some(a), None) => Some(a),
|
||||
(None, Some(b)) => Some(b),
|
||||
(None, None) => None,
|
||||
}
|
||||
})
|
||||
.map(|at| (at - now.clone()).to_std().unwrap_or(Duration::ZERO))
|
||||
.min())
|
||||
}
|
||||
@@ -693,7 +805,17 @@ pub fn refresh_control_ownership(zone: &mut Zone, house_power_enabled: bool, blo
|
||||
"home_assistant_thermostat" | "web_thermostat" => zone.control_source.clone(),
|
||||
_ => "local_thermostat".into(),
|
||||
};
|
||||
("local_thermostat", source, zone.local_thermostat_resume_at, if zone.local_thermostat_power == Some(false) { "Local thermostat is explicitly off".into() } else { "Local thermostat owns the zone".into() })
|
||||
let resume_at = zone.temporary_quick_thermostat.as_ref()
|
||||
.and_then(temporary_quick_thermostat_next_deadline)
|
||||
.or(zone.local_thermostat_resume_at.clone());
|
||||
let reason = if zone.local_thermostat_power == Some(false) {
|
||||
"Local thermostat is explicitly off".into()
|
||||
} else if zone.temporary_quick_thermostat.is_some() {
|
||||
"Temporary Quick Thermostat owns the zone".into()
|
||||
} else {
|
||||
"Local thermostat owns the zone".into()
|
||||
};
|
||||
("local_thermostat", source, resume_at, reason)
|
||||
} else if blocked_by_group {
|
||||
("automation", "group".to_string(), None, "Zone is blocked by a disabled group".to_string())
|
||||
} else {
|
||||
@@ -1146,6 +1268,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
// 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.
|
||||
expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode)?;
|
||||
let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode)?;
|
||||
|
||||
// Outdoor temperature is deliberately optional. Prefer the configured Home
|
||||
// Assistant entity, but keep the dashboard/assist useful by falling back to the
|
||||
@@ -1286,11 +1409,32 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
zone.control_temperature_source = control_source;
|
||||
zone.updated_at = Utc::now();
|
||||
|
||||
let condition_now = zone.updated_at.clone();
|
||||
if let Some(reason) = evaluate_temporary_quick_thermostat_condition(&mut zone, condition_now) {
|
||||
let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default();
|
||||
reset_local_thermostat_override(&mut zone);
|
||||
refresh_zone_runtime_target(&mut zone, &schedules, &settings.house_mode);
|
||||
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)?);
|
||||
if persisted_zone.temporary_quick_thermostat.is_none() {
|
||||
ensure_device_off_after_temporary_disabled_restore(state, &persisted_zone, &device).await;
|
||||
state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": reason
|
||||
}));
|
||||
}
|
||||
state.wake_zone_control();
|
||||
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
|
||||
// therefore remain active until the zone is explicitly enabled again.
|
||||
if !zone.enabled {
|
||||
if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) {
|
||||
ensure_device_off_after_temporary_disabled_restore(state, &zone, &device).await;
|
||||
}
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
@@ -2523,7 +2667,7 @@ mod tests {
|
||||
sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()),
|
||||
external_sensor_weight: 0.4, max_sensor_difference: 3.0, sensor_stale_after_seconds: 300, device_temperature: None, external_temperature: None,
|
||||
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
|
||||
manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None,
|
||||
manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, temporary_quick_thermostat: None,
|
||||
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None,
|
||||
revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "test".into(), last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None,
|
||||
effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
|
||||
@@ -2603,6 +2747,65 @@ mod tests {
|
||||
assert_eq!(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES, 15);
|
||||
}
|
||||
|
||||
fn temporary_session(now: DateTime<Utc>) -> TemporaryQuickThermostat {
|
||||
TemporaryQuickThermostat {
|
||||
finish_kind: "temperature_stable".into(),
|
||||
started_at: now,
|
||||
restore_zone_enabled: Some(true),
|
||||
expires_at: None,
|
||||
temperature_target: Some(23.0),
|
||||
temperature_operator: Some("within".into()),
|
||||
tolerance_c: 0.3,
|
||||
hold_seconds: 3600,
|
||||
condition_started_at: None,
|
||||
safety_expires_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_quick_thermostat_restores_previous_zone_enabled_state() {
|
||||
let now = Utc::now();
|
||||
let mut zone = test_zone("device");
|
||||
zone.enabled = true;
|
||||
zone.local_thermostat_power = Some(true);
|
||||
let mut session = temporary_session(now);
|
||||
session.restore_zone_enabled = Some(false);
|
||||
zone.temporary_quick_thermostat = Some(session);
|
||||
zone.manual_setpoint = Some(23.0);
|
||||
|
||||
assert!(reset_local_thermostat_override(&mut zone));
|
||||
assert!(!zone.enabled);
|
||||
assert!(zone.local_thermostat_power.is_none());
|
||||
assert!(zone.temporary_quick_thermostat.is_none());
|
||||
assert!(zone.manual_setpoint.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_stable_condition_requires_continuous_hold_time() {
|
||||
let now = Utc::now();
|
||||
let mut zone = test_zone("device");
|
||||
zone.current_temperature = Some(23.2);
|
||||
let mut session = temporary_session(now.clone());
|
||||
session.condition_started_at = Some(now.clone() - chrono::Duration::seconds(3599));
|
||||
zone.temporary_quick_thermostat = Some(session);
|
||||
|
||||
assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now.clone()).is_none());
|
||||
assert_eq!(evaluate_temporary_quick_thermostat_condition(&mut zone, now + chrono::Duration::seconds(2)), Some("temperature_stable".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_stable_condition_resets_when_temperature_leaves_range() {
|
||||
let now = Utc::now();
|
||||
let mut zone = test_zone("device");
|
||||
zone.current_temperature = Some(24.0);
|
||||
let mut session = temporary_session(now.clone());
|
||||
session.condition_started_at = Some(now.clone() - chrono::Duration::minutes(30));
|
||||
zone.temporary_quick_thermostat = Some(session);
|
||||
|
||||
assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now).is_none());
|
||||
assert!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_thermostat_off_restarts_backend_handback_deadline() {
|
||||
let mut zone = test_zone("device");
|
||||
|
||||
Reference in New Issue
Block a user