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
+2
View File
@@ -71,6 +71,8 @@ pub fn router(state: AppState) -> Router {
.route("/api/zones", get(list_zones).post(create_zone))
.route("/api/zones/:id", get(get_zone).put(update_zone).delete(delete_zone))
.route("/api/zones/:id/control", post(update_zone_control))
.route("/api/zones/:id/compressor-queue/cancel", post(cancel_zone_compressor_queue))
.route("/api/compressor-queue/cancel-all", post(cancel_all_compressor_queues))
.route("/api/zones/:id/schedule-template", post(apply_schedule_template))
.route("/api/groups", get(list_groups).post(create_group))
.route("/api/groups/:id", get(get_group).put(update_group).delete(delete_group))
+20
View File
@@ -1,6 +1,23 @@
#[derive(Debug, Deserialize)]
struct HouseControlPatch { mode: String }
async fn rearm_all_compressor_queues(state: &AppState) -> Result<(), AppError> {
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
zone_ids.sort();
zone_ids.dedup();
for zone_id in zone_ids {
let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; };
if zone.compressor_pending_action.is_none() && zone.compressor_cancelled_action.is_none()
&& zone.lockout_until.is_none() && zone.lockout_reason.is_none() { continue; }
engine::rearm_compressor_queue(&mut zone);
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
}
Ok(())
}
async fn clear_group_control_sources(state: &AppState, reason: &str) -> Result<(), AppError> {
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
@@ -105,6 +122,7 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
};
state.broadcast("settings.updated", payload.clone());
clear_group_control_sources(&state, "Whole-house mode control took ownership").await?;
rearm_all_compressor_queues(&state).await?;
// run_zone_control_now takes the same cycle lock, so release the mutation window first.
drop(cycle_guard);
if activate_all {
@@ -141,6 +159,7 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
// enabled for future group actions. A later house ON therefore does not silently turn
// disabled group control back on.
clear_group_control_sources(&state, if input.power { "Whole-house power control resumed automation" } else { "Whole-house power disabled" }).await?;
rearm_all_compressor_queues(&state).await?;
if !input.power {
let zone_snapshot = state.db.list_zones()?;
let mut zone_ids: Vec<String> = zone_snapshot.iter().map(|zone| zone.id.clone()).collect();
@@ -217,6 +236,7 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
};
state.broadcast("settings.updated", settings_payload.clone());
clear_group_control_sources(&state, "Whole-house preset control took ownership").await?;
rearm_all_compressor_queues(&state).await?;
let schedules = state.db.list_schedules()?;
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
+2
View File
@@ -14,6 +14,8 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
"history_compaction_enabled": settings.history_compaction_enabled,
"event_log_retention_days": settings.event_log_retention_days,
"suppress_device_beep": settings.suppress_device_beep,
"compressor_protection_enabled": settings.compressor_protection_enabled,
"compressor_protection_seconds": settings.compressor_protection_seconds,
"debug": settings.debug,
"night_mode": settings.night_mode,
"notifications": {
+31 -1
View File
@@ -37,6 +37,9 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
if !matches!(input.notifications.provider.as_str(), "pushover" | "slack" | "discord") { return Err(AppError::BadRequest("unsupported notification provider".into())); }
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650);
input.compressor_protection_seconds = input.compressor_protection_seconds.clamp(30, 1800);
let compressor_settings_changed = input.compressor_protection_enabled != old.compressor_protection_enabled
|| input.compressor_protection_seconds != old.compressor_protection_seconds;
normalize_sensor_aliases(&mut input);
canonicalize_home_assistant_entities(&mut input);
validate_night_mode(&mut input)?;
@@ -52,7 +55,29 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
canonicalize_saved_zone_entities(&state, &input).await?;
state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed);
*state.settings.write().await = input.clone();
state.log("info", "settings.updated", "Settings updated", json!({}));
if compressor_settings_changed {
// A changed protection policy invalidates old deadlines. Clear transient tasks under
// the same control-cycle exclusion; the next thermostat pass re-evaluates intent
// against the new enabled flag/duration.
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
zone_ids.sort();
zone_ids.dedup();
for zone_id in zone_ids {
let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; };
if zone.compressor_pending_action.is_none() && zone.compressor_cancelled_action.is_none()
&& zone.lockout_until.is_none() && zone.lockout_reason.is_none() { continue; }
engine::clear_compressor_pending(&mut zone, true);
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
}
}
state.log("info", "settings.updated", "Settings updated", json!({
"compressor_protection_enabled": input.compressor_protection_enabled,
"compressor_protection_seconds": input.compressor_protection_seconds
}));
state.broadcast("settings.updated", public_settings(&input));
state.wake_zone_control();
Ok(Json(public_settings(&input)))
@@ -299,6 +324,10 @@ fn sanitize_configuration_runtime(export: &mut ConfigurationExport) {
zone.last_mode_change_at = None;
zone.lockout_until = None;
zone.lockout_reason = None;
zone.compressor_pending_action = None;
zone.compressor_pending_since = None;
zone.compressor_pending_until = None;
zone.compressor_cancelled_action = None;
zone.effective_mode.clear();
zone.effective_setpoint = None;
zone.device_setpoint = None;
@@ -319,6 +348,7 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
validate_configuration_export(&export)?;
export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650);
export.settings.event_log_retention_days = export.settings.event_log_retention_days.clamp(1, 3650);
export.settings.compressor_protection_seconds = export.settings.compressor_protection_seconds.clamp(30, 1800);
normalize_sensor_aliases(&mut export.settings);
canonicalize_home_assistant_entities(&mut export.settings);
for zone in &mut export.zones { canonicalize_zone_ha_entity(zone, &export.settings); }
+75
View File
@@ -109,6 +109,7 @@ impl ZoneInput {
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: "zone created".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: String::new(), effective_setpoint: None, device_setpoint: None,
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None,
created_at, updated_at: Utc::now(),
@@ -208,6 +209,10 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
zone.last_mode_change_at = existing.last_mode_change_at;
zone.lockout_until = existing.lockout_until;
zone.lockout_reason = existing.lockout_reason;
zone.compressor_pending_action = existing.compressor_pending_action;
zone.compressor_pending_since = existing.compressor_pending_since;
zone.compressor_pending_until = existing.compressor_pending_until;
zone.compressor_cancelled_action = existing.compressor_cancelled_action;
zone.effective_mode = existing.effective_mode;
zone.effective_setpoint = existing.effective_setpoint;
zone.device_setpoint = existing.device_setpoint;
@@ -234,6 +239,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
}
engine::reset_local_thermostat_override(&mut zone);
engine::reset_device_manual_override(&mut zone);
engine::rearm_compressor_queue(&mut zone);
zone.enabled = false;
}
state.db.save_zone(&zone)?;
@@ -263,6 +269,13 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
let mut zone = state.db.get_zone(id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let was_enabled = zone.enabled;
let schedules = state.db.list_schedules()?;
let rearm_compressor = patch.power.is_some() || patch.setpoint.is_some() || patch.mode.is_some()
|| patch.enabled.is_some() || patch.preset.is_some() || patch.clear_override.unwrap_or(false)
|| patch.clear_device_manual_override.unwrap_or(false) || patch.clear_local_thermostat_override.unwrap_or(false)
|| patch.temporary_quick_thermostat.is_some() || patch.clear_temporary_quick_thermostat.unwrap_or(false);
if rearm_compressor {
engine::rearm_compressor_queue(&mut zone);
}
let resume_device_takeover = patch.clear_device_manual_override.unwrap_or(false);
let resume_local_thermostat = patch.clear_local_thermostat_override.unwrap_or(false);
let stop_temporary_quick_thermostat = patch.clear_temporary_quick_thermostat.unwrap_or(false);
@@ -692,3 +705,65 @@ async fn power_off_zone_device(state: &AppState, zone: &Zone, source: &str) {
}
}
async fn cancel_zone_compressor_queue(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, AppError> {
// Serialize cancellation with the thermostat cycle. If cancellation wins this lock, the
// pending action cannot expire and execute between the UI click and the persisted marker.
let _cycle_guard = state.lock_zone_control_cycle().await;
let _zone_guard = state.lock_zone_operation(&id).await;
let mut zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let cancelled = zone.compressor_pending_action.clone();
if let Some(action) = cancelled.clone() {
zone.compressor_cancelled_action = Some(action);
zone.compressor_pending_action = None;
zone.compressor_pending_since = None;
zone.compressor_pending_until = None;
zone.lockout_until = None;
zone.lockout_reason = None;
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
state.log("info", "zone.compressor_queue_cancelled", &format!("Cancelled compressor-protection task for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "action": cancelled.clone()
}));
}
if cancelled.is_some() { state.wake_zone_control(); }
Ok(Json(json!({"cancelled": cancelled.is_some(), "action": cancelled, "zone": zone})))
}
async fn cancel_all_compressor_queues(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
// Stop the thermostat arbiter while taking all zone locks, so a task cannot expire and
// execute between discovering it and persisting the cancellation marker.
let _cycle_guard = state.lock_zone_control_cycle().await;
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
zone_ids.sort();
zone_ids.dedup();
let mut guards = Vec::with_capacity(zone_ids.len());
for zone_id in &zone_ids { guards.push(state.lock_zone_operation(zone_id).await); }
let mut cancelled = 0usize;
let mut zones = Vec::new();
for zone_id in &zone_ids {
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
let Some(action) = zone.compressor_pending_action.clone() else { continue; };
zone.compressor_cancelled_action = Some(action.clone());
zone.compressor_pending_action = None;
zone.compressor_pending_since = None;
zone.compressor_pending_until = None;
zone.lockout_until = None;
zone.lockout_reason = None;
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
state.log("info", "zone.compressor_queue_cancelled", &format!("Cancelled compressor-protection task for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "action": action, "source": "cancel_all"
}));
cancelled += 1;
zones.push(zone);
}
if cancelled > 0 { state.wake_zone_control(); }
Ok(Json(json!({"cancelled": cancelled, "zones": zones})))
}
+4
View File
@@ -60,6 +60,8 @@ impl Config {
history_compaction_enabled: env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED").unwrap_or(true),
event_log_retention_days: env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650),
suppress_device_beep: env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP").unwrap_or(false),
compressor_protection_enabled: env_bool("GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED").unwrap_or(true),
compressor_protection_seconds: env_u64("GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS").unwrap_or(180).clamp(30, 1800),
influxdb: influx_settings_from_env(),
debug: DebugSettings {
overlay_enabled: env_bool("GREE_CONTROLLER_DEBUG_OVERLAY").unwrap_or(false),
@@ -98,6 +100,8 @@ impl Config {
settings.event_log_retention_days = env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").unwrap_or(settings.event_log_retention_days).clamp(1, 3650);
}
if let Some(value) = env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP") { settings.suppress_device_beep = value; }
if let Some(value) = env_bool("GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED") { settings.compressor_protection_enabled = value; }
if let Some(value) = env_u64("GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS") { settings.compressor_protection_seconds = value.clamp(30, 1800); }
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_OVERLAY") { settings.debug.overlay_enabled = value; }
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES") { settings.debug.gree_frames = value; }
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED") { settings.night_mode.enabled = value; }
+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,
+1
View File
@@ -9,6 +9,7 @@ fn default_external_sensor_weight() -> f64 { 0.4 }
fn default_max_sensor_difference() -> f64 { 3.0 }
fn default_control_temperature_source() -> String { "device".into() }
fn default_min_cycle() -> u64 { 180 }
fn default_compressor_protection_seconds() -> u64 { 180 }
fn default_sensor_stale_after() -> u64 { 300 }
fn default_cooldown() -> u64 { 300 }
fn default_house_mode() -> String { "cool".into() }
+7
View File
@@ -28,6 +28,13 @@ pub struct RuntimeSettings {
/// Add protocol-specific buzzer suppression fields to command frames.
#[serde(default)]
pub suppress_device_beep: bool,
/// Global thermostat compressor protection. When enabled, automatic starts and
/// Heat/Cool reversals are delayed instead of being sent during the protection window.
#[serde(default = "default_true")]
pub compressor_protection_enabled: bool,
/// Global compressor protection window in seconds. The UI exposes this as minutes.
#[serde(default = "default_compressor_protection_seconds")]
pub compressor_protection_seconds: u64,
#[serde(default)]
pub influxdb: InfluxDbSettings,
#[serde(default)]
+12
View File
@@ -142,6 +142,18 @@ pub struct Zone {
pub lockout_until: Option<DateTime<Utc>>,
#[serde(default)]
pub lockout_reason: Option<String>,
/// Human/API-visible compressor-protection task waiting for its safe execution window.
/// Values are stable identifiers such as `power_on:cool:23.0` or `mode_change:heat:21.0`.
#[serde(default)]
pub compressor_pending_action: Option<String>,
#[serde(default)]
pub compressor_pending_since: Option<DateTime<Utc>>,
#[serde(default)]
pub compressor_pending_until: Option<DateTime<Utc>>,
/// The last explicitly cancelled pending action. The same thermostat intent is suppressed
/// until a new control request changes/re-arms it; this prevents an immediate re-queue.
#[serde(default)]
pub compressor_cancelled_action: Option<String>,
#[serde(default)]
pub effective_mode: String,
#[serde(default)]