This commit is contained in:
Mateusz Gruszczyński
2026-09-01 11:10:28 +02:00
parent 3479ed750d
commit 9f08d7ccf9
30 changed files with 559 additions and 150 deletions
+12 -2
View File
@@ -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
View File
@@ -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())
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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?;
+3
View File
@@ -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()?;
+6
View File
@@ -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