542 lines
20 KiB
Rust
542 lines
20 KiB
Rust
#[derive(Debug, Deserialize)]
|
|
struct HouseControlPatch {
|
|
mode: String,
|
|
}
|
|
|
|
async fn rearm_house_automation_compressor_queues(state: &AppState) -> 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();
|
|
for zone_id in zone_ids {
|
|
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
|
let Some(mut zone) = state.db.get_zone(&zone_id)? else {
|
|
continue;
|
|
};
|
|
let scoped_manual = zone.device_manual_override
|
|
|| zone.local_thermostat_power.is_some()
|
|
|| zone.control_source.starts_with("group:")
|
|
|| engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
|
|
if scoped_manual {
|
|
continue;
|
|
}
|
|
if zone.compressor_pending_action.is_none()
|
|
&& zone.compressor_cancelled_action.is_none()
|
|
&& zone.lockout_until.is_none()
|
|
&& zone.lockout_reason.is_none()
|
|
{
|
|
continue;
|
|
}
|
|
engine::rearm_compressor_queue(&mut zone);
|
|
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)?);
|
|
}
|
|
Ok(())
|
|
}
|
|
async fn set_all_thermostat_power_state(state: &AppState, power: bool) -> Result<usize, 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 changed = 0usize;
|
|
for zone_id in zone_ids {
|
|
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
|
let Some(mut zone) = state.db.get_zone(&zone_id)? else {
|
|
continue;
|
|
};
|
|
engine::rearm_compressor_queue(&mut zone);
|
|
if engine::set_house_bulk_thermostat_power(&mut zone, power) {
|
|
changed += 1;
|
|
}
|
|
engine::refresh_control_ownership(&mut zone);
|
|
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)?);
|
|
}
|
|
Ok(changed)
|
|
}
|
|
|
|
async fn command_all_enabled_devices_power(
|
|
state: &AppState,
|
|
power: bool,
|
|
source: &str,
|
|
) -> Result<Vec<Value>, AppError> {
|
|
let mut failed = Vec::new();
|
|
for device in state.db.list_devices()? {
|
|
if !device.enabled {
|
|
continue;
|
|
}
|
|
// The per-zone thermostat power state is persisted before these physical commands.
|
|
// OFF is immediate; ON still respects compressor protection.
|
|
let result = if power {
|
|
engine::one_shot_house_power_on_device(state, &device.id).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 clear_all_automation_compressor_queues(state: &AppState) -> Result<usize, 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 changed = 0usize;
|
|
for zone_id in zone_ids {
|
|
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
|
let Some(mut zone) = state.db.get_zone(&zone_id)? else {
|
|
continue;
|
|
};
|
|
if zone.compressor_pending_action.is_none()
|
|
&& zone.compressor_cancelled_action.is_none()
|
|
&& zone.lockout_until.is_none()
|
|
&& zone.lockout_reason.is_none()
|
|
{
|
|
continue;
|
|
}
|
|
engine::rearm_compressor_queue(&mut zone);
|
|
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)?);
|
|
changed += 1;
|
|
}
|
|
Ok(changed)
|
|
}
|
|
|
|
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 mut settings = state.settings.write().await;
|
|
settings.house_mode = mode.clone();
|
|
state.db.save_runtime_settings(&settings)?;
|
|
}
|
|
let payload = json!({"mode": mode});
|
|
state.broadcast("house.mode_changed", payload.clone());
|
|
// House rules do not steal explicit local/group/manual ownership. Free zones follow the
|
|
// new mode immediately; scoped manual controls continue independently.
|
|
rearm_house_automation_compressor_queues(&state).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}),
|
|
);
|
|
Ok(Json(payload))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct HousePowerPatch {
|
|
power: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct HouseEmergencyStopPatch {
|
|
active: bool,
|
|
}
|
|
|
|
async fn update_house_emergency_stop(
|
|
State(state): State<AppState>,
|
|
Json(input): Json<HouseEmergencyStopPatch>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
// Automation execution already uses this lock before committing an action. Taking it first
|
|
// gives the emergency stop a clean barrier: an in-flight action finishes, then no newer
|
|
// automatic action can cross the persisted safety flag.
|
|
let _automation_guard = state.lock_automation_operation().await;
|
|
let _house_guard = state.lock_house_operation().await;
|
|
// Persist the safety gate while automatic control is serialized. Once the flag is stored,
|
|
// neither the background regulator nor an immediate thermostat run can emit automation
|
|
// commands until the user explicitly resumes normal operation.
|
|
let cycle_guard = state.lock_zone_control_cycle().await;
|
|
let (changed, emergency_stop_since) = {
|
|
let mut settings = state.settings.write().await;
|
|
let changed = settings.emergency_stop_enabled != input.active;
|
|
if changed {
|
|
settings.emergency_stop_enabled = input.active;
|
|
settings.emergency_stop_since = if input.active { Some(Utc::now()) } else { None };
|
|
state.db.save_runtime_settings(&settings)?;
|
|
}
|
|
(changed, settings.emergency_stop_since.clone())
|
|
};
|
|
|
|
// OFF is intentionally a one-shot side effect of activating the emergency stop. The
|
|
// persistent flag survives restarts, but startup never replays physical commands from it.
|
|
let mut failed = Vec::new();
|
|
let mut cleared_queues = 0usize;
|
|
if input.active && changed {
|
|
cleared_queues = clear_all_automation_compressor_queues(&state).await?;
|
|
failed = command_all_enabled_devices_power(&state, false, "house_emergency_stop").await?;
|
|
}
|
|
|
|
let payload = json!({
|
|
"active": input.active,
|
|
"since": emergency_stop_since,
|
|
"changed": changed,
|
|
"failed": failed,
|
|
"cleared_queues": cleared_queues,
|
|
});
|
|
state.broadcast("house.emergency_stop_changed", payload.clone());
|
|
drop(cycle_guard);
|
|
state.wake_zone_control();
|
|
|
|
// Resuming does not force any unit ON. It only releases the persistent safety gate and
|
|
// immediately re-evaluates current schedules, temperatures and ownership from fresh state.
|
|
if !input.active
|
|
&& changed
|
|
&& state.initial_device_sync_complete.load(Ordering::Acquire)
|
|
{
|
|
if let Err(err) = engine::run_zone_control_now(&state).await {
|
|
state.log(
|
|
"error",
|
|
"house.emergency_resume_control_error",
|
|
&err.to_string(),
|
|
json!({"active": false}),
|
|
);
|
|
}
|
|
}
|
|
|
|
state.log(
|
|
"info",
|
|
if input.active {
|
|
"house.emergency_stop_activated"
|
|
} else {
|
|
"house.emergency_stop_released"
|
|
},
|
|
if input.active {
|
|
"Emergency stop activated; automatic climate control paused"
|
|
} else {
|
|
"Emergency stop released; automatic climate control resumed"
|
|
},
|
|
json!({
|
|
"active": input.active,
|
|
"changed": changed,
|
|
"failed_devices": payload["failed"].as_array().map(Vec::len).unwrap_or(0),
|
|
"cleared_queues": cleared_queues,
|
|
"persistent_across_restart": true,
|
|
"off_replayed_on_restart": false,
|
|
}),
|
|
);
|
|
|
|
Ok(Json(payload))
|
|
}
|
|
|
|
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;
|
|
|
|
// Global power is a bulk thermostat action, not a persistent master gate. OFF stores every
|
|
// thermostat as an indefinite local OFF so the next regulator cycle cannot immediately
|
|
// resurrect demand. ON releases that OFF state and records an explicit automatic house-power
|
|
// intent for otherwise-free zones. Later explicit local/group/manual actions remain
|
|
// independent and can take over only the selected scope.
|
|
// Persist the thermostat power intent before touching devices. The cycle lock held by this
|
|
// handler guarantees that no setpoint-modulation cycle can race between the marker and OFF.
|
|
let changed_zones = set_all_thermostat_power_state(&state, input.power).await?;
|
|
let failed = command_all_enabled_devices_power(&state, input.power, "house_power_bulk").await?;
|
|
state.wake_zone_control();
|
|
|
|
let devices = state.db.list_devices()?;
|
|
let groups = state.db.list_groups()?;
|
|
state.log(
|
|
"info",
|
|
"house.power_all",
|
|
if input.power {
|
|
"Whole-house ON sent; local OFF state released and house thermostat intent armed"
|
|
} else {
|
|
"Whole-house OFF sent; all thermostats left locally OFF until explicitly re-enabled"
|
|
},
|
|
json!({
|
|
"power": input.power,
|
|
"failed": failed.len(),
|
|
"changed_zones": changed_zones,
|
|
"one_shot": true,
|
|
"persistent_global_gate": false,
|
|
}),
|
|
);
|
|
Ok(Json(json!({
|
|
"power": input.power,
|
|
"one_shot": true,
|
|
"devices": devices,
|
|
"groups": groups,
|
|
"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 house profile applies to free house-controlled zones. Explicit local/group/direct
|
|
// owners remain higher priority and are not cleared or re-armed by changing house rules.
|
|
rearm_house_automation_compressor_queues(&state).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;
|
|
};
|
|
let scoped_manual = zone.device_manual_override
|
|
|| zone.local_thermostat_power.is_some()
|
|
|| zone.control_source.starts_with("group:")
|
|
|| engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
|
|
if scoped_manual {
|
|
// House rules never overwrite explicit manual/group/local ownership. The scoped
|
|
// controller keeps its own target/profile until the user releases it.
|
|
zones.push(zone);
|
|
continue;
|
|
} 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,
|
|
"failed": failed.len(),
|
|
}),
|
|
);
|
|
Ok(Json(json!({
|
|
"preset": input.preset,
|
|
"zones": zones,
|
|
"devices": devices,
|
|
"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(),
|
|
flow_id: None,
|
|
flow_node_id: None,
|
|
});
|
|
};
|
|
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)
|
|
}
|