v0.9.4
This commit is contained in:
+34
-29
@@ -1,24 +1,6 @@
|
||||
#[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 rearm_house_automation_compressor_queues(state: &AppState) -> Result<(), AppError> {
|
||||
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
|
||||
@@ -42,12 +24,31 @@ async fn rearm_house_automation_compressor_queues(state: &AppState) -> Result<()
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn set_all_thermostat_power_state(state: &AppState, power: bool) -> Result<usize, AppError> {
|
||||
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 changed = 0usize;
|
||||
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; };
|
||||
engine::rearm_compressor_queue(&mut zone);
|
||||
if engine::set_house_bulk_thermostat_power(&mut zone, power) { changed += 1; }
|
||||
engine::refresh_control_ownership(&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)?);
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result<Vec<Value>, AppError> {
|
||||
let mut failed = Vec::new();
|
||||
for device in state.db.list_devices()? {
|
||||
if !device.enabled { continue; }
|
||||
// Global ON/OFF is deliberately a one-shot physical command. It does not mutate
|
||||
// thermostat/group/manual ownership, so those controllers may make a later decision.
|
||||
// The per-zone thermostat power state is persisted before these physical commands.
|
||||
// OFF is immediate; ON still respects compressor protection.
|
||||
let result = if power {
|
||||
engine::one_shot_house_power_on_device(state, &device.id).await
|
||||
} else {
|
||||
@@ -112,9 +113,10 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
|
||||
// v0.9.3: this endpoint is a one-shot physical action, not a persistent automation gate.
|
||||
// Preserve every zone/group/manual owner exactly as-is. A later thermostat, group, rule or
|
||||
// manual action is therefore free to issue its own command independently.
|
||||
// Global power is a bulk thermostat action, not a persistent master gate. OFF stores every
|
||||
// thermostat as an indefinite local OFF so the next regulator cycle cannot immediately
|
||||
// resurrect demand. ON releases that OFF state. Later explicit local/group/manual actions
|
||||
// remain independent and can re-enable only the selected scope.
|
||||
{
|
||||
let mut settings = state.settings.write().await;
|
||||
if !settings.house_power_enabled {
|
||||
@@ -124,10 +126,11 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
|
||||
}
|
||||
}
|
||||
|
||||
// Drop stale protection tasks from the intent that existed before this global physical
|
||||
// command. They may be recreated by a later thermostat cycle if demand still exists.
|
||||
rearm_all_compressor_queues(&state).await?;
|
||||
let failed = command_all_enabled_devices_power(&state, input.power, "house_power_one_shot").await?;
|
||||
// Persist the thermostat power intent before touching devices. The cycle lock held by this
|
||||
// handler guarantees that no setpoint-modulation cycle can race between the marker and OFF.
|
||||
let changed_zones = set_all_thermostat_power_state(&state, input.power).await?;
|
||||
let failed = command_all_enabled_devices_power(&state, input.power, "house_power_bulk").await?;
|
||||
state.wake_zone_control();
|
||||
|
||||
let devices = state.db.list_devices()?;
|
||||
let groups = state.db.list_groups()?;
|
||||
@@ -135,13 +138,15 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
|
||||
let settings_payload = public_settings(&settings);
|
||||
drop(settings);
|
||||
state.log("info", "house.power_all", if input.power {
|
||||
"One-shot whole-house ON sent; thermostat/group/manual ownership preserved"
|
||||
"Whole-house ON sent; local OFF state released for all thermostats"
|
||||
} else {
|
||||
"One-shot whole-house OFF sent; thermostat/group/manual ownership preserved"
|
||||
"Whole-house OFF sent; all thermostats left locally OFF until explicitly re-enabled"
|
||||
}, json!({
|
||||
"power": input.power,
|
||||
"failed": failed.len(),
|
||||
"changed_zones": changed_zones,
|
||||
"one_shot": true,
|
||||
"persistent_global_gate": false,
|
||||
}));
|
||||
Ok(Json(json!({
|
||||
"power": input.power,
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
|
||||
"house_mode must be changed through the House Control API".into(),
|
||||
));
|
||||
}
|
||||
// Compatibility-only field: global ON/OFF is one-shot and never disables automation.
|
||||
// Compatibility-only field: global ON/OFF no longer uses a persistent master automation gate.
|
||||
input.house_power_enabled = true;
|
||||
input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600);
|
||||
input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600);
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
pub async fn send_command(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
send_command_locked(state, device_id, command).await
|
||||
}
|
||||
|
||||
async fn send_command_locked(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
||||
send_command_locked_inner(state, device_id, command, true, true).await
|
||||
}
|
||||
|
||||
@@ -70,8 +70,13 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
// releases group ownership: members are represented as individually OFF, not as
|
||||
// "blocked by a disabled group". ON clears that scoped OFF and lets group arbitration
|
||||
// take ownership again. The whole-house master remains independent.
|
||||
let explicit_group_power = patch.power.is_some() && source != "automation.group";
|
||||
let manual_group_control = source != "automation.group";
|
||||
let explicit_group_power = patch.power.is_some() && manual_group_control;
|
||||
// Applying a manual mode/profile/custom target to an already-enabled group is itself an
|
||||
// explicit scoped takeover. It must wake members that were left locally OFF by a previous
|
||||
// group/whole-house OFF; otherwise the UI says "group control" while every unit stays OFF.
|
||||
let manual_group_activation = manual_group_control && group.power_enabled && climate_change;
|
||||
let explicit_group_takeover = explicit_group_power || manual_group_activation;
|
||||
|
||||
let mut zones = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
@@ -89,7 +94,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
// or temporary ownership so all members react consistently to the bulk command.
|
||||
// Scheduled automations deliberately do not do this: manual/local ownership keeps
|
||||
// its higher priority and the automation can only affect currently free members.
|
||||
if explicit_group_power {
|
||||
if explicit_group_takeover {
|
||||
if zone.temporary_quick_thermostat.is_some() {
|
||||
if temporary_owns_zone {
|
||||
finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode);
|
||||
@@ -161,7 +166,8 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
// Power ON clears this scoped OFF and returns the member to group arbitration.
|
||||
let mut force_power_off_after_save = false;
|
||||
let mut applied_group_power: Option<bool> = None;
|
||||
if let Some(power) = patch.power {
|
||||
let requested_member_power = patch.power.or(manual_group_activation.then_some(true));
|
||||
if let Some(power) = requested_member_power {
|
||||
let automatic_power_blocked = source == "automation.group"
|
||||
&& (zone.device_manual_override || zone.local_thermostat_power.is_some() || temporary_owns_zone);
|
||||
if automatic_power_blocked {
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
pub const LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES: i64 = 15;
|
||||
|
||||
/// Apply the whole-house bulk power state to a thermostat without creating a persistent
|
||||
/// global gate. OFF leaves the zone explicitly/indefinitely off; ON releases that local OFF
|
||||
/// so the zone can immediately return to its group/manual/automatic controller.
|
||||
pub fn set_house_bulk_thermostat_power(zone: &mut Zone, power: bool) -> bool {
|
||||
if power {
|
||||
if zone.local_thermostat_power != Some(false) { return false; }
|
||||
zone.local_thermostat_power = None;
|
||||
zone.local_thermostat_resume_at = None;
|
||||
zone.local_thermostat_restore_zone_enabled = None;
|
||||
true
|
||||
} else {
|
||||
let changed = zone.local_thermostat_power != Some(false)
|
||||
|| zone.local_thermostat_resume_at.is_some()
|
||||
|| zone.local_thermostat_restore_zone_enabled.is_some()
|
||||
|| zone.demand
|
||||
|| zone.demand_since.is_some();
|
||||
zone.local_thermostat_power = Some(false);
|
||||
zone.local_thermostat_resume_at = None;
|
||||
zone.local_thermostat_restore_zone_enabled = None;
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.effective_mode = "off".into();
|
||||
zone.device_setpoint = None;
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply local quick-thermostat power ownership and keep the automatic hand-back
|
||||
/// deadline in one backend-owned place. Every fresh OFF action receives a fresh
|
||||
/// deadline; ON cancels any pending hand-back.
|
||||
|
||||
+5
-21
@@ -155,7 +155,7 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec<Str
|
||||
zone.control_source = normalized_direct_source(source).into();
|
||||
zone.control_reason = "Direct/manual device control has priority".into();
|
||||
// Direct/pilot control is an explicit ownership takeover, not a temporary preset.
|
||||
// Keep it manual until the user explicitly chooses Resume automation. A one-shot global
|
||||
// Keep it manual until the user explicitly chooses Resume automation. A global
|
||||
// ON/OFF command changes physical power only and must never erase manual ownership.
|
||||
// A schedule boundary must never silently take the unit back from the person controlling it.
|
||||
zone.device_manual_override_until = None;
|
||||
@@ -262,9 +262,8 @@ pub async fn send_manual_command(state: &AppState, device_id: &str, command: Dev
|
||||
}
|
||||
|
||||
pub async fn force_house_power_off_device(state: &AppState, device_id: &str, _source: &str) -> Result<Device, AppError> {
|
||||
// Whole-house OFF is a one-shot physical action. Preserve every zone/manual/group owner,
|
||||
// while still taking zone -> device locks so a concurrent local/manual action cannot race
|
||||
// the forced OFF frame. Controllers are free to make a later independent decision.
|
||||
// Whole-house OFF physically forces the unit down after the API has persisted per-zone local OFF.
|
||||
// Keep zone -> device ordering so a concurrent local/manual action cannot race the frame.
|
||||
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter()
|
||||
.filter(|zone| zone.device_id == device_id)
|
||||
.map(|zone| zone.id)
|
||||
@@ -280,7 +279,7 @@ pub async fn force_house_power_off_device(state: &AppState, device_id: &str, _so
|
||||
}
|
||||
|
||||
pub async fn one_shot_house_power_on_device(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
||||
// Global ON is also one-shot and must not take thermostat ownership. For thermostat-managed
|
||||
// Global ON releases per-zone OFF state in the API and must not create a local-ON ownership marker. For thermostat-managed
|
||||
// units it still respects compressor protection; a protected start is stored as a visible
|
||||
// queue item and executed at the protection deadline unless a newer intent cancels/replaces it.
|
||||
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter()
|
||||
@@ -330,7 +329,7 @@ pub async fn one_shot_house_power_on_device(state: &AppState, device_id: &str) -
|
||||
}
|
||||
}
|
||||
|
||||
// The device lock is already held. This is physical one-shot power only: no manual marker.
|
||||
// The device lock is already held. This is physical global power only: no manual marker.
|
||||
send_command_locked(state, device_id, DeviceCommand { power: Some(true), ..Default::default() }).await
|
||||
}
|
||||
|
||||
@@ -365,18 +364,3 @@ pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result<
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
pub fn clear_all_device_manual_overrides(state: &AppState, source: &str) -> Result<usize, AppError> {
|
||||
let mut cleared = 0usize;
|
||||
for mut zone in state.db.list_zones()? {
|
||||
if !reset_device_manual_override(&mut zone) { continue; }
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
state.log("info", "zone.device_manual_override_cleared", &format!("Automation resumed for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "source": source
|
||||
}));
|
||||
cleared += 1;
|
||||
}
|
||||
Ok(cleared)
|
||||
}
|
||||
|
||||
|
||||
@@ -780,4 +780,34 @@ mod tests {
|
||||
assert_eq!(outdoor_assist_offset("cool", None, 27.0, 23.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_house_off_is_indefinite_until_explicit_reenable() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.local_thermostat_power = Some(true);
|
||||
zone.local_thermostat_resume_at = Some(Utc::now() + chrono::Duration::minutes(15));
|
||||
zone.demand = true;
|
||||
zone.demand_since = Some(Utc::now());
|
||||
|
||||
assert!(set_house_bulk_thermostat_power(&mut zone, false));
|
||||
assert_eq!(zone.local_thermostat_power, Some(false));
|
||||
assert!(zone.local_thermostat_resume_at.is_none());
|
||||
assert!(zone.local_thermostat_restore_zone_enabled.is_none());
|
||||
assert!(!zone.demand);
|
||||
assert!(zone.demand_since.is_none());
|
||||
assert_eq!(zone.effective_mode, "off");
|
||||
|
||||
// A normal cycle must see an indefinite local OFF. Global/local/group ON releases it.
|
||||
assert!(set_house_bulk_thermostat_power(&mut zone, true));
|
||||
assert!(zone.local_thermostat_power.is_none());
|
||||
assert!(zone.local_thermostat_resume_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_house_on_does_not_create_a_local_on_override() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.local_thermostat_power = None;
|
||||
assert!(!set_house_bulk_thermostat_power(&mut zone, true));
|
||||
assert!(zone.local_thermostat_power.is_none());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let settings = state.settings.read().await.clone();
|
||||
let mut zone_snapshot = state.db.list_zones()?;
|
||||
// Local/temporary thermostat ownership is independent from one-shot whole-house power
|
||||
// Local/temporary thermostat ownership is independent from the legacy whole-house master gate
|
||||
// commands. Expire/activate sessions on their own deadlines.
|
||||
expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
|
||||
let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
|
||||
@@ -189,7 +189,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
zone.control_temperature_source = control_source;
|
||||
zone.updated_at = Utc::now();
|
||||
|
||||
// A queued whole-house ON is a delayed one-shot physical action, not thermostat
|
||||
// A queued whole-house ON is a delayed bulk physical action, not thermostat
|
||||
// ownership. It must survive local/group/manual state while compressor protection is
|
||||
// active, then execute once and hand control straight back to the existing owner.
|
||||
if zone.compressor_pending_action.as_deref() == Some("global_power_on") {
|
||||
@@ -216,7 +216,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
}
|
||||
Err(err) => {
|
||||
// Keep the user-visible task and retry on a bounded deadline instead of
|
||||
// spinning immediately or silently dropping a one-shot request.
|
||||
// spinning immediately or silently dropping the requested global start.
|
||||
zone.compressor_pending_until = Some(Utc::now() + chrono::Duration::seconds(10));
|
||||
zone.lockout_until = zone.compressor_pending_until;
|
||||
zone.lockout_reason = Some("global_start_retry".into());
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ async fn main() -> Result<()> {
|
||||
runtime_settings.discovery_broadcast = config.discovery_broadcast.clone();
|
||||
}
|
||||
config.apply_runtime_env_overrides(&mut runtime_settings);
|
||||
// v0.9.3: whole-house ON/OFF is a one-shot physical action, not a persistent master gate.
|
||||
// v0.9.4: whole-house ON/OFF is a bulk thermostat action, not a persistent master gate.
|
||||
// Normalize databases upgraded from older releases so a historical OFF cannot suppress
|
||||
// thermostats, groups or automations after restart.
|
||||
runtime_settings.house_power_enabled = true;
|
||||
|
||||
@@ -95,7 +95,7 @@ pub struct ControlPlan {
|
||||
pub house_mode: String,
|
||||
/// Uniform whole-house preset when every zone uses the same override; None for a mixed state.
|
||||
pub house_preset: Option<String>,
|
||||
/// Compatibility field. Global ON/OFF is a one-shot action; this is always true in v0.9.3+.
|
||||
/// Compatibility field. Global ON/OFF does not use a persistent master gate; this remains true in v0.9.4+.
|
||||
pub house_power: bool,
|
||||
pub outdoor_temperature: Option<f64>,
|
||||
pub control_strategy: String,
|
||||
|
||||
@@ -9,7 +9,7 @@ pub struct RuntimeSettings {
|
||||
/// Global seasonal mode. Zones follow this by default. Values: cool/heat/off; off pauses house-level thermostat control.
|
||||
#[serde(default = "default_house_mode")]
|
||||
pub house_mode: String,
|
||||
/// Legacy compatibility flag. Since v0.9.3 global ON/OFF is a one-shot command, not an
|
||||
/// Legacy compatibility flag. Since v0.9.4 global ON/OFF is a bulk thermostat action, not an
|
||||
/// automation gate. The controller normalizes this field to true on load/import.
|
||||
#[serde(default = "default_true")]
|
||||
pub house_power_enabled: bool,
|
||||
|
||||
Reference in New Issue
Block a user