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