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