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
+14
View File
@@ -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)
+2
View File
@@ -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
View File
@@ -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)); }
+2
View File
@@ -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
View File
@@ -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
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)
}
+18 -5
View File
@@ -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
View File
@@ -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 &current_zones {
if imported_zone_map.get(&current.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
View File
@@ -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; }
+16 -3
View File
@@ -1,8 +1,6 @@
async fn run_automations(state: &AppState) -> Result<()> {
if !state.settings.read().await.house_power_enabled { return Ok(()); }
let devices = state.db.list_devices()?;
let zones = state.db.list_zones()?;
let groups = state.db.list_groups()?;
let mut automations = state.db.list_automations()?;
// Stable arbitration for same-cycle conflicts: the oldest configured rule wins, then ID.
// This avoids database row order deciding the physical outcome (M2).
@@ -20,6 +18,20 @@ async fn run_automations(state: &AppState) -> Result<()> {
_ => false,
};
if !should_fire { continue; }
// API edits/deletes and execution share one short ownership window. If the rule
// changed since this cycle snapshot was taken, skip it now and evaluate the new
// definition on the next cycle instead of firing stale configuration.
let _automation_guard = state.lock_automation_operation().await;
let Some(latest_item) = state.db.get_automation(&item.id)? else { continue; };
if latest_item.updated_at != item.updated_at { continue; }
item = latest_item;
// Group membership and zone ownership may have changed after the cycle snapshot but
// before we acquired the automation lock. Reload them inside this serialized window so
// same-cycle conflict arbitration claims the actual current target set.
let zones = state.db.list_zones()?;
let groups = state.db.list_groups()?;
if item.action_group_id.is_none()
&& device_blocked_by_disabled_zone(&item.action_device_id, &zones)
&& item.action.power != Some(true)
@@ -75,7 +87,8 @@ async fn run_automations(state: &AppState) -> Result<()> {
power: item.action.power,
mode: group_mode,
preset: item.action_preset.clone(),
}, "automation.group").await.map(|_| true)
setpoint: None,
}, "automation.group").await.map(|value| !value.get("suppressed").and_then(Value::as_bool).unwrap_or(false))
} else {
match apply_automatic_device_action(state, &item.action_device_id, item.action.clone()).await {
Ok(Some(_)) => Ok(true),
+55 -8
View File
@@ -5,15 +5,52 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
}
}
if let Some(preset) = patch.preset.as_deref() {
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
return Err(AppError::BadRequest("group preset must be auto, comfort, sleep or away".into()));
if !matches!(preset, "auto" | "comfort" | "sleep" | "away" | "custom") {
return Err(AppError::BadRequest("group preset must be auto, comfort, sleep, away or custom".into()));
}
}
if patch.preset.as_deref() == Some("custom") && patch.setpoint.is_none() {
return Err(AppError::BadRequest("group custom preset requires a setpoint".into()));
}
if let Some(setpoint) = patch.setpoint {
if !(8.0..=30.0).contains(&setpoint) {
return Err(AppError::BadRequest("group setpoint must be between 8 and 30 C".into()));
}
if patch.preset.as_deref() != Some("custom") {
return Err(AppError::BadRequest("group setpoint requires preset=custom".into()));
}
}
// House/group actions share one ordering domain. This prevents a concurrent group ON
// (especially from an automation) from resurrecting the master while whole-house OFF
// is being applied. Group and zone locks then make the member update atomic.
let _house_guard = state.lock_house_operation().await;
let _group_guard = state.lock_group_operation(group_id).await;
let mut group = state.db.get_group(group_id)?
.ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?;
if source == "automation.group" && !state.settings.read().await.house_power_enabled {
state.log("info", "automation.blocked_by_house_power", &format!("Group automation suppressed while whole-house power is off for {}", group.name), json!({
"group_id": group.id, "source": source
}));
return Ok(json!({
"group": group,
"zones": [],
"devices": state.db.list_devices()?,
"failed": [],
"master_power_enabled": false,
"suppressed": true,
}));
}
let mut locked_zone_ids = group.zone_ids.clone();
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 schedules = state.db.list_schedules()?;
let climate_change = patch.mode.is_some() || patch.preset.is_some();
let custom_setpoint = patch.setpoint.map(|value| (value * 10.0).round() / 10.0);
let climate_change = patch.mode.is_some() || patch.preset.is_some() || custom_setpoint.is_some();
if let Some(power) = patch.power {
group.power_enabled = power;
}
@@ -24,7 +61,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
// Explicit group ON is a conscious request to run this group. Resume the global
// master without changing the gates of any other groups. This makes group ON work
// even after a previous whole-house OFF while preserving multi-group OFF priority.
if patch.power == Some(true) {
if patch.power == Some(true) && source != "automation.group" {
let mut settings = state.settings.write().await;
if !settings.house_power_enabled {
settings.house_power_enabled = true;
@@ -38,7 +75,6 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
let mut zones = Vec::new();
for zone_id in &group.zone_ids {
let _zone_guard = state.lock_zone_operation(zone_id).await;
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; };
@@ -46,7 +82,11 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
if temporary_owns_zone {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
if let Some(mode) = patch.mode.as_deref() { session.deferred_mode = Some(mode.to_string()); }
if let Some(preset) = patch.preset.as_deref() { session.deferred_preset = Some(preset.to_string()); }
if let Some(preset) = patch.preset.as_deref() {
session.deferred_preset = Some(preset.to_string());
if preset != "custom" { session.deferred_setpoint = None; }
}
if let Some(setpoint) = custom_setpoint { session.deferred_setpoint = Some(setpoint); }
}
if climate_change {
state.log("info", "group.control_deferred_by_temporary_thermostat", &format!("Group climate change deferred for {} while Temporary Quick Thermostat owns the zone", zone.name), json!({
@@ -71,10 +111,17 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
zone.manual_override_until = None;
} else {
zone.manual_preset = Some(preset.to_string());
zone.manual_setpoint = None;
if preset != "custom" { zone.manual_setpoint = None; }
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
}
}
if let Some(setpoint) = custom_setpoint {
zone.setpoint = setpoint;
zone.manual_preset = Some("custom".into());
zone.manual_setpoint = Some(setpoint);
zone.effective_setpoint = Some(setpoint);
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
}
}
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
@@ -115,7 +162,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
state.wake_zone_control();
}
state.log("info", source, &format!("Updated group {}", group.name), json!({
"group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset,
"group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset, "setpoint": custom_setpoint,
"zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled,
}));
Ok(json!({
+9 -1
View File
@@ -233,7 +233,9 @@ async fn detect_external_device_control(state: &AppState, before: &Device, after
pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result<Device, AppError> {
// Keep zone -> device lock ordering consistent with Quick Thermostat/full-zone edits.
// A device belongs to at most one thermostat zone, but keep this generic for legacy data.
let zone_ids: Vec<String> = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).map(|zone| zone.id).collect();
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).map(|zone| zone.id).collect();
zone_ids.sort();
zone_ids.dedup();
let mut _zone_guards = Vec::new();
for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); }
// Keep the device lock until the zone takeover marker is persisted. Otherwise a poll
@@ -282,6 +284,12 @@ pub async fn force_power_off_device(state: &AppState, device_id: &str) -> Result
send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await
}
/// Same safety transition for callers that already hold the per-device operation lock.
/// Keeping this separate avoids recursive lock acquisition during atomic configuration import.
pub async fn force_power_off_device_locked(state: &AppState, device_id: &str) -> Result<Device, AppError> {
send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await
}
/// Technical device disable is a safety transition, not just a database flag. The unit is
/// explicitly powered off while it is still commandable, then removed from controller polling.
pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result<Device, AppError> {
+33
View File
@@ -1,8 +1,27 @@
async fn lock_poll_zone_operations(state: &AppState, device_id: &str) -> Result<Vec<tokio::sync::OwnedMutexGuard<()>>, AppError> {
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter()
.filter(|zone| zone.device_id == device_id)
.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);
}
Ok(guards)
}
pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppError> {
// Polling can update zone ownership when it detects physical/pilot control. Acquire
// the same zone -> device lock order used by thermostat/manual actions so those writes
// cannot race and overwrite a fresh override or hand-back state.
let _zone_guards = lock_poll_zone_operations(state, device_id).await?;
let _device_guard = state.lock_device_operation(device_id).await;
poll_one_locked(state, device_id).await
}
// Caller must hold the device lock and every current zone lock associated with this device.
async fn poll_one_locked(state: &AppState, device_id: &str) -> Result<Device, AppError> {
let mut device = state.db.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
@@ -26,12 +45,26 @@ pub(crate) async fn poll_all(state: &AppState) -> Result<()> {
.map(|device| device.id)
.collect();
for device_id in device_ids {
let _zone_guards = lock_poll_zone_operations(state, &device_id).await?;
let _device_guard = state.lock_device_operation(&device_id).await;
let _ = poll_one_locked(state, &device_id).await?;
}
Ok(())
}
/// Poll all enabled devices when the caller already holds the corresponding zone and device locks.
/// Used by configuration import so no command/poll can interleave with the replacement.
pub(crate) async fn poll_all_locked(state: &AppState) -> Result<()> {
let device_ids: Vec<String> = state.db.list_devices()?.into_iter()
.filter(|device| device.enabled)
.map(|device| device.id)
.collect();
for device_id in device_ids {
let _ = poll_one_locked(state, &device_id).await?;
}
Ok(())
}
async fn poll_device(state: &AppState, device: &mut Device) {
if device.simulated {
simulate_tick(device);
+19 -1
View File
@@ -73,8 +73,20 @@ pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule]
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
} else if matches!(preset, "comfort" | "sleep" | "away") {
} else if matches!(preset, "comfort" | "sleep" | "away" | "custom") {
zone.manual_preset = Some(preset.to_string());
if preset != "custom" { zone.manual_setpoint = None; }
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now());
}
}
// A deferred custom temperature is valid only while the final deferred preset is custom.
// This makes the hand-back robust even if an older persisted session contains a stale
// deferred_setpoint from a previously selected Custom action.
if session.deferred_preset.as_deref() == Some("custom") {
if let Some(setpoint) = session.deferred_setpoint {
zone.setpoint = setpoint;
zone.manual_preset = Some("custom".into());
zone.manual_setpoint = Some(setpoint);
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now());
}
}
@@ -88,6 +100,8 @@ async fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone]
for zone in zones.iter_mut() {
let zone_id = zone.id.clone();
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(latest) = state.db.get_zone(&zone_id)? else { continue; };
*zone = latest;
let active_under_manual = zone.device_manual_override
@@ -153,6 +167,8 @@ async fn activate_due_temporary_quick_thermostats(
for zone in zones.iter_mut() {
let zone_id = zone.id.clone();
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(latest) = state.db.get_zone(&zone_id)? else { continue; };
*zone = latest;
let Some((session_state, started_at)) = zone.temporary_quick_thermostat.as_ref()
@@ -330,6 +346,8 @@ async fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone],
for zone in zones.iter_mut() {
let zone_id = zone.id.clone();
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(latest) = state.db.get_zone(&zone_id)? else { continue; };
*zone = latest;
// A direct device/pilot takeover has higher priority than the local-OFF hand-back.
+15
View File
@@ -233,6 +233,7 @@ mod tests {
paused_at: None,
deferred_mode: None,
deferred_preset: None,
deferred_setpoint: None,
safety_expires_at: None,
}
}
@@ -309,6 +310,20 @@ mod tests {
assert_eq!(zone.manual_setpoint, Some(22.0));
}
#[test]
fn deferred_non_custom_preset_wins_over_stale_custom_setpoint() {
let now = Utc::now();
let mut zone = test_zone("device");
let mut session = temporary_session(now);
session.deferred_preset = Some("comfort".into());
session.deferred_setpoint = Some(27.0);
zone.temporary_quick_thermostat = Some(session);
assert!(finish_temporary_quick_thermostat(&mut zone, &[], "cool"));
assert_eq!(zone.manual_preset.as_deref(), Some("comfort"));
assert!(zone.manual_setpoint.is_none());
}
#[test]
fn temporary_session_without_activation_marker_is_never_active() {
let now = Utc::now();
+10 -1
View File
@@ -123,6 +123,7 @@ async fn apply_automatic_device_action(
};
let _zone_guard = state.lock_zone_operation(&zone_id).await;
let _device_guard = state.lock_device_operation(device_id).await;
let mut zone = state.db.get_zone(&zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?;
let settings = state.settings.read().await.clone();
if !settings.house_power_enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() {
@@ -173,7 +174,14 @@ async fn apply_automatic_device_action(
}
if command.power == Some(false) {
return force_power_off_device(state, device_id).await.map(Some);
// We already hold the device lock and have re-checked ownership under it. Keep that
// lock through the physical OFF so a poll/manual-takeover update cannot slip between
// the durable zone transition and the device command.
return send_command_locked_forced(
state,
device_id,
DeviceCommand { power: Some(false), ..Default::default() },
).await.map(Some);
}
// Climate fields above are durable zone state. Only non-climate device capabilities remain
@@ -196,6 +204,7 @@ async fn apply_automatic_device_action(
if residual.is_empty() {
return state.db.get_device(device_id)?.map(Some).ok_or_else(|| AppError::NotFound(format!("device {device_id}")));
}
drop(_device_guard);
send_automatic_device_command_if_owned(state, device_id, residual).await
}
+5
View File
@@ -70,6 +70,11 @@ async fn main() -> Result<()> {
zone_control_wakeup: Arc::new(Notify::new()),
device_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
zone_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
group_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
schedule_operation_lock: Arc::new(tokio::sync::Mutex::new(())),
automation_operation_lock: Arc::new(tokio::sync::Mutex::new(())),
house_operation_lock: Arc::new(tokio::sync::Mutex::new(())),
configuration_operation_lock: Arc::new(tokio::sync::Mutex::new(())),
pending_controller_commands: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
started: Instant::now(),
};
+3
View File
@@ -74,6 +74,9 @@ pub struct TemporaryQuickThermostat {
pub deferred_mode: Option<String>,
#[serde(default)]
pub deferred_preset: Option<String>,
/// Group custom target received while the temporary thermostat owns the zone.
#[serde(default)]
pub deferred_setpoint: Option<f64>,
/// Optional fail-safe for temperature-based modes.
#[serde(default)]
pub safety_expires_at: Option<DateTime<Utc>>,
+4 -1
View File
@@ -193,9 +193,12 @@ pub struct GroupControlPatch {
/// house follows the global house mode; cool/heat set an explicit mode on every member zone.
#[serde(default)]
pub mode: Option<String>,
/// auto clears temporary overrides; comfort/sleep/away apply a temporary preset to every member zone.
/// auto clears temporary overrides; comfort/sleep/away/custom apply a temporary preset to every member zone.
#[serde(default)]
pub preset: Option<String>,
/// Optional custom target temperature for the whole group. Used together with preset=custom.
#[serde(default)]
pub setpoint: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+29
View File
@@ -32,6 +32,11 @@ pub struct AppState {
pub zone_control_wakeup: Arc<Notify>,
pub(crate) device_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
pub(crate) zone_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
pub(crate) group_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
pub(crate) schedule_operation_lock: Arc<Mutex<()>>,
pub(crate) automation_operation_lock: Arc<Mutex<()>>,
pub(crate) house_operation_lock: Arc<Mutex<()>>,
pub(crate) configuration_operation_lock: Arc<Mutex<()>>,
/// Short-lived expected climate state from controller-originated commands. It prevents
/// a delayed GREE status update from being mistaken for remote/manual takeover.
pub(crate) pending_controller_commands: Arc<Mutex<HashMap<String, PendingControllerCommand>>>,
@@ -55,6 +60,30 @@ impl AppState {
lock.lock_owned().await
}
pub async fn lock_group_operation(&self, group_id: &str) -> OwnedMutexGuard<()> {
let lock = {
let mut locks = self.group_operation_locks.lock().await;
locks.entry(group_id.to_string()).or_insert_with(|| Arc::new(Mutex::new(()))).clone()
};
lock.lock_owned().await
}
pub async fn lock_schedule_operation(&self) -> OwnedMutexGuard<()> {
self.schedule_operation_lock.clone().lock_owned().await
}
pub async fn lock_automation_operation(&self) -> OwnedMutexGuard<()> {
self.automation_operation_lock.clone().lock_owned().await
}
pub async fn lock_house_operation(&self) -> OwnedMutexGuard<()> {
self.house_operation_lock.clone().lock_owned().await
}
pub async fn lock_configuration_operation(&self) -> OwnedMutexGuard<()> {
self.configuration_operation_lock.clone().lock_owned().await
}
pub fn wake_zone_control(&self) {
self.zone_control_wakeup.notify_one();
}