v0.8.20
This commit is contained in:
+12
-2
@@ -101,8 +101,18 @@ fn validate_automation_references(state: &AppState, input: &AutomationInput) ->
|
||||
if state.db.get_group(group_id)?.is_none() {
|
||||
return Err(AppError::BadRequest("automation action group does not exist".into()));
|
||||
}
|
||||
} else if state.db.get_device(input.action_device_id.trim())?.is_none() {
|
||||
return Err(AppError::BadRequest("automation action device does not exist".into()));
|
||||
} else {
|
||||
let device_id = input.action_device_id.trim();
|
||||
if state.db.get_device(device_id)?.is_none() {
|
||||
return Err(AppError::BadRequest("automation action device does not exist".into()));
|
||||
}
|
||||
if engine::automation_action_conflicts_with_thermostat(&input.action)
|
||||
&& state.db.list_zones()?.iter().any(|zone| zone.enabled && zone.device_id == device_id)
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"direct fan/quiet/sleep automation conflicts with an enabled thermostat zone; use thermostat/group policy instead".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+6
-1
@@ -144,16 +144,21 @@ async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Jso
|
||||
device.updated_at = Utc::now();
|
||||
state.db.save_device(&device)?;
|
||||
state.broadcast("device.updated", serde_json::to_value(&device)?);
|
||||
// Enabling or changing a thermostat device should be reflected by the arbiter without
|
||||
// waiting for the periodic loop. The device lock above keeps the edit ordered against
|
||||
// polling and an in-flight thermostat command.
|
||||
state.wake_zone_control();
|
||||
Ok(Json(device))
|
||||
}
|
||||
|
||||
async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
// Keep reference validation and the destructive DB operation in one serialized window.
|
||||
// Lock order for cross-resource destructive operations: configuration -> automation -> house -> schedule -> zones -> device.
|
||||
// Lock order for cross-resource destructive operations: configuration -> automation -> house -> schedule -> cycle -> zones -> device.
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
if state.db.get_device(&id)?.is_none() { return Err(AppError::NotFound(format!("device {id}"))); }
|
||||
if state.db.list_automations()?.iter().any(|item| {
|
||||
item.trigger_device_id.as_deref() == Some(id.as_str())
|
||||
|
||||
@@ -45,6 +45,7 @@ async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInpu
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _reference_guard = state.lock_automation_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let zone_ids = validate_group_input(&state, &input)?;
|
||||
let now = Utc::now();
|
||||
let group = ClimateGroup {
|
||||
@@ -67,6 +68,7 @@ async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Jso
|
||||
// automation execution/reference validation before taking the group lock.
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let _group_guard = state.lock_group_operation(&id).await;
|
||||
let existing = state.db.get_group(&id)?.ok_or_else(|| AppError::NotFound(format!("group {id}")))?;
|
||||
let zone_ids = validate_group_input(&state, &input)?;
|
||||
@@ -88,6 +90,7 @@ async fn delete_group(State(state): State<AppState>, Path(id): Path<String>) ->
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let _group_guard = state.lock_group_operation(&id).await;
|
||||
if state.db.list_automations()?.iter().any(|item| item.action_group_id.as_deref() == Some(id.as_str())) {
|
||||
return Err(AppError::BadRequest("group is used by an automation; remove or retarget that automation first".into()));
|
||||
|
||||
+67
-9
@@ -1,6 +1,28 @@
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HouseControlPatch { mode: String }
|
||||
|
||||
|
||||
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();
|
||||
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); }
|
||||
for zone_id in &zone_ids {
|
||||
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
|
||||
if !zone.control_source.starts_with("group:") { continue; }
|
||||
zone.control_source = "automation".into();
|
||||
zone.control_since = Some(Utc::now());
|
||||
zone.control_reason = reason.to_string();
|
||||
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)?);
|
||||
}
|
||||
drop(guards);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> {
|
||||
let mut group_ids: Vec<String> = state.db.list_groups()?.into_iter().map(|group| group.id).collect();
|
||||
group_ids.sort();
|
||||
@@ -80,6 +102,10 @@ async fn command_all_enabled_devices_power(state: &AppState, power: bool, source
|
||||
|
||||
async fn update_house_control(State(state): State<AppState>, Json(input): Json<HouseControlPatch>) -> Result<Json<Value>, AppError> {
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
// Serialize the ownership/configuration transition against an already-running thermostat
|
||||
// cycle. Otherwise a cycle that captured the previous house mode could send one stale
|
||||
// climate command after this interactive change.
|
||||
let cycle_guard = state.lock_zone_control_cycle().await;
|
||||
if !matches!(input.mode.as_str(), "cool" | "heat" | "off") {
|
||||
return Err(AppError::BadRequest("house mode must be cool, heat or off".into()));
|
||||
}
|
||||
@@ -96,10 +122,17 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
|
||||
public_settings(&settings)
|
||||
};
|
||||
state.broadcast("settings.updated", payload.clone());
|
||||
clear_group_control_sources(&state, "Whole-house mode control took ownership").await?;
|
||||
if activate_all {
|
||||
set_all_groups_power(&state, true).await?;
|
||||
// Never send a bare power=true frame. Wake the thermostat arbiter so every unit
|
||||
// starts only with a valid effective Heat/Cool mode and compressor lockout policy.
|
||||
}
|
||||
// run_zone_control_now takes the same cycle lock, so release the mutation window first.
|
||||
drop(cycle_guard);
|
||||
if activate_all {
|
||||
// House mode changes are interactive controls: arbitrate all zones now instead of
|
||||
// leaving part of the house waiting for the background interval.
|
||||
engine::run_zone_control_now(&state).await?;
|
||||
} else {
|
||||
state.wake_zone_control();
|
||||
}
|
||||
state.log("info", "house.mode", &format!("House mode set to {}", mode), json!({"mode": mode, "master_power_enabled": activate_all}));
|
||||
@@ -111,6 +144,7 @@ struct HousePowerPatch { power: bool }
|
||||
|
||||
async fn update_house_power(State(state): State<AppState>, Json(input): Json<HousePowerPatch>) -> Result<Json<Value>, AppError> {
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let cycle_guard = state.lock_zone_control_cycle().await;
|
||||
// Whole-house power is independent from the thermostat mode. Publish/persist the master
|
||||
// first so the regulator becomes passive before the one-shot OFF cascade starts.
|
||||
{
|
||||
@@ -127,6 +161,7 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
|
||||
// markers once, then each enabled device is re-cleared atomically with its OFF command.
|
||||
// A later pilot action is therefore not erased by subsequent controller cycles.
|
||||
set_all_groups_power(&state, input.power).await?;
|
||||
clear_group_control_sources(&state, if input.power { "Whole-house power control resumed automation" } else { "Whole-house power disabled" }).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();
|
||||
@@ -149,10 +184,20 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
|
||||
drop(zone_guards);
|
||||
}
|
||||
let failed = if input.power {
|
||||
state.wake_zone_control();
|
||||
Vec::new()
|
||||
// The immediate cycle acquires this lock itself.
|
||||
drop(cycle_guard);
|
||||
match engine::run_zone_control_now(&state).await {
|
||||
Ok(()) => Vec::new(),
|
||||
Err(err) => {
|
||||
state.log("error", "house.immediate_control_error", &err.to_string(), json!({"source":"house_power"}));
|
||||
vec![json!({"scope":"thermostat_cycle","error":err.to_string()})]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
command_all_enabled_devices_power(&state, false, "house_power").await?
|
||||
// Keep the cycle excluded through the one-shot safety OFF cascade.
|
||||
let failed = command_all_enabled_devices_power(&state, false, "house_power").await?;
|
||||
drop(cycle_guard);
|
||||
failed
|
||||
};
|
||||
|
||||
let devices = state.db.list_devices()?;
|
||||
@@ -178,6 +223,7 @@ struct HousePresetPatch { preset: String }
|
||||
|
||||
async fn update_house_preset(State(state): State<AppState>, Json(input): Json<HousePresetPatch>) -> Result<Json<Value>, AppError> {
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let cycle_guard = state.lock_zone_control_cycle().await;
|
||||
if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") {
|
||||
return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into()));
|
||||
}
|
||||
@@ -192,6 +238,7 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
|
||||
};
|
||||
state.broadcast("settings.updated", settings_payload.clone());
|
||||
set_all_groups_power(&state, true).await?;
|
||||
clear_group_control_sources(&state, "Whole-house preset control took ownership").await?;
|
||||
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
|
||||
@@ -226,10 +273,18 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
|
||||
zones.push(zone);
|
||||
}
|
||||
|
||||
// As with house mode/power ON, the central thermostat arbiter performs the physical
|
||||
// start with a valid mode/target. This prevents unmanaged power-on while house mode=off.
|
||||
let failed: Vec<Value> = Vec::new();
|
||||
state.wake_zone_control();
|
||||
// The immediate regulator cycle takes the same per-zone locks; release the batch guards
|
||||
// after the profile update is fully persisted to preserve the global zone -> device order.
|
||||
drop(_zone_guards);
|
||||
drop(cycle_guard);
|
||||
|
||||
// Apply the whole-house profile before returning so every eligible thermostat gets the
|
||||
// same arbitration cycle and no member is left waiting behind the periodic interval.
|
||||
let mut failed: Vec<Value> = Vec::new();
|
||||
if let Err(err) = engine::run_zone_control_now(&state).await {
|
||||
state.log("error", "house.immediate_control_error", &err.to_string(), json!({"source":"house_preset"}));
|
||||
failed.push(json!({"scope":"thermostat_cycle","error":err.to_string()}));
|
||||
}
|
||||
let devices = state.db.list_devices()?;
|
||||
state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({
|
||||
"preset": input.preset,
|
||||
@@ -251,6 +306,7 @@ struct ScheduleTemplateRequest { template: String }
|
||||
async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleTemplateRequest>) -> Result<Json<Value>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||
let mut items: Vec<Schedule> = Vec::new();
|
||||
let mut add = |name: &str, days: Vec<u32>, start: &str, end: &str, preset: &str| {
|
||||
@@ -294,6 +350,7 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
|
||||
state.db.replace_schedules_for_zone(&id, &items)?;
|
||||
refresh_zone_override_boundary(&state, &id).await?;
|
||||
state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()}));
|
||||
state.wake_zone_control();
|
||||
Ok(Json(json!({"zone": zone, "schedules": items})))
|
||||
}
|
||||
|
||||
@@ -306,6 +363,7 @@ async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> R
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let zone_guard = state.lock_zone_operation(&id).await;
|
||||
let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||
let mut removed = std::collections::HashSet::new();
|
||||
|
||||
+10
-1
@@ -71,7 +71,13 @@ async fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Resu
|
||||
if (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) && !temporary_owns_zone {
|
||||
zone.manual_override_until = boundary;
|
||||
}
|
||||
if zone.device_manual_override { zone.device_manual_override_until = boundary; zone.control_resume_at = boundary; }
|
||||
// Editing schedules must never arm an expiry for direct device takeover. Manual device
|
||||
// control is intentionally persistent until the user explicitly resumes automation (or
|
||||
// whole-house OFF performs the global safety reset).
|
||||
if zone.device_manual_override {
|
||||
zone.device_manual_override_until = None;
|
||||
zone.control_resume_at = None;
|
||||
}
|
||||
if has_temporary_schedule_boundary {
|
||||
let reference = zone.temporary_quick_thermostat.as_ref()
|
||||
.filter(|session| session.activated_at.is_none())
|
||||
@@ -96,6 +102,7 @@ async fn get_schedule(State(state): State<AppState>, Path(id): Path<String>) ->
|
||||
async fn create_schedule(State(state): State<AppState>, Json(input): Json<ScheduleInput>) -> Result<(StatusCode, Json<Schedule>), AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
input.validate()?;
|
||||
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
|
||||
let item = input.into_schedule(Uuid::new_v4().to_string(), Utc::now());
|
||||
@@ -109,6 +116,7 @@ async fn create_schedule(State(state): State<AppState>, Json(input): Json<Schedu
|
||||
async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleInput>) -> Result<Json<Schedule>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
input.validate()?;
|
||||
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
|
||||
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
|
||||
@@ -125,6 +133,7 @@ async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>,
|
||||
async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
|
||||
if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); }
|
||||
refresh_zone_override_boundary(&state, &existing.zone_id).await?;
|
||||
|
||||
@@ -6,6 +6,7 @@ async fn get_settings(State(state): State<AppState>) -> Json<Value> {
|
||||
async fn update_settings(State(state): State<AppState>, Json(mut input): Json<RuntimeSettings>) -> Result<Json<Value>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let old = state.settings.read().await.clone();
|
||||
if input.house_power_enabled != old.house_power_enabled || input.house_mode != old.house_mode {
|
||||
return Err(AppError::BadRequest(
|
||||
@@ -53,6 +54,7 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
|
||||
*state.settings.write().await = input.clone();
|
||||
state.log("info", "settings.updated", "Settings updated", json!({}));
|
||||
state.broadcast("settings.updated", public_settings(&input));
|
||||
state.wake_zone_control();
|
||||
Ok(Json(public_settings(&input)))
|
||||
}
|
||||
|
||||
@@ -330,6 +332,7 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
|
||||
let current_zones = state.db.list_zones()?;
|
||||
let current_devices = state.db.list_devices()?;
|
||||
|
||||
@@ -142,6 +142,7 @@ async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>
|
||||
canonicalize_zone_ha_entity(&mut zone, &settings);
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.created", serde_json::to_value(&zone)?);
|
||||
state.wake_zone_control();
|
||||
Ok((StatusCode::CREATED, Json(zone)))
|
||||
}
|
||||
async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ZoneInput>) -> Result<Json<Zone>, AppError> {
|
||||
@@ -241,10 +242,15 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
||||
if power_off_device {
|
||||
power_off_zone_device(&state, &zone, "zone.disabled").await;
|
||||
}
|
||||
state.wake_zone_control();
|
||||
Ok(Json(zone))
|
||||
}
|
||||
|
||||
async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch, source: &str) -> Result<Zone, AppError> {
|
||||
// A quick preset/setpoint derives its resume boundary from schedules. Take the schedule
|
||||
// lock before the per-zone lock so a concurrent schedule edit cannot leave an override
|
||||
// pointing at an obsolete boundary (and so lock order stays schedule -> zone -> device).
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
// Serialize quick-thermostat changes with the same device lock used by GREE polling and
|
||||
// manual-takeover detection. Without this, a poll that started just before a Web/HA
|
||||
// thermostat action could save an older zone snapshot afterwards and resurrect a false
|
||||
|
||||
@@ -50,3 +50,48 @@ impl Db {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Merge device transition timestamps into zone JSON without replacing newer zone state.
|
||||
/// `updated_at` acts as a compare-and-swap token but is intentionally not changed here:
|
||||
/// thermostat cycles use it to detect real configuration/ownership updates.
|
||||
pub fn merge_zone_device_transition_timestamps(
|
||||
&self,
|
||||
device_id: &str,
|
||||
power_changed: bool,
|
||||
mode_changed: bool,
|
||||
at: DateTime<Utc>,
|
||||
) -> Result<Vec<Zone>> {
|
||||
if !power_changed && !mode_changed { return Ok(Vec::new()); }
|
||||
let zone_ids: Vec<String> = self.list_zones()?.into_iter()
|
||||
.filter(|zone| zone.device_id == device_id)
|
||||
.map(|zone| zone.id)
|
||||
.collect();
|
||||
let mut updated = Vec::with_capacity(zone_ids.len());
|
||||
for zone_id in zone_ids {
|
||||
let mut saved = false;
|
||||
for _ in 0..8 {
|
||||
let Some(mut zone) = self.get_zone(&zone_id)? else { break; };
|
||||
let expected_updated_at = zone.updated_at.to_rfc3339();
|
||||
if power_changed { zone.last_power_change_at = Some(at); }
|
||||
if mode_changed { zone.last_mode_change_at = Some(at); }
|
||||
let payload = Self::to_json(&zone)?;
|
||||
let conn = self.lock()?;
|
||||
let changed = conn.execute(
|
||||
"UPDATE zones SET payload = ?1 WHERE id = ?2 AND updated_at = ?3",
|
||||
params![payload, zone.id, expected_updated_at],
|
||||
)?;
|
||||
drop(conn);
|
||||
if changed > 0 {
|
||||
updated.push(zone);
|
||||
saved = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !saved && self.get_zone(&zone_id)?.is_some() {
|
||||
return Err(anyhow::anyhow!("zone {zone_id} kept changing while device transition timestamps were merged"));
|
||||
}
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
|
||||
pub fn automation_action_conflicts_with_thermostat(command: &DeviceCommand) -> bool {
|
||||
// Power/mode/target are translated by apply_automatic_device_action into durable zone
|
||||
// state, so they do not fight the thermostat. Fan/quiet/sleep are thermostat outputs with
|
||||
// no independent zone override model; accepting them as one-shot direct automation would
|
||||
// let the next thermostat cycle immediately overwrite them.
|
||||
command.fan_speed.is_some() || command.quiet.is_some() || command.sleep.is_some()
|
||||
}
|
||||
|
||||
fn device_has_enabled_thermostat_zone(device_id: &str, zones: &[Zone]) -> bool {
|
||||
zones.iter().any(|zone| zone.device_id == device_id && zone.enabled)
|
||||
}
|
||||
|
||||
async fn run_automations(state: &AppState) -> Result<()> {
|
||||
if !state.settings.read().await.house_power_enabled { return Ok(()); }
|
||||
let devices = state.db.list_devices()?;
|
||||
@@ -62,6 +75,19 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
|
||||
if item.action_group_id.is_none()
|
||||
&& device_has_enabled_thermostat_zone(&item.action_device_id, &zones)
|
||||
&& automation_action_conflicts_with_thermostat(&item.action)
|
||||
{
|
||||
// Fan/quiet/sleep are outputs continuously managed by the thermostat. Unlike
|
||||
// power/mode/target they cannot be translated into durable zone state, so a direct
|
||||
// automation would be immediately overwritten by the next thermostat cycle.
|
||||
state.log("warn", "automation.blocked_by_thermostat_owner", &format!("Automation {} suppressed because the device is owned by an enabled thermostat zone", item.name), json!({
|
||||
"automation_id": item.id, "device_id": item.action_device_id
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
let target_devices: Vec<String> = if let Some(group_id) = item.action_group_id.as_deref() {
|
||||
groups.iter().find(|group| group.id == group_id)
|
||||
.map(|group| group.zone_ids.iter()
|
||||
|
||||
@@ -170,15 +170,15 @@ async fn send_command_locked_inner(
|
||||
}
|
||||
|
||||
fn record_device_transition_timestamps(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> {
|
||||
if before.power == after.power && before.mode == after.mode { return Ok(()); }
|
||||
let now = Utc::now();
|
||||
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) {
|
||||
if before.power != after.power { zone.last_power_change_at = Some(now); }
|
||||
if before.mode != after.mode { zone.last_mode_change_at = Some(now); }
|
||||
// Do not bump zone.updated_at here: an in-flight thermostat cycle uses that field
|
||||
// as its optimistic snapshot guard. The cycle mirrors these timestamps into its own
|
||||
// computed Zone after a successful automatic command.
|
||||
state.db.save_zone(&zone)?;
|
||||
let power_changed = before.power != after.power;
|
||||
let mode_changed = before.mode != after.mode;
|
||||
if !power_changed && !mode_changed { return Ok(()); }
|
||||
// This function is often called while the device lock is held, so acquiring a zone lock
|
||||
// here would invert the global zone -> device order. Merge only these timestamp fields
|
||||
// with a DB compare-and-swap instead of saving a stale whole-zone snapshot.
|
||||
for zone in state.db.merge_zone_device_transition_timestamps(
|
||||
&after.id, power_changed, mode_changed, Utc::now(),
|
||||
)? {
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
+30
-3
@@ -25,6 +25,10 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
// (especially from an automation) from resurrecting the master while whole-house OFF
|
||||
// is being applied. Group and zone locks then make the member update atomic.
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
// Group state participates in thermostat arbitration. Exclude an already-running cycle
|
||||
// while changing the group gate/profile so no cycle can act on a stale group snapshot.
|
||||
// Lock order stays house -> cycle -> group -> zone -> device.
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let _group_guard = state.lock_group_operation(group_id).await;
|
||||
let mut group = state.db.get_group(group_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?;
|
||||
@@ -123,6 +127,12 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
|
||||
}
|
||||
}
|
||||
if !temporary_owns_zone && !zone.device_manual_override && zone.local_thermostat_power.is_none() {
|
||||
zone.control_owner = "automation".into();
|
||||
zone.control_source = format!("group:{}", group.id);
|
||||
zone.control_since = Some(Utc::now());
|
||||
zone.control_reason = format!("Controlled by group {}", group.name);
|
||||
}
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
@@ -158,9 +168,26 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
}
|
||||
}
|
||||
|
||||
if desired_power && (should_command_power || climate_change) {
|
||||
state.wake_zone_control();
|
||||
}
|
||||
let run_immediately = desired_power && (should_command_power || climate_change);
|
||||
let zones = if run_immediately {
|
||||
// Do not just wake the background loop: a group ON/profile/setpoint action is expected
|
||||
// to arbitrate every member before the HTTP request completes. Release member/domain
|
||||
// locks first, then run one globally serialized thermostat cycle.
|
||||
drop(_zone_guards);
|
||||
drop(_group_guard);
|
||||
drop(_cycle_guard);
|
||||
drop(_house_guard);
|
||||
if let Err(err) = run_zone_control_now(state).await {
|
||||
state.log("error", "group.immediate_control_error", &err.to_string(), json!({
|
||||
"group_id": group.id, "source": source,
|
||||
}));
|
||||
failed.push(json!({"scope":"thermostat_cycle","error":err.to_string()}));
|
||||
}
|
||||
group.zone_ids.iter().filter_map(|zone_id| state.db.get_zone(zone_id).ok().flatten()).collect::<Vec<_>>()
|
||||
} else {
|
||||
zones
|
||||
};
|
||||
|
||||
state.log("info", source, &format!("Updated group {}", group.name), json!({
|
||||
"group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset, "setpoint": custom_setpoint,
|
||||
"zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled,
|
||||
|
||||
+34
-23
@@ -25,9 +25,11 @@ pub fn refresh_control_ownership(zone: &mut Zone, house_power_enabled: bool, blo
|
||||
};
|
||||
("local_thermostat", source, resume_at, reason)
|
||||
} else if blocked_by_group {
|
||||
("automation", "group".to_string(), None, "Zone is blocked by a disabled group".to_string())
|
||||
let source = if zone.control_source.starts_with("group:") { zone.control_source.clone() } else { "group".into() };
|
||||
("automation", source, None, "Zone is blocked by a disabled group".to_string())
|
||||
} else {
|
||||
("automation", "automation".to_string(), zone.manual_override_until, "Automatic thermostat/schedule control".to_string())
|
||||
let source = if zone.control_source.starts_with("group:") { zone.control_source.clone() } else { "automation".into() };
|
||||
("automation", source, zone.manual_override_until, "Automatic thermostat/schedule control".to_string())
|
||||
};
|
||||
if zone.control_owner != owner || zone.control_source != source {
|
||||
zone.control_since = Some(now);
|
||||
@@ -156,12 +158,11 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec<Str
|
||||
zone.control_owner = "direct_manual".into();
|
||||
zone.control_source = normalized_direct_source(source).into();
|
||||
zone.control_reason = "Direct/manual device control has priority".into();
|
||||
zone.device_manual_override_until = if zone.enabled {
|
||||
next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
zone.control_resume_at = zone.device_manual_override_until;
|
||||
// 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).
|
||||
// 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;
|
||||
for field in fields {
|
||||
if !zone.device_manual_override_fields.iter().any(|existing| existing == &field) {
|
||||
zone.device_manual_override_fields.push(field);
|
||||
@@ -196,10 +197,9 @@ async fn detect_external_device_control(state: &AppState, before: &Device, after
|
||||
after,
|
||||
raw_fields.clone(),
|
||||
).await;
|
||||
if zone.device_manual_override && manual_override_matches_baseline(&zone, after) {
|
||||
persist_manual_override_clear(state, &mut zone, "gree_poll", true)?;
|
||||
continue;
|
||||
}
|
||||
// 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.
|
||||
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.
|
||||
@@ -243,15 +243,14 @@ pub async fn send_manual_command(state: &AppState, device_id: &str, command: Dev
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
let before = state.db.get_device(device_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
||||
let effective_command = if before.online && before.communication_failures == 0 { command.changed_from(&before) } else { command.clone() };
|
||||
let fields = command_manual_control_fields(&effective_command);
|
||||
// An explicit direct-control request is an ownership action even when the requested
|
||||
// value already matches the cached device state. Derive takeover fields from the user's
|
||||
// request, not only from the physical delta, so clicking ON / entering the current target
|
||||
// still switches the thermostat zone to persistent manual control.
|
||||
let fields = command_manual_control_fields(&command);
|
||||
let updated = send_command_locked_inner(state, device_id, command, true, false).await?;
|
||||
if !fields.is_empty() {
|
||||
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) {
|
||||
if zone.device_manual_override && manual_override_matches_baseline(&zone, &updated) {
|
||||
persist_manual_override_clear(state, &mut zone, source, true)?;
|
||||
continue;
|
||||
}
|
||||
if !zone.enabled && !updated.power {
|
||||
persist_manual_override_clear(state, &mut zone, source, false)?;
|
||||
continue;
|
||||
@@ -263,11 +262,22 @@ 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> {
|
||||
// Global OFF is a one-shot authority transition. Clear takeover and send OFF while
|
||||
// polling for this unit is excluded; a later remote change happens after the lock and
|
||||
// is therefore preserved as a new manual takeover.
|
||||
// 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.
|
||||
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;
|
||||
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) {
|
||||
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)?;
|
||||
@@ -276,7 +286,8 @@ pub async fn force_house_power_off_device(state: &AppState, device_id: &str, sou
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "source": source
|
||||
}));
|
||||
}
|
||||
send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await
|
||||
// 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 force_power_off_device(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
||||
|
||||
@@ -167,6 +167,15 @@ mod tests {
|
||||
assert!(!command_field_matches_device(&command, "target_temperature", &device));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_thermostat_output_fields_are_detected_for_direct_automation() {
|
||||
assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { power: Some(true), ..Default::default() }));
|
||||
assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { target_temperature: Some(22.0), ..Default::default() }));
|
||||
assert!(automation_action_conflicts_with_thermostat(&DeviceCommand { fan_speed: Some(3), ..Default::default() }));
|
||||
assert!(automation_action_conflicts_with_thermostat(&DeviceCommand { quiet: Some(true), ..Default::default() }));
|
||||
assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { light: Some(false), turbo: Some(true), ..Default::default() }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_thermostat_ownership_blocks_direct_automation() {
|
||||
let mut zone = test_zone("device");
|
||||
|
||||
@@ -117,6 +117,9 @@ async fn apply_automatic_device_action(
|
||||
device_id: &str,
|
||||
command: DeviceCommand,
|
||||
) -> Result<Option<Device>, AppError> {
|
||||
// Direct automation target/setpoint is translated into zone state and may use the next
|
||||
// schedule boundary. Serialize that derivation with schedule edits before locking the zone.
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
let zones = state.db.list_zones()?;
|
||||
let Some(zone_id) = zones.iter().find(|zone| zone.device_id == device_id).map(|zone| zone.id.clone()) else {
|
||||
return send_automatic_device_command_if_owned(state, device_id, command).await;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let groups = state.db.list_groups()?;
|
||||
let settings = state.settings.read().await.clone();
|
||||
@@ -78,16 +79,29 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
.map(|(zone_id, entity_id, result)| (zone_id, (entity_id, result)))
|
||||
.collect();
|
||||
|
||||
for mut zone in zone_snapshot {
|
||||
for zone_snapshot_item in zone_snapshot {
|
||||
// Every thermostat decision participates in the same zone -> device ordering as
|
||||
// Web/HA/manual control and polling. Re-read after taking the zone lock so an
|
||||
// interactive change cannot be evaluated from a stale snapshot.
|
||||
let _zone_guard = state.lock_zone_operation(&zone_snapshot_item.id).await;
|
||||
let Some(mut zone) = state.db.get_zone(&zone_snapshot_item.id)? else { continue; };
|
||||
let cycle_started_at = zone.updated_at;
|
||||
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
if zone.control_source.starts_with("group:") {
|
||||
zone.control_source = "automation".into();
|
||||
zone.control_since = Some(Utc::now());
|
||||
zone.control_reason = "Group override expired at schedule boundary".into();
|
||||
}
|
||||
}
|
||||
if zone.device_manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
|
||||
reset_device_manual_override(&mut zone);
|
||||
state.log("info", "zone.device_manual_override_expired", &format!("Manual device control expired for {} at schedule transition", zone.name), json!({
|
||||
// v0.8.20 makes direct/manual takeover persistent. Normalize any legacy persisted
|
||||
// boundary from older releases instead of silently returning ownership to schedules.
|
||||
if zone.device_manual_override && zone.device_manual_override_until.is_some() {
|
||||
zone.device_manual_override_until = None;
|
||||
zone.control_resume_at = None;
|
||||
state.log("info", "zone.device_manual_override_migrated", &format!("Manual device control remains active for {} until explicit resume", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id
|
||||
}));
|
||||
}
|
||||
@@ -541,3 +555,9 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
/// Run one thermostat arbitration cycle immediately and wait for all currently eligible zones.
|
||||
/// The cycle lock prevents overlap with the background regulator.
|
||||
pub async fn run_zone_control_now(state: &AppState) -> Result<(), AppError> {
|
||||
control_zones(state).await.map_err(AppError::from)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ async fn main() -> Result<()> {
|
||||
automation_operation_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
house_operation_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
configuration_operation_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
zone_control_cycle_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
pending_controller_commands: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
started: Instant::now(),
|
||||
};
|
||||
|
||||
@@ -37,6 +37,8 @@ pub struct AppState {
|
||||
pub(crate) automation_operation_lock: Arc<Mutex<()>>,
|
||||
pub(crate) house_operation_lock: Arc<Mutex<()>>,
|
||||
pub(crate) configuration_operation_lock: Arc<Mutex<()>>,
|
||||
/// Serializes thermostat control cycles, including explicit immediate runs requested by group/house control.
|
||||
pub(crate) zone_control_cycle_lock: Arc<Mutex<()>>,
|
||||
/// Short-lived expected climate state from controller-originated commands. It prevents
|
||||
/// a delayed GREE status update from being mistaken for remote/manual takeover.
|
||||
pub(crate) pending_controller_commands: Arc<Mutex<HashMap<String, PendingControllerCommand>>>,
|
||||
@@ -84,6 +86,10 @@ impl AppState {
|
||||
self.configuration_operation_lock.clone().lock_owned().await
|
||||
}
|
||||
|
||||
pub async fn lock_zone_control_cycle(&self) -> OwnedMutexGuard<()> {
|
||||
self.zone_control_cycle_lock.clone().lock_owned().await
|
||||
}
|
||||
|
||||
pub fn wake_zone_control(&self) {
|
||||
self.zone_control_wakeup.notify_one();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user