This commit is contained in:
Mateusz Gruszczyński
2026-09-01 10:20:19 +02:00
parent 1a5c1305dc
commit 3479ed750d
34 changed files with 575 additions and 113 deletions
+60 -11
View File
@@ -1,7 +1,14 @@
#[derive(Debug, Deserialize)]
struct HouseControlPatch { mode: String }
fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> {
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();
group_ids.dedup();
let mut _group_guards = Vec::with_capacity(group_ids.len());
for group_id in &group_ids {
_group_guards.push(state.lock_group_operation(group_id).await);
}
for mut group in state.db.list_groups()? {
if group.power_enabled == power { continue; }
group.power_enabled = power;
@@ -72,6 +79,7 @@ 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;
if !matches!(input.mode.as_str(), "cool" | "heat" | "off") {
return Err(AppError::BadRequest("house mode must be cool, heat or off".into()));
}
@@ -89,7 +97,7 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
};
state.broadcast("settings.updated", payload.clone());
if activate_all {
set_all_groups_power(&state, true)?;
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.
state.wake_zone_control();
@@ -102,6 +110,7 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
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;
// 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.
{
@@ -117,10 +126,27 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
// Global power is a true cascade across group gates. OFF clears the current takeover
// 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)?;
set_all_groups_power(&state, input.power).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();
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 mut device_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.device_id).collect();
device_ids.sort();
device_ids.dedup();
let mut device_guards = Vec::with_capacity(device_ids.len());
for device_id in &device_ids {
device_guards.push(state.lock_device_operation(device_id).await);
}
engine::clear_all_device_manual_overrides(&state, "house_power_off")?;
clear_all_local_thermostat_overrides(&state)?;
drop(device_guards);
drop(zone_guards);
}
let failed = if input.power {
state.wake_zone_control();
@@ -151,6 +177,7 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
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;
if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") {
return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into()));
}
@@ -164,14 +191,25 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
public_settings(&settings)
};
state.broadcast("settings.updated", settings_payload.clone());
set_all_groups_power(&state, true)?;
set_all_groups_power(&state, true).await?;
let schedules = state.db.list_schedules()?;
let mut zones = state.db.list_zones()?;
for zone in &mut zones {
if engine::temporary_quick_thermostat_is_active(zone, Utc::now()) {
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 _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 mut zones = Vec::with_capacity(zone_ids.len());
for zone_id in &zone_ids {
let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; };
let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await;
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
session.deferred_preset = Some(input.preset.clone());
session.deferred_setpoint = None;
}
} else if input.preset == "auto" {
zone.manual_preset = None;
@@ -183,8 +221,9 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
}
zone.updated_at = Utc::now();
state.db.save_zone(zone)?;
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
zones.push(zone);
}
// As with house mode/power ON, the central thermostat arbiter performs the physical
@@ -210,6 +249,8 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
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 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| {
@@ -251,7 +292,7 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
}
validate_schedule_set(&items)?;
state.db.replace_schedules_for_zone(&id, &items)?;
refresh_zone_override_boundary(&state, &id)?;
refresh_zone_override_boundary(&state, &id).await?;
state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()}));
Ok(Json(json!({"zone": zone, "schedules": items})))
}
@@ -261,13 +302,21 @@ async fn update_home_assistant_zone_control(State(state): State<AppState>, Path(
}
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
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 _schedule_guard = state.lock_schedule_operation().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();
removed.insert(id.clone());
ensure_zone_removal_safe(&state, &removed)?;
ensure_device_stopped_for_detach(&state, &zone.device_id, "zone.deleted").await?;
if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); }
remove_zone_ids_from_groups(&state, &removed)?;
// Group control locks group first and zone second. Release the zone lock before taking
// group locks so deletion cannot form the inverse zone -> group lock order.
drop(zone_guard);
remove_zone_ids_from_groups_locked(&state, &removed).await?;
state.broadcast("zone.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT)
}