v0.8.20
This commit is contained in:
+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();
|
||||
|
||||
Reference in New Issue
Block a user