382 lines
18 KiB
Rust
382 lines
18 KiB
Rust
#[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();
|
|
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;
|
|
group.updated_at = Utc::now();
|
|
state.db.save_group(&group)?;
|
|
state.broadcast("group.updated", serde_json::to_value(&group)?);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn clear_all_local_thermostat_overrides(state: &AppState) -> Result<usize, AppError> {
|
|
let mut cleared = 0;
|
|
for mut zone in state.db.list_zones()? {
|
|
if zone.local_thermostat_power.is_none() && zone.local_thermostat_resume_at.is_none() && zone.temporary_quick_thermostat.is_none() { continue; }
|
|
let temporary_was_active = engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
|
|
let temporary_restore = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.restore_zone_enabled);
|
|
zone.temporary_quick_thermostat = None;
|
|
engine::reset_local_thermostat_override(&mut zone);
|
|
if temporary_was_active {
|
|
if let Some(enabled) = temporary_restore { zone.enabled = enabled; }
|
|
}
|
|
zone.updated_at = Utc::now();
|
|
state.db.save_zone(&zone)?;
|
|
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
|
cleared += 1;
|
|
}
|
|
Ok(cleared)
|
|
}
|
|
|
|
async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result<Vec<Value>, AppError> {
|
|
let mut failed = Vec::new();
|
|
let enabled_zone_devices: std::collections::HashSet<String> = if power {
|
|
state.db.list_zones()?.into_iter()
|
|
.filter(|zone| zone.enabled && !zone.device_manual_override && zone.local_thermostat_power != Some(false))
|
|
.map(|zone| zone.device_id)
|
|
.collect()
|
|
} else {
|
|
std::collections::HashSet::new()
|
|
};
|
|
for device in state.db.list_devices()? {
|
|
if !device.enabled { continue; }
|
|
// Whole-house ON only operates thermostat-managed, enabled zones. Devices with
|
|
// a disabled zone (or no zone at all) remain manual/technical Devices controls.
|
|
if power && !enabled_zone_devices.contains(&device.id) { continue; }
|
|
// Do not trust the pre-loop power snapshot for deciding whether to send. The engine
|
|
// reloads state under the per-device lock and turns an already-matching command into
|
|
// a no-op. This closes the polling/command race without extra UDP frames.
|
|
let result = if power {
|
|
engine::send_command(state, &device.id, DeviceCommand { power: Some(true), ..Default::default() }).await
|
|
} else {
|
|
engine::force_house_power_off_device(state, &device.id, source).await
|
|
};
|
|
if let Err(err) = result {
|
|
state.log("error", "house.power_all_error", &err.to_string(), json!({
|
|
"device_id": device.id,
|
|
"device_name": device.name,
|
|
"power": power,
|
|
"source": source,
|
|
}));
|
|
failed.push(json!({
|
|
"device_id": device.id,
|
|
"device_name": device.name,
|
|
"error": err.to_string(),
|
|
}));
|
|
}
|
|
}
|
|
Ok(failed)
|
|
}
|
|
|
|
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()));
|
|
}
|
|
let mode = input.mode;
|
|
let activate_all = mode != "off";
|
|
let payload = {
|
|
let mut settings = state.settings.write().await;
|
|
settings.house_mode = mode.clone();
|
|
// Choosing a real whole-house operating mode is an explicit request to run the
|
|
// house climate. It therefore clears a previous global power-off. "off" keeps
|
|
// its separate meaning: do not control, without changing master power.
|
|
if activate_all { settings.house_power_enabled = true; }
|
|
state.db.save_runtime_settings(&settings)?;
|
|
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?;
|
|
}
|
|
// 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}));
|
|
Ok(Json(payload))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
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.
|
|
{
|
|
let mut settings = state.settings.write().await;
|
|
if settings.house_power_enabled != input.power {
|
|
settings.house_power_enabled = input.power;
|
|
state.db.save_runtime_settings(&settings)?;
|
|
let payload = public_settings(&settings);
|
|
state.broadcast("settings.updated", payload);
|
|
}
|
|
}
|
|
|
|
// 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).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();
|
|
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 {
|
|
// 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 {
|
|
// 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()?;
|
|
let groups = state.db.list_groups()?;
|
|
let settings = state.settings.read().await;
|
|
let settings_payload = public_settings(&settings);
|
|
drop(settings);
|
|
state.log("info", "house.power_all", if input.power { "Whole-house automation enabled; thermostat arbiter resumed" } else { "Whole-house power disabled; all groups and enabled devices powered off" }, json!({
|
|
"power": input.power,
|
|
"failed": failed.len(),
|
|
}));
|
|
Ok(Json(json!({
|
|
"power": input.power,
|
|
"devices": devices,
|
|
"groups": groups,
|
|
"settings": settings_payload,
|
|
"failed": failed,
|
|
})))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
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()));
|
|
}
|
|
|
|
// A whole-house profile is also an explicit whole-house activation. This mirrors
|
|
// selecting cooling/heating and makes the separate master-power control intuitive.
|
|
let settings_payload = {
|
|
let mut settings = state.settings.write().await;
|
|
settings.house_power_enabled = true;
|
|
state.db.save_runtime_settings(&settings)?;
|
|
public_settings(&settings)
|
|
};
|
|
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();
|
|
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;
|
|
zone.manual_setpoint = None;
|
|
zone.manual_override_until = None;
|
|
} else {
|
|
zone.manual_preset = Some(input.preset.clone());
|
|
zone.manual_setpoint = None;
|
|
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)?);
|
|
zones.push(zone);
|
|
}
|
|
|
|
// 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,
|
|
"master_power_enabled": true,
|
|
"failed": failed.len(),
|
|
}));
|
|
Ok(Json(json!({
|
|
"preset": input.preset,
|
|
"zones": zones,
|
|
"devices": devices,
|
|
"settings": settings_payload,
|
|
"failed": failed,
|
|
})))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
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| {
|
|
items.push(Schedule {
|
|
id: Uuid::new_v4().to_string(), zone_id: id.clone(), name: name.into(), enabled: true,
|
|
weekdays: days, start_time: start.into(), end_time: end.into(), preset: preset.into(),
|
|
setpoint: zone.setpoint, created_at: Utc::now(), updated_at: Utc::now(),
|
|
});
|
|
};
|
|
let all = vec![1,2,3,4,5,6,7];
|
|
match input.template.as_str() {
|
|
"family" => {
|
|
add("Comfort", all.clone(), "06:30", "22:30", "comfort");
|
|
add("Sleep", all, "22:30", "06:30", "sleep");
|
|
}
|
|
"child" => {
|
|
add("Comfort", all.clone(), "06:30", "20:30", "comfort");
|
|
add("Sleep", all, "20:30", "06:30", "sleep");
|
|
}
|
|
"bedroom" => {
|
|
add("Comfort", all.clone(), "06:30", "22:00", "comfort");
|
|
add("Sleep", all, "22:00", "06:30", "sleep");
|
|
}
|
|
"workday" => {
|
|
let weekdays = vec![1,2,3,4,5];
|
|
let weekend = vec![6,7];
|
|
add("Morning", weekdays.clone(), "06:30", "08:00", "comfort");
|
|
add("Away", weekdays.clone(), "08:00", "16:00", "away");
|
|
add("Evening", weekdays.clone(), "16:00", "22:30", "comfort");
|
|
add("Sleep", weekdays, "22:30", "06:30", "sleep");
|
|
add("Weekend", weekend, "08:00", "23:00", "comfort");
|
|
// Saturday can sleep until the Sunday weekend block starts at 08:00.
|
|
add("Saturday sleep", vec![6], "23:00", "08:00", "sleep");
|
|
// Sunday must hand over at 06:30 so it never overlaps Monday morning.
|
|
add("Sunday sleep", vec![7], "23:00", "06:30", "sleep");
|
|
}
|
|
"always" => add("Comfort", all, "00:00", "00:00", "comfort"),
|
|
_ => return Err(AppError::BadRequest("unknown schedule template".into())),
|
|
}
|
|
validate_schedule_set(&items)?;
|
|
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})))
|
|
}
|
|
|
|
async fn update_home_assistant_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, AppError> {
|
|
Ok(Json(apply_zone_control_patch(&state, &id, patch, "home_assistant.zone_thermostat").await?))
|
|
}
|
|
|
|
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 _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();
|
|
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}"))); }
|
|
// 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)
|
|
}
|
|
|