This commit is contained in:
Mateusz Gruszczyński
2026-09-01 14:18:00 +02:00
parent 97c26c67b6
commit d0346dd797
33 changed files with 327 additions and 270 deletions
+51 -15
View File
@@ -11,7 +11,7 @@ pub(crate) fn clear_compressor_pending(zone: &mut Zone, clear_cancelled: bool) {
if clear_cancelled { zone.compressor_cancelled_action = None; }
}
fn queue_compressor_action(zone: &mut Zone, action: String, until: DateTime<Utc>, reason: &str) {
pub(crate) fn queue_compressor_action(zone: &mut Zone, action: String, until: DateTime<Utc>, reason: &str) {
let now = Utc::now();
if zone.compressor_pending_action.as_deref() != Some(action.as_str()) {
zone.compressor_pending_since = Some(now);
@@ -35,12 +35,11 @@ async fn control_zones(state: &AppState) -> Result<()> {
let schedules = state.db.list_schedules()?;
let settings = state.settings.read().await.clone();
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.
// Local/temporary thermostat ownership is independent from one-shot whole-house power
// commands. Expire/activate sessions on their own deadlines.
expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode, settings.house_power_enabled).await?;
activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode, true).await?;
// Outdoor temperature is deliberately optional. Prefer the configured Home
// Assistant entity, but keep the dashboard/assist useful by falling back to the
@@ -84,13 +83,6 @@ async fn control_zones(state: &AppState) -> Result<()> {
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
if !settings.house_power_enabled {
// Whole-house OFF is a one-shot action performed by the API endpoint. While the
// master remains off the regulator stays passive. A later physical/remote change
// is therefore detected as manual takeover and is not erased or forced OFF again.
return Ok(());
}
// Read all per-zone Home Assistant sensors concurrently. A down HA instance should cost
// one request timeout per cycle, not one timeout multiplied by the number of zones.
let room_sensor_reads = futures_util::future::join_all(zone_snapshot.iter().filter_map(|zone| {
@@ -146,11 +138,11 @@ async fn control_zones(state: &AppState) -> Result<()> {
// A zone explicitly switched to heat/cool remains independent and may still run.
// Local Quick Thermostat is an explicit per-zone request. If the inherited house
// climate mode is "off" (no automatic climate control), use the zone's last local
// heat/cool mode while local ownership is ON. The separate whole-house master power
// remains authoritative and is checked before this loop.
// heat/cool mode while local ownership is ON. Global ON/OFF is intentionally not a
// persistent gate: later thermostat/group/manual intent may act independently.
let effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
zone.effective_mode = effective_mode_owned.clone();
refresh_control_ownership(&mut zone, settings.house_power_enabled);
refresh_control_ownership(&mut zone, true);
let effective_mode = effective_mode_owned.as_str();
let previous_source = zone.control_temperature_source.clone();
@@ -197,6 +189,50 @@ async fn control_zones(state: &AppState) -> Result<()> {
zone.control_temperature_source = control_source;
zone.updated_at = Utc::now();
// A queued whole-house ON is a delayed one-shot physical action, not thermostat
// ownership. It must survive local/group/manual state while compressor protection is
// active, then execute once and hand control straight back to the existing owner.
if zone.compressor_pending_action.as_deref() == Some("global_power_on") {
let now = Utc::now();
if device.power {
clear_compressor_pending(&mut zone, true);
zone.updated_at = now;
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue;
}
let due = !settings.compressor_protection_enabled
|| zone.compressor_pending_until.map(|until| until <= now).unwrap_or(true);
if due {
let _device_guard = state.lock_device_operation(&zone.device_id).await;
match send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(true), ..Default::default() }).await {
Ok(updated_device) => {
if !device.power && updated_device.power { zone.last_power_change_at = Some(Utc::now()); }
clear_compressor_pending(&mut zone, true);
zone.last_action_at = Some(Utc::now());
state.log("info", "house.power_one_shot_executed", &format!("Executed queued global ON for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id
}));
}
Err(err) => {
// Keep the user-visible task and retry on a bounded deadline instead of
// spinning immediately or silently dropping a one-shot request.
zone.compressor_pending_until = Some(Utc::now() + chrono::Duration::seconds(10));
zone.lockout_until = zone.compressor_pending_until;
zone.lockout_reason = Some("global_start_retry".into());
state.log("error", "house.power_one_shot_error", &err.to_string(), json!({
"zone_id": zone.id, "device_id": zone.device_id
}));
}
}
}
zone.updated_at = Utc::now();
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;
}
// 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