v0.7.12
This commit is contained in:
+15
-9
@@ -529,7 +529,7 @@ impl ZoneInput {
|
||||
sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()),
|
||||
external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference,
|
||||
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,
|
||||
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: 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,
|
||||
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
|
||||
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None,
|
||||
@@ -577,6 +577,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
||||
zone.manual_setpoint = existing.manual_setpoint;
|
||||
zone.manual_override_until = existing.manual_override_until;
|
||||
zone.local_thermostat_power = existing.local_thermostat_power;
|
||||
zone.local_thermostat_resume_at = existing.local_thermostat_resume_at;
|
||||
zone.device_manual_override = existing.device_manual_override;
|
||||
zone.device_manual_override_since = existing.device_manual_override_since;
|
||||
zone.device_manual_override_until = existing.device_manual_override_until;
|
||||
@@ -618,16 +619,19 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|
||||
|| patch.preset.is_some() || patch.enabled.is_some();
|
||||
|
||||
if resume_local_thermostat {
|
||||
zone.local_thermostat_power = None;
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
engine::reset_local_thermostat_override(&mut zone);
|
||||
}
|
||||
if let Some(power) = patch.power {
|
||||
zone.local_thermostat_power = Some(power);
|
||||
zone.local_thermostat_resume_at = if power {
|
||||
None
|
||||
} else {
|
||||
Some(Utc::now() + chrono::Duration::minutes(engine::LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES))
|
||||
};
|
||||
if power { zone.enabled = true; }
|
||||
// A manually started local thermostat keeps an already selected target/profile until
|
||||
// the user chooses Auto or resumes global automation.
|
||||
// it is switched off and the delayed hand-back completes, Auto is selected, or the
|
||||
// user explicitly resumes automation.
|
||||
if power && (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) {
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
@@ -680,7 +684,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|
||||
}
|
||||
if let Some(value) = patch.enabled {
|
||||
zone.enabled = value;
|
||||
if !value { zone.local_thermostat_power = None; }
|
||||
if !value { engine::reset_local_thermostat_override(&mut zone); }
|
||||
}
|
||||
let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false };
|
||||
let house_mode = state.settings.read().await.house_mode.clone();
|
||||
@@ -709,6 +713,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|
||||
"inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset,
|
||||
"override_until": zone.manual_override_until, "enabled": zone.enabled,
|
||||
"local_thermostat_power": zone.local_thermostat_power,
|
||||
"local_thermostat_resume_at": zone.local_thermostat_resume_at,
|
||||
"device_manual_override_cleared": device_override_cleared
|
||||
}));
|
||||
Ok(zone)
|
||||
@@ -955,6 +960,7 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
|
||||
"control_source": zone.control_source,
|
||||
"current_schedule": zone.current_schedule_name,
|
||||
"local_thermostat_power": zone.local_thermostat_power,
|
||||
"local_thermostat_resume_at": zone.local_thermostat_resume_at,
|
||||
"device_manual_override": zone.device_manual_override,
|
||||
"device_manual_override_until": zone.device_manual_override_until,
|
||||
})).collect::<Vec<_>>();
|
||||
@@ -1009,8 +1015,8 @@ fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> {
|
||||
fn clear_all_local_thermostat_overrides(state: &AppState) -> Result<usize, AppError> {
|
||||
let mut cleared = 0;
|
||||
for mut zone in state.db.list_zones()? {
|
||||
if zone.local_thermostat_power.is_none() { continue; }
|
||||
zone.local_thermostat_power = None;
|
||||
if zone.local_thermostat_power.is_none() && zone.local_thermostat_resume_at.is_none() { continue; }
|
||||
engine::reset_local_thermostat_override(&mut zone);
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
|
||||
+93
-3
@@ -47,8 +47,17 @@ pub fn start(state: AppState) {
|
||||
tracing::error!(error=?err, "automation cycle failed");
|
||||
}
|
||||
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) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
tracing::warn!(error=?err, "cannot calculate local thermostat resume deadline");
|
||||
None
|
||||
}
|
||||
};
|
||||
let sleep_for = resume_delay.map(|delay| delay.min(normal_delay)).unwrap_or(normal_delay);
|
||||
tokio::select! {
|
||||
_ = sleep(Duration::from_secs(seconds)) => {},
|
||||
_ = sleep(sleep_for) => {},
|
||||
_ = control_state.zone_control_wakeup.notified() => {},
|
||||
}
|
||||
}
|
||||
@@ -561,6 +570,62 @@ fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zon
|
||||
fields
|
||||
}
|
||||
|
||||
|
||||
pub const LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES: i64 = 15;
|
||||
|
||||
pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool {
|
||||
let changed = zone.local_thermostat_power.is_some()
|
||||
|| zone.local_thermostat_resume_at.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.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
changed
|
||||
}
|
||||
|
||||
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() {
|
||||
if zone.local_thermostat_power == Some(false) && zone.local_thermostat_resume_at.is_none() {
|
||||
// Upgrade safety for a persisted 0.7.10/0.7.11 local-OFF state: old releases
|
||||
// had no hand-back deadline, so start one from the first cycle after upgrade.
|
||||
zone.local_thermostat_resume_at = Some(now.clone() + chrono::Duration::minutes(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES));
|
||||
zone.updated_at = now.clone();
|
||||
state.db.save_zone(zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
||||
state.log("info", "zone.local_thermostat_resume_scheduled", &format!("Local thermostat hand-back scheduled for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "delay_minutes": LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
let expired = zone.local_thermostat_power == Some(false)
|
||||
&& zone.local_thermostat_resume_at.as_ref().map(|at| at <= &now).unwrap_or(false);
|
||||
if !expired { continue; }
|
||||
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.local_thermostat_resumed", &format!("Local thermostat hand-back completed for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "delay_minutes": LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_local_thermostat_resume_delay(state: &AppState) -> Result<Option<Duration>, AppError> {
|
||||
let now = Utc::now();
|
||||
Ok(state.db.list_zones()?.into_iter()
|
||||
.filter(|zone| zone.local_thermostat_power == Some(false))
|
||||
.filter_map(|zone| zone.local_thermostat_resume_at)
|
||||
.map(|at| (at - now.clone()).to_std().unwrap_or(Duration::ZERO))
|
||||
.min())
|
||||
}
|
||||
|
||||
pub fn reset_device_manual_override(zone: &mut Zone) -> bool {
|
||||
let changed = zone.device_manual_override
|
||||
|| zone.device_manual_override_since.is_some()
|
||||
@@ -961,7 +1026,11 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let groups = state.db.list_groups()?;
|
||||
let settings = state.settings.read().await.clone();
|
||||
let zone_snapshot = state.db.list_zones()?;
|
||||
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.
|
||||
expire_local_thermostat_overrides(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
|
||||
@@ -1857,6 +1926,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
control_source: zone.control_temperature_source.clone(),
|
||||
manual_override_until: zone.manual_override_until,
|
||||
local_thermostat_power: zone.local_thermostat_power,
|
||||
local_thermostat_resume_at: zone.local_thermostat_resume_at,
|
||||
device_manual_override: zone.device_manual_override,
|
||||
device_manual_override_until: zone.device_manual_override_until,
|
||||
current_schedule_id: active.map(|item| item.id.clone()),
|
||||
@@ -2258,7 +2328,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, 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,
|
||||
manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: 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,
|
||||
effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
|
||||
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
|
||||
@@ -2317,6 +2387,26 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_thermostat_resume_clears_only_local_quick_control_state() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.local_thermostat_power = Some(false);
|
||||
zone.local_thermostat_resume_at = Some(Utc::now() + chrono::Duration::minutes(15));
|
||||
zone.manual_preset = Some("comfort".into());
|
||||
zone.manual_setpoint = Some(23.0);
|
||||
zone.manual_override_until = Some(Utc::now() + chrono::Duration::hours(1));
|
||||
zone.device_manual_override = true;
|
||||
|
||||
assert!(reset_local_thermostat_override(&mut zone));
|
||||
assert!(zone.local_thermostat_power.is_none());
|
||||
assert!(zone.local_thermostat_resume_at.is_none());
|
||||
assert!(zone.manual_preset.is_none());
|
||||
assert!(zone.manual_setpoint.is_none());
|
||||
assert!(zone.manual_override_until.is_none());
|
||||
assert!(zone.device_manual_override);
|
||||
assert_eq!(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_gree_setpoint_does_not_create_manual_override() {
|
||||
let zone = test_zone("device");
|
||||
|
||||
@@ -349,6 +349,11 @@ pub struct Zone {
|
||||
/// Some(true) runs this zone locally through the full thermostat; Some(false) keeps it locally off.
|
||||
#[serde(default)]
|
||||
pub local_thermostat_power: Option<bool>,
|
||||
/// Automatic hand-back deadline after the local quick thermostat is switched OFF.
|
||||
/// When reached, local ownership and quick profile/setpoint overrides are cleared and
|
||||
/// the current group/schedule state is evaluated again.
|
||||
#[serde(default)]
|
||||
pub local_thermostat_resume_at: Option<DateTime<Utc>>,
|
||||
/// True when the physical unit was changed outside the thermostat engine (for example by IR remote).
|
||||
/// While active, normal zone/group/schedule automation observes the unit but does not overwrite it.
|
||||
#[serde(default)]
|
||||
@@ -738,6 +743,7 @@ pub struct ZoneControlPlan {
|
||||
pub control_source: String,
|
||||
pub manual_override_until: Option<DateTime<Utc>>,
|
||||
pub local_thermostat_power: Option<bool>,
|
||||
pub local_thermostat_resume_at: Option<DateTime<Utc>>,
|
||||
pub device_manual_override: bool,
|
||||
pub device_manual_override_until: Option<DateTime<Utc>>,
|
||||
pub current_schedule_id: Option<String>,
|
||||
|
||||
Reference in New Issue
Block a user