v0.9.3
This commit is contained in:
+64
-21
@@ -1,8 +1,6 @@
|
||||
pub fn refresh_control_ownership(zone: &mut Zone, house_power_enabled: bool) {
|
||||
pub fn refresh_control_ownership(zone: &mut Zone, _house_power_enabled: bool) {
|
||||
let now = Utc::now();
|
||||
let (owner, source, resume_at, reason) = if !house_power_enabled {
|
||||
("global_off", "global".to_string(), None, "Whole-house power is disabled".to_string())
|
||||
} else if zone.device_manual_override {
|
||||
let (owner, source, resume_at, reason) = if zone.device_manual_override {
|
||||
let source = match zone.control_source.as_str() {
|
||||
"home_assistant_direct" | "web_direct" | "external" => zone.control_source.clone(),
|
||||
_ => "external".into(),
|
||||
@@ -157,7 +155,8 @@ 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 chooses Resume automation (or a global safety OFF).
|
||||
// Keep it manual until the user explicitly chooses Resume automation. A one-shot 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;
|
||||
zone.control_resume_at = None;
|
||||
@@ -200,7 +199,7 @@ async fn detect_external_device_control(state: &AppState, before: &Device, after
|
||||
).await;
|
||||
// Once direct/pilot ownership has been detected, merely returning the device to a
|
||||
// previous physical state must not silently hand control back to schedules. Only the
|
||||
// explicit Resume automation action (or global OFF safety reset) ends manual ownership.
|
||||
// explicit Resume automation action ends manual ownership.
|
||||
if fields.is_empty() { continue; }
|
||||
// A disabled zone is outside controller ownership. When its manually operated unit is
|
||||
// switched off there is no takeover left to display or remember.
|
||||
@@ -262,10 +261,10 @@ pub async fn send_manual_command(state: &AppState, device_id: &str, command: Dev
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn force_house_power_off_device(state: &AppState, device_id: &str, source: &str) -> Result<Device, AppError> {
|
||||
// Preserve the global zone -> device lock order even for the whole-house safety path.
|
||||
// Otherwise a zone edit could hold its zone lock while waiting for this device lock as
|
||||
// this function saved a stale zone snapshot without owning the corresponding zone lock.
|
||||
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.
|
||||
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter()
|
||||
.filter(|zone| zone.device_id == device_id)
|
||||
.map(|zone| zone.id)
|
||||
@@ -277,20 +276,64 @@ pub async fn force_house_power_off_device(state: &AppState, device_id: &str, sou
|
||||
_zone_guards.push(state.lock_zone_operation(zone_id).await);
|
||||
}
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
for zone_id in &zone_ids {
|
||||
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
|
||||
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
|
||||
}));
|
||||
}
|
||||
// The device lock is already held, so use the locked forced-command variant directly.
|
||||
force_power_off_device_locked(state, device_id).await
|
||||
}
|
||||
|
||||
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
|
||||
// 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()
|
||||
.filter(|zone| zone.device_id == device_id)
|
||||
.map(|zone| zone.id)
|
||||
.collect();
|
||||
zone_ids.sort();
|
||||
zone_ids.dedup();
|
||||
let mut _zone_guards = Vec::with_capacity(zone_ids.len());
|
||||
for zone_id in &zone_ids {
|
||||
_zone_guards.push(state.lock_zone_operation(zone_id).await);
|
||||
}
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
let device = state.db.get_device(device_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
||||
if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); }
|
||||
if device.power {
|
||||
return Ok(device);
|
||||
}
|
||||
|
||||
let settings = state.settings.read().await.clone();
|
||||
if settings.compressor_protection_enabled {
|
||||
if let Some(zone_id) = zone_ids.first() {
|
||||
if let Some(mut zone) = state.db.get_zone(zone_id)? {
|
||||
let now = Utc::now();
|
||||
let protection = chrono::Duration::seconds(settings.compressor_protection_seconds as i64);
|
||||
if let Some(last_change) = zone.last_power_change_at {
|
||||
let until = last_change + protection;
|
||||
if until > now {
|
||||
rearm_compressor_queue(&mut zone);
|
||||
queue_compressor_action(&mut zone, "global_power_on".into(), until, "minimum_off_before_global_start");
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = now;
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
state.log("info", "zone.compressor_queue_queued", &format!("Queued global ON for {} behind compressor protection", zone.name), json!({
|
||||
"zone_id": zone.id,
|
||||
"device_id": zone.device_id,
|
||||
"action": "global_power_on",
|
||||
"resume_at": zone.compressor_pending_until,
|
||||
}));
|
||||
state.wake_zone_control();
|
||||
return Ok(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The device lock is already held. This is physical one-shot power only: no manual marker.
|
||||
send_command_locked(state, device_id, DeviceCommand { power: Some(true), ..Default::default() }).await
|
||||
}
|
||||
|
||||
pub async fn force_power_off_device(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await
|
||||
|
||||
Reference in New Issue
Block a user