v0.9.0
This commit is contained in:
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user