v0.8.19
This commit is contained in:
@@ -112,7 +112,13 @@ async fn get_automation(State(state): State<AppState>, Path(id): Path<String>) -
|
||||
state.db.get_automation(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("automation {id}")))
|
||||
}
|
||||
async fn create_automation(State(state): State<AppState>, Json(input): Json<AutomationInput>) -> Result<(StatusCode, Json<Automation>), AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
input.validate()?;
|
||||
let action_group_id = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string);
|
||||
let _group_guard = if let Some(group_id) = action_group_id.as_deref() {
|
||||
Some(state.lock_group_operation(group_id).await)
|
||||
} else { None };
|
||||
validate_automation_references(&state, &input)?;
|
||||
let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None);
|
||||
state.db.save_automation(&item)?;
|
||||
@@ -120,8 +126,14 @@ async fn create_automation(State(state): State<AppState>, Json(input): Json<Auto
|
||||
Ok((StatusCode::CREATED, Json(item)))
|
||||
}
|
||||
async fn update_automation(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<AutomationInput>) -> Result<Json<Automation>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
input.validate()?;
|
||||
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
||||
let action_group_id = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string);
|
||||
let _group_guard = if let Some(group_id) = action_group_id.as_deref() {
|
||||
Some(state.lock_group_operation(group_id).await)
|
||||
} else { None };
|
||||
validate_automation_references(&state, &input)?;
|
||||
let item = input.into_automation(id, existing.created_at, existing.last_fired_at);
|
||||
state.db.save_automation(&item)?;
|
||||
@@ -129,6 +141,8 @@ async fn update_automation(State(state): State<AppState>, Path(id): Path<String>
|
||||
Ok(Json(item))
|
||||
}
|
||||
async fn delete_automation(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;
|
||||
if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); }
|
||||
state.broadcast("automation.deleted", json!({"id": id}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
|
||||
@@ -3,6 +3,8 @@ async fn get_debug(State(state): State<AppState>) -> Json<DebugSettings> {
|
||||
}
|
||||
|
||||
async fn update_debug(State(state): State<AppState>, Json(input): Json<DebugSettings>) -> Result<Json<DebugSettings>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.debug = input.clone();
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
|
||||
+18
-1
@@ -1,4 +1,5 @@
|
||||
async fn discover(State(state): State<AppState>, Json(request): Json<DiscoveryRequest>) -> Result<Json<Value>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let settings = state.settings.read().await.clone();
|
||||
let timeout_ms = request.timeout_ms.unwrap_or(settings.discovery_timeout_ms).clamp(500, 30_000);
|
||||
let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast);
|
||||
@@ -50,6 +51,7 @@ async fn list_devices(State(state): State<AppState>) -> Result<Json<Vec<Device>>
|
||||
}
|
||||
|
||||
async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDeviceRequest>) -> Result<(StatusCode, Json<Device>), AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
if input.name.trim().is_empty() || input.mac.trim().is_empty() || input.ip.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("name, mac and ip are required".into()));
|
||||
}
|
||||
@@ -114,6 +116,7 @@ async fn get_device(State(state): State<AppState>, Path(id): Path<String>) -> Re
|
||||
}
|
||||
|
||||
async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<DevicePatch>) -> Result<Json<Device>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
if patch.enabled == Some(false) {
|
||||
engine::disable_device_safely(&state, &id).await?;
|
||||
}
|
||||
@@ -145,6 +148,12 @@ async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Jso
|
||||
}
|
||||
|
||||
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.
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _schedule_guard = state.lock_schedule_operation().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())
|
||||
@@ -156,16 +165,24 @@ async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) ->
|
||||
.filter(|zone| zone.device_id == id)
|
||||
.map(|zone| zone.id)
|
||||
.collect();
|
||||
let mut sorted_zone_ids: Vec<String> = removed_zone_ids.iter().cloned().collect();
|
||||
sorted_zone_ids.sort();
|
||||
let mut zone_guards = Vec::with_capacity(sorted_zone_ids.len());
|
||||
for zone_id in &sorted_zone_ids {
|
||||
zone_guards.push(state.lock_zone_operation(zone_id).await);
|
||||
}
|
||||
ensure_zone_removal_safe(&state, &removed_zone_ids)?;
|
||||
ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?;
|
||||
if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); }
|
||||
remove_zone_ids_from_groups(&state, &removed_zone_ids)?;
|
||||
drop(zone_guards);
|
||||
remove_zone_ids_from_groups_locked(&state, &removed_zone_ids).await?;
|
||||
state.log("info", "device.deleted", "Device deleted", json!({"device_id": id}));
|
||||
state.broadcast("device.deleted", json!({"id": id}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn bind_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _device_guard = state.lock_device_operation(&id).await;
|
||||
let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
|
||||
if device.simulated { return Ok(Json(device)); }
|
||||
|
||||
@@ -13,6 +13,8 @@ async fn get_event_retention(State(state): State<AppState>) -> Json<Value> {
|
||||
}
|
||||
|
||||
async fn update_event_retention(State(state): State<AppState>, Json(input): Json<EventRetentionInput>) -> Result<Json<Value>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.event_log_retention_days = input.days.clamp(1, 3650);
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
|
||||
+37
-3
@@ -42,6 +42,9 @@ async fn get_group(State(state): State<AppState>, Path(id): Path<String>) -> Res
|
||||
}
|
||||
|
||||
async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInput>) -> Result<(StatusCode, Json<ClimateGroup>), AppError> {
|
||||
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 zone_ids = validate_group_input(&state, &input)?;
|
||||
let now = Utc::now();
|
||||
let group = ClimateGroup {
|
||||
@@ -59,6 +62,12 @@ async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInpu
|
||||
}
|
||||
|
||||
async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<GroupInput>) -> Result<Json<ClimateGroup>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
// Membership changes alter the target set of group automations, so serialize them with
|
||||
// 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 _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)?;
|
||||
let group = ClimateGroup {
|
||||
@@ -76,6 +85,10 @@ async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Jso
|
||||
}
|
||||
|
||||
async fn delete_group(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 _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()));
|
||||
}
|
||||
@@ -99,9 +112,14 @@ fn ensure_zone_removal_safe(state: &AppState, zone_ids: &std::collections::HashS
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_zone_ids_from_groups(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
|
||||
async fn remove_zone_ids_from_groups_locked(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
|
||||
if zone_ids.is_empty() { return Ok(()); }
|
||||
for mut group in state.db.list_groups()? {
|
||||
let mut group_ids: Vec<String> = state.db.list_groups()?.into_iter().map(|group| group.id).collect();
|
||||
group_ids.sort();
|
||||
group_ids.dedup();
|
||||
for group_id in group_ids {
|
||||
let _group_guard = state.lock_group_operation(&group_id).await;
|
||||
let Some(mut group) = state.db.get_group(&group_id)? else { continue; };
|
||||
let before = group.zone_ids.len();
|
||||
group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id));
|
||||
if group.zone_ids.len() == before { continue; }
|
||||
@@ -141,7 +159,7 @@ fn home_assistant_group_preset(zones: &[&Zone]) -> String {
|
||||
let mut value: Option<&str> = None;
|
||||
for zone in zones {
|
||||
let current = zone.manual_preset.as_deref().unwrap_or("auto");
|
||||
if !matches!(current, "auto" | "comfort" | "sleep" | "away") {
|
||||
if !matches!(current, "auto" | "comfort" | "sleep" | "away" | "custom") {
|
||||
return "mixed".into();
|
||||
}
|
||||
if let Some(previous) = value {
|
||||
@@ -153,6 +171,21 @@ fn home_assistant_group_preset(zones: &[&Zone]) -> String {
|
||||
value.unwrap_or("mixed").to_string()
|
||||
}
|
||||
|
||||
|
||||
fn home_assistant_group_custom_setpoint(zones: &[&Zone]) -> Option<f64> {
|
||||
let mut value: Option<f64> = None;
|
||||
for zone in zones {
|
||||
if zone.manual_preset.as_deref() != Some("custom") { return None; }
|
||||
let current = zone.manual_setpoint.or(zone.effective_setpoint)?;
|
||||
if let Some(previous) = value {
|
||||
if (previous - current).abs() > 0.05 { return None; }
|
||||
} else {
|
||||
value = Some(current);
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Json<Vec<Value>>, AppError> {
|
||||
let groups = state.db.list_groups()?;
|
||||
let zones = state.db.list_zones()?;
|
||||
@@ -223,6 +256,7 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
|
||||
"effective_power": settings.house_power_enabled && group.power_enabled,
|
||||
"mode": home_assistant_group_mode(&members),
|
||||
"preset": home_assistant_group_preset(&members),
|
||||
"custom_setpoint": home_assistant_group_custom_setpoint(&members),
|
||||
"house_mode": settings.house_mode,
|
||||
"zone_count": members.len(),
|
||||
"enabled_zones": members.iter().filter(|zone| zone.enabled).count(),
|
||||
|
||||
+60
-11
@@ -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)
|
||||
}
|
||||
|
||||
+18
-5
@@ -49,7 +49,14 @@ fn validate_schedule_conflicts(state: &AppState, item: &Schedule, exclude_id: Op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(), AppError> {
|
||||
async fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(), AppError> {
|
||||
let _zone_guard = state.lock_zone_operation(zone_id).await;
|
||||
let device_id = state.db.get_zone(zone_id)?.map(|zone| zone.device_id);
|
||||
let _device_guard = if let Some(device_id) = device_id.as_deref() {
|
||||
Some(state.lock_device_operation(device_id).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); };
|
||||
let has_temporary_schedule_boundary = zone.temporary_quick_thermostat.as_ref()
|
||||
.map(|session| session.finish_kind == "schedule_boundary")
|
||||
@@ -87,17 +94,21 @@ async fn get_schedule(State(state): State<AppState>, Path(id): Path<String>) ->
|
||||
state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}")))
|
||||
}
|
||||
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;
|
||||
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());
|
||||
validate_schedule_conflicts(&state, &item, None)?;
|
||||
state.db.save_schedule(&item)?;
|
||||
refresh_zone_override_boundary(&state, &item.zone_id)?;
|
||||
refresh_zone_override_boundary(&state, &item.zone_id).await?;
|
||||
state.broadcast("schedule.created", serde_json::to_value(&item)?);
|
||||
state.wake_zone_control();
|
||||
Ok((StatusCode::CREATED, Json(item)))
|
||||
}
|
||||
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;
|
||||
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())); }
|
||||
@@ -105,16 +116,18 @@ async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>,
|
||||
let item = input.into_schedule(id.clone(), existing.created_at);
|
||||
validate_schedule_conflicts(&state, &item, Some(&id))?;
|
||||
state.db.save_schedule(&item)?;
|
||||
refresh_zone_override_boundary(&state, &old_zone_id)?;
|
||||
if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id)?; }
|
||||
refresh_zone_override_boundary(&state, &old_zone_id).await?;
|
||||
if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id).await?; }
|
||||
state.broadcast("schedule.updated", serde_json::to_value(&item)?);
|
||||
state.wake_zone_control();
|
||||
Ok(Json(item))
|
||||
}
|
||||
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 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)?;
|
||||
refresh_zone_override_boundary(&state, &existing.zone_id).await?;
|
||||
state.broadcast("schedule.deleted", json!({"id": id}));
|
||||
state.wake_zone_control();
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
|
||||
+54
-8
@@ -4,6 +4,8 @@ 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 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(
|
||||
@@ -46,7 +48,7 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
|
||||
if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); }
|
||||
}
|
||||
state.db.save_runtime_settings(&input)?;
|
||||
canonicalize_saved_zone_entities(&state, &input)?;
|
||||
canonicalize_saved_zone_entities(&state, &input).await?;
|
||||
state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed);
|
||||
*state.settings.write().await = input.clone();
|
||||
state.log("info", "settings.updated", "Settings updated", json!({}));
|
||||
@@ -84,8 +86,15 @@ fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &RuntimeSettings) {
|
||||
zone.ha_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&configured));
|
||||
}
|
||||
|
||||
fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> Result<(), AppError> {
|
||||
for mut zone in state.db.list_zones()? {
|
||||
async fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> 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(snapshot) = state.db.get_zone(&zone_id)? else { continue; };
|
||||
let _device_guard = state.lock_device_operation(&snapshot.device_id).await;
|
||||
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; };
|
||||
let previous = zone.ha_entity_id.clone();
|
||||
canonicalize_zone_ha_entity(&mut zone, settings);
|
||||
if zone.ha_entity_id != previous {
|
||||
@@ -314,6 +323,36 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
|
||||
validate_night_mode(&mut export.settings)?;
|
||||
export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650);
|
||||
|
||||
// Configuration replacement is the broadest mutation in the application. Serialize it
|
||||
// against every structural editor and every live owner that can write zone/device state.
|
||||
// Global lock order: configuration -> automation -> house -> schedule -> zones -> devices.
|
||||
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 current_zones = state.db.list_zones()?;
|
||||
let current_devices = state.db.list_devices()?;
|
||||
let mut locked_zone_ids: Vec<String> = current_zones.iter().map(|zone| zone.id.clone())
|
||||
.chain(export.zones.iter().map(|zone| zone.id.clone()))
|
||||
.collect();
|
||||
locked_zone_ids.sort();
|
||||
locked_zone_ids.dedup();
|
||||
let mut _zone_guards = Vec::with_capacity(locked_zone_ids.len());
|
||||
for zone_id in &locked_zone_ids {
|
||||
_zone_guards.push(state.lock_zone_operation(zone_id).await);
|
||||
}
|
||||
|
||||
let mut locked_device_ids: Vec<String> = current_devices.iter().map(|device| device.id.clone())
|
||||
.chain(export.devices.iter().map(|device| device.id.clone()))
|
||||
.collect();
|
||||
locked_device_ids.sort();
|
||||
locked_device_ids.dedup();
|
||||
let mut _device_guards = Vec::with_capacity(locked_device_ids.len());
|
||||
for device_id in &locked_device_ids {
|
||||
_device_guards.push(state.lock_device_operation(device_id).await);
|
||||
}
|
||||
|
||||
// Before replacing ownership, safely stop every currently managed device whose zone is
|
||||
// removed or rewired by the imported configuration. Otherwise an orphaned physical unit
|
||||
// could keep running after its database owner disappears.
|
||||
@@ -321,13 +360,20 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
|
||||
.map(|zone| (zone.id.clone(), zone.device_id.clone()))
|
||||
.collect();
|
||||
let mut detach_devices = std::collections::HashSet::new();
|
||||
for current in state.db.list_zones()? {
|
||||
for current in ¤t_zones {
|
||||
if imported_zone_map.get(¤t.id).map(String::as_str) != Some(current.device_id.as_str()) {
|
||||
detach_devices.insert(current.device_id);
|
||||
detach_devices.insert(current.device_id.clone());
|
||||
}
|
||||
}
|
||||
for device_id in detach_devices {
|
||||
ensure_device_stopped_for_detach(&state, &device_id, "configuration.import").await?;
|
||||
let Some(device) = state.db.get_device(&device_id)? else { continue; };
|
||||
if !device.enabled {
|
||||
return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into()));
|
||||
}
|
||||
engine::force_power_off_device_locked(&state, &device_id).await?;
|
||||
state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({
|
||||
"device_id": device.id, "source": "configuration.import"
|
||||
}));
|
||||
}
|
||||
|
||||
// Configuration import never restores ephemeral owners/timers or cached physical state.
|
||||
@@ -353,7 +399,7 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
|
||||
.map(|zone| zone.device_id.clone())
|
||||
.collect();
|
||||
for device in export.devices.iter().filter(|device| device.enabled && !controllable_devices.contains(&device.id)) {
|
||||
if let Err(err) = engine::force_power_off_device(&state, &device.id).await {
|
||||
if let Err(err) = engine::force_power_off_device_locked(&state, &device.id).await {
|
||||
state.log("error", "settings.import_reconcile_error", &err.to_string(), json!({"device_id": device.id}));
|
||||
return Err(err);
|
||||
}
|
||||
@@ -361,7 +407,7 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
|
||||
// Rebuild live device snapshots before allowing the thermostat loop to make decisions.
|
||||
// Network failures are represented in device health by poll_one rather than reviving
|
||||
// imported cache values.
|
||||
engine::poll_all(&state).await?;
|
||||
engine::poll_all_locked(&state).await?;
|
||||
state.initial_device_sync_complete.store(true, Ordering::Release);
|
||||
state.wake_zone_control();
|
||||
state.log("info", "settings.imported", "Application configuration imported", json!({"format_version": export.format_version}));
|
||||
|
||||
+30
-8
@@ -128,9 +128,15 @@ async fn get_zone(State(state): State<AppState>, Path(id): Path<String>) -> Resu
|
||||
state.db.get_zone(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("zone {id}")))
|
||||
}
|
||||
async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>) -> Result<(StatusCode, Json<Zone>), AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _reference_guard = state.lock_automation_operation().await;
|
||||
input.validate()?;
|
||||
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
|
||||
validate_zone_device_assignment(&state, &input.device_id, None)?;
|
||||
// Creating thermostat ownership must not overlap a poll of the device. Otherwise a poll
|
||||
// that started before the zone existed could apply its old physical-control snapshot to
|
||||
// the newly created zone without participating in the zone operation lock.
|
||||
let _device_guard = state.lock_device_operation(&input.device_id).await;
|
||||
let mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now());
|
||||
let settings = state.settings.read().await.clone();
|
||||
canonicalize_zone_ha_entity(&mut zone, &settings);
|
||||
@@ -139,6 +145,8 @@ async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>
|
||||
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> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _reference_guard = state.lock_automation_operation().await;
|
||||
input.validate()?;
|
||||
let _zone_guard = state.lock_zone_operation(&id).await;
|
||||
let mut existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||
@@ -150,10 +158,16 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
||||
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
|
||||
validate_zone_device_assignment(&state, &input.device_id, Some(&id))?;
|
||||
let device_changed = existing.device_id != input.device_id;
|
||||
// Serialize a normal zone edit with polling/manual-takeover detection for its device.
|
||||
// Device reassignment uses ensure_device_stopped_for_detach below, which acquires the
|
||||
// old device lock itself while this zone lock is held.
|
||||
let _device_guard = if !device_changed { Some(state.lock_device_operation(&existing.device_id).await) } else { None };
|
||||
// Keep the zone lock while taking all involved device locks in stable order. This makes a
|
||||
// reassignment atomic against polling of both the old and the new unit and preserves the
|
||||
// global zone -> device ordering used by live control paths.
|
||||
let mut locked_device_ids = vec![existing.device_id.clone(), input.device_id.clone()];
|
||||
locked_device_ids.sort();
|
||||
locked_device_ids.dedup();
|
||||
let mut device_guards = Vec::with_capacity(locked_device_ids.len());
|
||||
for device_id in &locked_device_ids {
|
||||
device_guards.push(state.lock_device_operation(device_id).await);
|
||||
}
|
||||
if !device_changed {
|
||||
// Polling may have updated takeover/runtime state while we were waiting for the
|
||||
// device lock. Re-read under both locks before building the replacement Zone.
|
||||
@@ -203,7 +217,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
||||
} else {
|
||||
// A new physical unit starts with a clean ownership/runtime state. Never transfer
|
||||
// demand, sensor cache or remote-control takeover from the previous device.
|
||||
ensure_device_stopped_for_detach(&state, &existing.device_id, "zone.device_reassigned").await?;
|
||||
ensure_device_stopped_for_detach_locked(&state, &existing.device_id, "zone.device_reassigned").await?;
|
||||
zone.revision = existing.revision.saturating_add(1);
|
||||
}
|
||||
let settings = state.settings.read().await.clone();
|
||||
@@ -223,7 +237,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
||||
}
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
drop(_device_guard);
|
||||
drop(device_guards);
|
||||
if power_off_device {
|
||||
power_off_zone_device(&state, &zone, "zone.disabled").await;
|
||||
}
|
||||
@@ -473,6 +487,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|
||||
} else { None },
|
||||
deferred_mode: existing_session.as_ref().and_then(|session| session.deferred_mode.clone()),
|
||||
deferred_preset: existing_session.as_ref().and_then(|session| session.deferred_preset.clone()),
|
||||
deferred_setpoint: existing_session.as_ref().and_then(|session| session.deferred_setpoint),
|
||||
safety_expires_at: if immediate_activation && is_temperature_condition {
|
||||
safety_duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64))
|
||||
} else { safety_expires_at },
|
||||
@@ -546,6 +561,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|
||||
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
session.deferred_preset = Some(value.to_string());
|
||||
if value != "custom" { session.deferred_setpoint = None; }
|
||||
}
|
||||
} else {
|
||||
match value {
|
||||
@@ -571,6 +587,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|
||||
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
session.deferred_preset = Some("auto".into());
|
||||
session.deferred_setpoint = None;
|
||||
}
|
||||
} else {
|
||||
zone.manual_preset = None;
|
||||
@@ -639,20 +656,25 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
|
||||
|
||||
|
||||
|
||||
async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> {
|
||||
async fn ensure_device_stopped_for_detach_locked(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> {
|
||||
let Some(device) = state.db.get_device(device_id)? else { return Ok(()); };
|
||||
if !device.enabled {
|
||||
return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into()));
|
||||
}
|
||||
// Force one OFF transition even when the cached state already says OFF. A remote change
|
||||
// may not have been polled yet and detaching must not leave a running unit without owner.
|
||||
engine::force_power_off_device(state, device_id).await?;
|
||||
engine::force_power_off_device_locked(state, device_id).await?;
|
||||
state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({
|
||||
"device_id": device.id, "source": source
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> {
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
ensure_device_stopped_for_detach_locked(state, device_id, source).await
|
||||
}
|
||||
|
||||
async fn power_off_zone_device(state: &AppState, zone: &Zone, source: &str) {
|
||||
let Ok(Some(device)) = state.db.get_device(&zone.device_id) else { return; };
|
||||
if !device.enabled { return; }
|
||||
|
||||
Reference in New Issue
Block a user