This commit is contained in:
Mateusz Gruszczyński
2026-09-01 13:05:44 +02:00
parent e496f911da
commit 7e0160834d
32 changed files with 486 additions and 67 deletions
+9 -1
View File
@@ -24,6 +24,9 @@ fn next_zone_control_deadline_delay(state: &AppState) -> Result<Option<Duration>
if local_thermostat_handback_is_active(zone) {
if let Some(at) = zone.local_thermostat_resume_at.clone() { deadlines.push(at); }
}
if zone.compressor_pending_action.is_some() {
if let Some(at) = zone.compressor_pending_until.clone().or(zone.lockout_until.clone()) { deadlines.push(at); }
}
if let Some(at) = temporary_quick_thermostat_wakeup_at(zone, now.clone()) { deadlines.push(at); }
if let Some(at) = next_schedule_boundary_utc(&zone.id, &schedules, local_now.clone()) { deadlines.push(at); }
}
@@ -31,8 +34,13 @@ fn next_zone_control_deadline_delay(state: &AppState) -> Result<Option<Duration>
if let Some(at) = next_time_automation_utc(automation, local_now.clone()) { deadlines.push(at); }
}
// Ignore already-expired deadlines here. The control cycle that just ran had the
// opportunity to consume them; if another prerequisite (offline sensor/device, manual
// ownership, etc.) prevents execution, the normal thermostat interval should retry
// instead of creating a zero-delay busy loop.
Ok(deadlines.into_iter()
.map(|at| (at - now.clone()).to_std().unwrap_or(Duration::ZERO))
.filter(|at| at > &now)
.filter_map(|at| (at - now.clone()).to_std().ok())
.min())
}
+3
View File
@@ -92,6 +92,9 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; };
let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await;
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
if patch.power.is_some() || climate_change {
rearm_compressor_queue(&mut zone);
}
let mut temporary_owns_zone = temporary_quick_thermostat_is_active(&zone, Utc::now());
// A user/HA group power click is an explicit scoped takeover. End any older direct
+3
View File
@@ -168,6 +168,9 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec<Str
}
zone.demand = false;
zone.demand_since = None;
// Direct/manual takeover supersedes any thermostat task that was waiting behind
// compressor protection; never leave a stale queued badge behind.
rearm_compressor_queue(zone);
zone.updated_at = now;
state.db.save_zone(zone)?;
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
+8
View File
@@ -91,6 +91,9 @@ pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule]
}
}
refresh_zone_runtime_target(zone, schedules, house_mode);
// The temporary session has ended and ownership/intent changes again. A cancellation
// that belonged to the temporary intent must not suppress the restored thermostat state.
rearm_compressor_queue(zone);
true
}
@@ -221,6 +224,11 @@ async fn activate_due_temporary_quick_thermostats(
continue;
}
// A scheduled temporary session becoming active is a new explicit thermostat intent.
// Replace any older cancelled/pending compressor task so this session can queue its
// own start or mode change against the current protection window.
rearm_compressor_queue(zone);
// Temporary ownership is independent from the ordinary local hand-back state.
zone.local_thermostat_power = Some(true);
zone.local_thermostat_resume_at = None;
+27 -1
View File
@@ -114,7 +114,7 @@ mod tests {
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, local_thermostat_restore_zone_enabled: 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,
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, compressor_pending_action: None, compressor_pending_since: None, compressor_pending_until: None, compressor_cancelled_action: 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(),
}
@@ -690,6 +690,32 @@ mod tests {
assert_eq!(gree_outdoor_temperature(&[a, b, c]), Some(12.0));
}
#[test]
fn compressor_queue_helpers_track_and_clear_pending_intent() {
let mut zone = test_zone("device");
let until = Utc::now() + chrono::Duration::seconds(180);
let action = compressor_action_id("power_on", "cool", 23.04);
assert_eq!(action, "power_on:cool:23.0");
queue_compressor_action(&mut zone, action.clone(), until.clone(), "minimum_off_before_start");
assert_eq!(zone.compressor_pending_action.as_deref(), Some(action.as_str()));
assert!(zone.compressor_pending_since.is_some());
assert_eq!(zone.compressor_pending_until, Some(until.clone()));
assert_eq!(zone.lockout_until, Some(until));
assert_eq!(zone.lockout_reason.as_deref(), Some("minimum_off_before_start"));
zone.compressor_cancelled_action = Some(action.clone());
clear_compressor_pending(&mut zone, false);
assert!(zone.compressor_pending_action.is_none());
assert_eq!(zone.compressor_cancelled_action.as_deref(), Some(action.as_str()));
rearm_compressor_queue(&mut zone);
assert!(zone.compressor_cancelled_action.is_none());
assert!(zone.lockout_until.is_none());
assert!(zone.lockout_reason.is_none());
}
#[test]
fn outdoor_assist_is_bounded_and_direction_neutral() {
let cool = outdoor_assist_offset("cool", Some(36.0), 27.0, 23.0);
+3
View File
@@ -109,6 +109,9 @@ async fn apply_automatic_device_action(
}
if domain_changed {
// A fresh automation climate command is a new explicit intent. Re-arm a queue that
// may have been cancelled for an older request so protection can defer this new one.
rearm_compressor_queue(&mut zone);
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
zone.control_source = "automation.device".into();
+92 -16
View File
@@ -1,3 +1,35 @@
fn compressor_action_id(kind: &str, mode: &str, target: f64) -> String {
format!("{kind}:{mode}:{target:.1}")
}
pub(crate) fn clear_compressor_pending(zone: &mut Zone, clear_cancelled: bool) {
zone.lockout_until = None;
zone.lockout_reason = None;
zone.compressor_pending_action = None;
zone.compressor_pending_since = None;
zone.compressor_pending_until = None;
if clear_cancelled { zone.compressor_cancelled_action = None; }
}
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);
}
zone.compressor_pending_action = Some(action);
zone.compressor_pending_until = Some(until.clone());
zone.lockout_until = Some(until);
zone.lockout_reason = Some(reason.to_string());
}
fn compressor_action_is_cancelled(zone: &Zone, action: &str) -> bool {
zone.compressor_cancelled_action.as_deref() == Some(action)
}
pub(crate) fn rearm_compressor_queue(zone: &mut Zone) {
clear_compressor_pending(zone, true);
}
async fn control_zones(state: &AppState) -> Result<()> {
let _cycle_guard = state.lock_zone_control_cycle().await;
let schedules = state.db.list_schedules()?;
@@ -173,6 +205,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) {
ensure_device_off_after_temporary_disabled_restore(state, &zone, &device).await;
}
clear_compressor_pending(&mut zone, true);
zone.demand = false;
zone.demand_since = None;
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
@@ -184,6 +217,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
// A technically disabled device is outside thermostat ownership. Do not create
// repeated command errors while keeping any available external sensor data visible.
if !device.enabled {
clear_compressor_pending(&mut zone, true);
zone.demand = false;
zone.demand_since = None;
zone.device_setpoint = None;
@@ -197,6 +231,9 @@ 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 {
// Direct/manual ownership and the thermostat compressor queue are mutually
// exclusive. Clean any stale persisted task before remaining passive.
clear_compressor_pending(&mut zone, true);
// 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
@@ -419,30 +456,44 @@ async fn control_zones(state: &AppState) -> Result<()> {
device.sleep,
);
// Compressor protection for automatic ownership. Direct/manual commands and global safety OFF
// deliberately bypass this path, while the thermostat never performs an immediate Heat<->Cool swap.
// Global compressor protection. Automatic thermostat/group/house requests are not
// discarded while the protection window is active: they become a visible pending
// task which can be cancelled from the thermostat UI. Safety OFF paths still bypass
// protection. A cancelled task is not silently re-created until a new control intent
// re-arms the queue (or a different mode/target produces a different task id).
let now = Utc::now();
if zone.lockout_until.map(|until| until <= now).unwrap_or(false) {
if !settings.compressor_protection_enabled {
clear_compressor_pending(&mut zone, true);
} else if zone.lockout_until.map(|until| until <= now).unwrap_or(false) {
zone.lockout_until = None;
zone.lockout_reason = None;
zone.compressor_pending_until = None;
}
if device.power && device.mode != effective_mode {
let min_on = chrono::Duration::seconds(zone.min_on_seconds as i64);
if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_on).unwrap_or(false) {
let until = zone.last_power_change_at.map(|at| at + min_on);
zone.lockout_until = until;
zone.lockout_reason = Some("minimum_on_before_mode_change".into());
let protection = chrono::Duration::seconds(settings.compressor_protection_seconds as i64);
if settings.compressor_protection_enabled && device.power && device.mode != effective_mode {
let action = compressor_action_id("mode_change", effective_mode, desired_device_target);
if compressor_action_is_cancelled(&zone, &action) {
clear_compressor_pending(&mut zone, false);
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;
}
if let Some(last_change) = zone.last_power_change_at {
if now.signed_duration_since(last_change) < protection {
queue_compressor_action(&mut zone, action, last_change + protection, "minimum_on_before_mode_change");
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;
}
}
match send_zone_command_if_owned(state, &zone.id, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await {
Ok(Some(_)) => {
zone.last_power_change_at = Some(now);
zone.lockout_until = Some(now + chrono::Duration::seconds(zone.min_off_seconds as i64));
zone.lockout_reason = Some("mode_change_off_delay".into());
state.log("info", "zone.mode_change_lockout", &format!("Zone {} switched off before {} mode", zone.name, effective_mode), json!({"zone_id": zone.id, "resume_at": zone.lockout_until}));
queue_compressor_action(&mut zone, action, now + protection, "mode_change_off_delay");
state.log("info", "zone.mode_change_lockout", &format!("Zone {} switched off before {} mode", zone.name, effective_mode), json!({"zone_id": zone.id, "resume_at": zone.lockout_until, "compressor_protection_enabled": settings.compressor_protection_enabled}));
}
Ok(None) => {}
Err(err) => state.log("error", "zone.mode_change_off_error", &err.to_string(), json!({"zone_id": zone.id})),
@@ -453,18 +504,42 @@ async fn control_zones(state: &AppState) -> Result<()> {
continue;
}
if !device.power {
let min_off = chrono::Duration::seconds(zone.min_off_seconds as i64);
if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_off).unwrap_or(false) {
zone.lockout_until = zone.last_power_change_at.map(|at| at + min_off);
zone.lockout_reason = Some("minimum_off_before_start".into());
let action = zone.compressor_pending_action.clone()
.filter(|value| value.starts_with("mode_change:"))
.unwrap_or_else(|| compressor_action_id("power_on", effective_mode, desired_device_target));
if compressor_action_is_cancelled(&zone, &action) {
clear_compressor_pending(&mut zone, false);
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;
}
if settings.compressor_protection_enabled {
if let Some(last_change) = zone.last_power_change_at {
if now.signed_duration_since(last_change) < protection {
queue_compressor_action(&mut zone, action, last_change + protection, "minimum_off_before_start");
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;
}
}
}
// The safe window is open. Remove the pending marker before attempting the
// command; a transport error will be retried by the normal thermostat cycle.
zone.compressor_pending_action = None;
zone.compressor_pending_since = None;
zone.compressor_pending_until = None;
zone.lockout_until = None;
zone.lockout_reason = None;
} else {
// Reaching the intended powered/mode state retires both pending and cancelled
// markers so a future independent request starts with a clean queue.
clear_compressor_pending(&mut zone, true);
}
let core_needs_command = !device.power
|| device.mode != effective_mode
|| (device.target_temperature - desired_device_target).abs() >= 0.5;
// In normal standby, Low fan is a transition hint rather than a state that should
// be reasserted forever. Some GREE firmwares accept the frame but later report Auto
@@ -496,6 +571,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
if device.mode != updated_device.mode { zone.last_mode_change_at = Some(transition_at); }
zone.device_setpoint = if updated_device.power { Some(updated_device.target_temperature) } else { None };
zone.last_action_at = Some(transition_at);
clear_compressor_pending(&mut zone, true);
state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({
"zone_id": zone.id,
"room_temperature": temp,