This commit is contained in:
Mateusz Gruszczyński
2026-08-28 12:56:36 +02:00
parent 556a031358
commit 615b2836b7
12 changed files with 1279 additions and 331 deletions
+416 -116
View File
@@ -363,6 +363,9 @@ 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> {
if patch.enabled == Some(false) {
engine::disable_device_safely(&state, &id).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 let Some(v) = patch.name { if !v.trim().is_empty() { device.name = v.trim().to_string(); } }
@@ -540,7 +543,7 @@ impl ZoneInput {
sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()),
external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference, sensor_stale_after_seconds: self.sensor_stale_after_seconds,
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, temporary_quick_thermostat: None,
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, local_thermostat_restore_zone_enabled: None, temporary_quick_thermostat: None,
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None,
revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "zone created".into(),
last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None,
@@ -611,6 +614,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
zone.manual_override_until = existing.manual_override_until;
zone.local_thermostat_power = existing.local_thermostat_power;
zone.local_thermostat_resume_at = existing.local_thermostat_resume_at;
zone.local_thermostat_restore_zone_enabled = existing.local_thermostat_restore_zone_enabled;
zone.temporary_quick_thermostat = existing.temporary_quick_thermostat;
zone.device_manual_override = existing.device_manual_override;
zone.device_manual_override_since = existing.device_manual_override_since;
@@ -643,6 +647,18 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
let settings = state.settings.read().await.clone();
canonicalize_zone_ha_entity(&mut zone, &settings);
let power_off_device = !device_changed && existing.enabled && !zone.enabled;
if power_off_device {
// Full configuration PUT and quick-control disable use the same ownership cleanup.
// No local/temporary/manual takeover survives a disabled thermostat zone (H6).
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
engine::finish_temporary_quick_thermostat(&mut zone, &state.db.list_schedules()?, &settings.house_mode);
} else {
zone.temporary_quick_thermostat = None;
}
engine::reset_local_thermostat_override(&mut zone);
engine::reset_device_manual_override(&mut zone);
zone.enabled = false;
}
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
drop(_device_guard);
@@ -668,11 +684,15 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
let resume_device_takeover = patch.clear_device_manual_override.unwrap_or(false);
let resume_local_thermostat = patch.clear_local_thermostat_override.unwrap_or(false);
let stop_temporary_quick_thermostat = patch.clear_temporary_quick_thermostat.unwrap_or(false);
// Any thermostat action takes ownership back from a physical/pilot takeover. Local power
// is a thermostat state of its own and must never be recorded as device manual control.
if patch.temporary_quick_thermostat.is_some() && (patch.power.is_some() || stop_temporary_quick_thermostat || resume_local_thermostat) {
return Err(AppError::BadRequest("temporary thermostat cannot be combined with local power/clear operations in one request".into()));
}
// Direct/manual device takeover is higher priority than a temporary thermostat. Creating
// or editing a temporary session therefore never clears an active pilot/Devices takeover;
// the session waits/pauses instead. Other explicit thermostat actions still resume control.
let resume_device_automation = resume_device_takeover
|| patch.power.is_some() || patch.setpoint.is_some() || patch.mode.is_some()
|| patch.preset.is_some() || patch.enabled.is_some() || patch.temporary_quick_thermostat.is_some();
|| patch.preset.is_some() || patch.enabled.is_some();
if resume_device_automation || resume_local_thermostat || stop_temporary_quick_thermostat || patch.temporary_quick_thermostat.is_some() {
zone.control_source = if source.contains("home_assistant") { "home_assistant_thermostat".into() } else { "web_thermostat".into() };
}
@@ -681,8 +701,8 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
engine::reset_local_thermostat_override(&mut zone);
}
if stop_temporary_quick_thermostat && zone.temporary_quick_thermostat.is_some() {
if zone.local_thermostat_power == Some(true) {
engine::reset_local_thermostat_override(&mut zone);
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode);
} else {
// Cancelling a delayed session before it starts must not erase unrelated
// quick preset/setpoint state that automation may be using in the meantime.
@@ -691,27 +711,45 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
}
if let Some(request) = patch.temporary_quick_thermostat.as_ref() {
let now = Utc::now();
let start_kind = request.start_kind.as_str();
if !matches!(start_kind, "now" | "delay" | "at") {
let runtime = state.settings.read().await.clone();
let existing_session = zone.temporary_quick_thermostat.clone();
let editing_active = engine::temporary_quick_thermostat_is_active(&zone, now);
let requested_start_kind = request.start_kind.as_str();
if !matches!(requested_start_kind, "now" | "delay" | "at") {
return Err(AppError::BadRequest("unsupported temporary thermostat start kind".into()));
}
let started_at = match start_kind {
"now" => now.clone(),
"delay" => {
let minutes = request.start_delay_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat start delay is required".into()))?;
if !(1..=43_200).contains(&minutes) {
return Err(AppError::BadRequest("temporary thermostat start delay must be between 1 minute and 30 days".into()));
// Editing an already active session changes only target/finish rules. Its historical
// start and activated_at are preserved, so delay/at sessions cannot be accidentally
// rescheduled or rejected because their original start is now in the past.
let (start_kind, started_at, activated_at) = if editing_active {
let existing = existing_session.as_ref().expect("active temporary session must exist");
(existing.start_kind.clone(), existing.started_at.clone(), existing.activated_at.clone())
} else {
let started_at = match requested_start_kind {
"now" => now.clone(),
"delay" => {
let minutes = request.start_delay_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat start delay is required".into()))?;
if !(1..=43_200).contains(&minutes) {
return Err(AppError::BadRequest("temporary thermostat start delay must be between 1 minute and 30 days".into()));
}
now.clone() + ChronoDuration::minutes(minutes as i64)
}
now.clone() + ChronoDuration::minutes(minutes as i64)
}
"at" => {
let at = request.start_at.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat start time is required".into()))?;
if at <= now { return Err(AppError::BadRequest("temporary thermostat start time must be in the future".into())); }
if at > now.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat start time cannot be more than 30 days away".into())); }
at
}
_ => unreachable!(),
"at" => {
let at = request.start_at.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat start time is required".into()))?;
if at <= now { return Err(AppError::BadRequest("temporary thermostat start time must be in the future".into())); }
if at > now.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat start time cannot be more than 30 days away".into())); }
at
}
_ => unreachable!(),
};
(requested_start_kind.to_string(), started_at, None)
};
if !editing_active && start_kind == "now" && !runtime.house_power_enabled {
return Err(AppError::BadRequest("temporary thermostat cannot start while whole-house automation is off; enable house power or schedule it for later".into()));
}
let finish_kind = request.finish_kind.as_str();
if !matches!(finish_kind, "duration" | "until" | "temperature_reached" | "temperature_stable" | "schedule_boundary") {
return Err(AppError::BadRequest("unsupported temporary thermostat finish kind".into()));
@@ -722,88 +760,171 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
return Err(AppError::BadRequest("temporary thermostat target must be between 8 and 30 C".into()));
}
let target = (target * 2.0).round() / 2.0;
let tolerance = request.tolerance_c.unwrap_or(0.3);
if !(0.1..=3.0).contains(&tolerance) {
let min_stable_tolerance = (zone.hysteresis.max(0.1) / 2.0 + 0.1).min(3.0);
let requested_tolerance = request.tolerance_c.unwrap_or(min_stable_tolerance.max(0.3));
if !(0.1..=3.0).contains(&requested_tolerance) {
return Err(AppError::BadRequest("temporary thermostat tolerance must be between 0.1 and 3 C".into()));
}
let tolerance = if finish_kind == "temperature_stable" { requested_tolerance.max(min_stable_tolerance) } else { requested_tolerance };
let temperature_operator = request.temperature_operator.as_deref().unwrap_or("within");
if !matches!(temperature_operator, "within" | "at_or_below" | "at_or_above") {
return Err(AppError::BadRequest("unsupported temporary thermostat temperature operator".into()));
}
let expires_at = match finish_kind {
"duration" => {
let minutes = request.duration_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat duration is required".into()))?;
if !(1..=14_400).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat duration must be between 1 minute and 10 days".into())); }
Some(started_at.clone() + ChronoDuration::minutes(minutes as i64))
}
"until" => {
let until = request.until.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat end time is required".into()))?;
if until <= started_at { return Err(AppError::BadRequest("temporary thermostat end time must be after its start time".into())); }
if until > started_at.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days after start".into())); }
Some(until)
}
"schedule_boundary" => Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, started_at.with_timezone(&chrono::Local))
.ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?),
_ => None,
};
let duration_seconds = if finish_kind == "duration" {
let minutes = request.duration_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat duration is required".into()))?;
if !(1..=14_400).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat duration must be between 1 minute and 10 days".into())); }
Some(minutes.saturating_mul(60))
} else { None };
let is_temperature_condition = matches!(finish_kind, "temperature_reached" | "temperature_stable");
let hold_seconds = if finish_kind == "temperature_stable" {
let minutes = request.hold_minutes.ok_or_else(|| AppError::BadRequest("temperature hold time is required".into()))?;
if !(1..=1_440).contains(&minutes) { return Err(AppError::BadRequest("temperature hold time must be between 1 minute and 24 hours".into())); }
minutes.saturating_mul(60)
} else { 0 };
let safety_expires_at = if is_temperature_condition {
let safety_duration_seconds = if is_temperature_condition {
request.max_duration_minutes.map(|minutes| {
if !(1..=14_400).contains(&minutes) {
return Err(AppError::BadRequest("temporary thermostat safety limit must be between 1 minute and 10 days".into()));
}
Ok(started_at.clone() + ChronoDuration::minutes(minutes as i64))
Ok(minutes.saturating_mul(60))
}).transpose()?
} else { None };
// Replacing/editing an active temporary session must keep the state that existed
// before the very first temporary takeover. Otherwise editing a session that was
// started from a disabled zone would incorrectly restore automation as enabled.
let restore_zone_enabled = zone.temporary_quick_thermostat.as_ref()
.and_then(|session| session.restore_zone_enabled)
.unwrap_or(zone.enabled);
let activate_now = start_kind == "now";
if !activate_now && zone.temporary_quick_thermostat.is_some() {
if zone.local_thermostat_power == Some(true) {
engine::reset_local_thermostat_override(&mut zone);
} else {
zone.temporary_quick_thermostat = None;
let active_base = activated_at.clone().unwrap_or(now.clone());
let expires_at = match finish_kind {
"duration" => if editing_active { duration_seconds.map(|seconds| active_base.clone() + ChronoDuration::seconds(seconds as i64)) } else { None },
"until" => {
let until = request.until.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat end time is required".into()))?;
let comparison_start = if editing_active { now.clone() } else { started_at.clone() };
if until <= comparison_start { return Err(AppError::BadRequest("temporary thermostat end time must be in the future and after its start".into())); }
if until > comparison_start.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days after start".into())); }
Some(until)
}
"schedule_boundary" => {
let reference = if editing_active { chrono::Local::now() } else { started_at.clone().with_timezone(&chrono::Local) };
Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, reference)
.ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?)
}
_ => None,
};
let safety_expires_at = if editing_active {
safety_duration_seconds.map(|seconds| active_base.clone() + ChronoDuration::seconds(seconds as i64))
} else { None };
let immediate_activation = !editing_active && start_kind == "now" && runtime.house_power_enabled && !zone.device_manual_override;
let restore_zone_enabled = if editing_active {
existing_session.as_ref().and_then(|session| session.restore_zone_enabled)
} else if immediate_activation {
Some(zone.enabled)
} else {
// Delayed sessions capture this at actual takeover time (H12), not planning time.
None
};
let configured_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() };
let captured_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() };
let active_mode = if editing_active {
existing_session.as_ref().and_then(|session| session.active_mode.clone())
} else if immediate_activation {
Some(captured_mode.clone())
} else { None };
let condition_mode = active_mode.as_deref().unwrap_or(captured_mode.as_str());
if is_temperature_condition {
if (condition_mode == "heat" && temperature_operator == "at_or_below")
|| (condition_mode == "cool" && temperature_operator == "at_or_above")
{
return Err(AppError::BadRequest(
"temporary thermostat temperature condition conflicts with the active heating/cooling direction".into(),
));
}
}
if activate_now {
engine::set_local_thermostat_power(&mut zone, true, now.clone());
let state_value = if editing_active {
if zone.device_manual_override { "paused_manual" } else { "active" }
} else if start_kind == "now" && zone.device_manual_override {
"paused_manual"
} else {
"scheduled"
};
let underlying_local_power = zone.local_thermostat_power;
let underlying_local_resume_at = zone.local_thermostat_resume_at;
let underlying_local_zone_enabled = zone.local_thermostat_restore_zone_enabled;
let underlying_manual_preset = zone.manual_preset.clone();
let underlying_manual_setpoint = zone.manual_setpoint;
let underlying_manual_override_until = zone.manual_override_until;
if immediate_activation {
zone.local_thermostat_power = Some(true);
zone.local_thermostat_resume_at = None;
zone.local_thermostat_restore_zone_enabled = None;
zone.enabled = true;
zone.setpoint = target;
zone.manual_setpoint = Some(target);
zone.effective_setpoint = Some(target);
zone.manual_override_until = None;
} else if editing_active {
// Keep current ownership and update the live target without restarting the session.
zone.manual_setpoint = Some(target);
zone.effective_setpoint = Some(target);
zone.manual_override_until = None;
}
zone.temporary_quick_thermostat = Some(TemporaryQuickThermostat {
start_kind: start_kind.into(),
start_kind,
finish_kind: finish_kind.into(),
started_at,
activated_at: activate_now.then_some(now),
restore_zone_enabled: Some(restore_zone_enabled),
expires_at,
// Keep the requested thermostat target even for delayed time-based sessions;
// it is applied only when ownership actually starts.
activated_at: if immediate_activation { Some(now.clone()) } else { activated_at.clone() },
state: state_value.into(),
generation: existing_session.as_ref().map(|session| session.generation.saturating_add(1)).unwrap_or(1),
active_mode,
restore_zone_enabled,
restore_local_thermostat_power: if editing_active {
existing_session.as_ref().and_then(|session| session.restore_local_thermostat_power)
} else if immediate_activation { underlying_local_power } else { None },
restore_local_thermostat_resume_at: if editing_active {
existing_session.as_ref().and_then(|session| session.restore_local_thermostat_resume_at)
} else if immediate_activation { underlying_local_resume_at } else { None },
restore_local_thermostat_zone_enabled: if editing_active {
existing_session.as_ref().and_then(|session| session.restore_local_thermostat_zone_enabled)
} else if immediate_activation { underlying_local_zone_enabled } else { None },
restore_manual_preset: if editing_active {
existing_session.as_ref().and_then(|session| session.restore_manual_preset.clone())
} else if immediate_activation { underlying_manual_preset } else { None },
restore_manual_setpoint: if editing_active {
existing_session.as_ref().and_then(|session| session.restore_manual_setpoint)
} else if immediate_activation { underlying_manual_setpoint } else { None },
restore_manual_override_until: if editing_active {
existing_session.as_ref().and_then(|session| session.restore_manual_override_until)
} else if immediate_activation { underlying_manual_override_until } else { None },
expires_at: if immediate_activation && finish_kind == "duration" {
duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64))
} else { expires_at },
duration_seconds,
safety_duration_seconds,
temperature_target: Some(target),
temperature_operator: is_temperature_condition.then(|| temperature_operator.to_string()),
tolerance_c: tolerance,
hold_seconds,
condition_started_at: None,
safety_expires_at,
condition_last_observed_at: None,
paused_at: if zone.device_manual_override && (editing_active || start_kind == "now") {
existing_session.as_ref().and_then(|session| session.paused_at.clone()).or(Some(now.clone()))
} 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()),
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 },
});
}
if let Some(power) = patch.power {
zone.temporary_quick_thermostat = None;
// The neighbouring quick-power control and the explicit Stop button must use the
// same temporary-session cleanup/restore semantics before local ownership changes.
if zone.temporary_quick_thermostat.is_some() {
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode);
} else {
zone.temporary_quick_thermostat = None;
}
}
engine::set_local_thermostat_power(&mut zone, power, Utc::now());
if power { zone.enabled = true; }
// A manually started local thermostat keeps an already selected target/profile until
@@ -821,9 +942,12 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
zone.effective_setpoint = Some(value);
if zone.local_thermostat_power == Some(true) {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
// +/- always edits the live temporary target, including duration/until
// sessions, so the modal and regulator cannot diverge (M14).
session.temperature_target = Some(value);
if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") {
session.temperature_target = Some(value);
session.condition_started_at = None;
session.condition_last_observed_at = None;
}
}
}
@@ -834,42 +958,72 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
};
}
if let Some(value) = patch.mode.as_deref() {
match value {
"house" | "auto" => zone.inherit_house_mode = true,
"cool" | "heat" => {
zone.inherit_house_mode = false;
zone.mode = value.to_string();
if !matches!(value, "house" | "auto" | "cool" | "heat") {
return Err(AppError::BadRequest("zone mode must be house, cool or heat".into()));
}
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
session.deferred_mode = Some(value.to_string());
}
} else {
match value {
"house" | "auto" => zone.inherit_house_mode = true,
"cool" | "heat" => {
zone.inherit_house_mode = false;
zone.mode = value.to_string();
}
_ => unreachable!(),
}
_ => return Err(AppError::BadRequest("zone mode must be house, cool or heat".into())),
}
}
if let Some(value) = patch.preset.as_deref() {
match value {
"auto" => {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
if !matches!(value, "auto" | "comfort" | "sleep" | "away" | "custom") {
return Err(AppError::BadRequest("unsupported zone preset".into()));
}
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());
}
"comfort" | "sleep" | "away" | "custom" => {
zone.manual_preset = Some(value.to_string());
zone.manual_setpoint = None;
zone.manual_override_until = if zone.local_thermostat_power == Some(true) {
None
} else {
engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now())
};
} else {
match value {
"auto" => {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
}
"comfort" | "sleep" | "away" | "custom" => {
zone.manual_preset = Some(value.to_string());
zone.manual_setpoint = None;
zone.manual_override_until = if zone.local_thermostat_power == Some(true) {
None
} else {
engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now())
};
}
_ => unreachable!(),
}
_ => return Err(AppError::BadRequest("unsupported zone preset".into())),
}
}
if patch.clear_override.unwrap_or(false) {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
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());
}
} else {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
}
}
if let Some(value) = patch.enabled {
if !value {
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode);
} else {
zone.temporary_quick_thermostat = None;
}
engine::reset_local_thermostat_override(&mut zone);
engine::reset_device_manual_override(&mut zone);
zone.enabled = false;
} else {
zone.enabled = true;
@@ -882,17 +1036,6 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
&& state.db.list_groups()?.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
engine::refresh_control_ownership(&mut zone, runtime.house_power_enabled, blocked_by_group);
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
if (patch.preset.is_some() || patch.clear_override.unwrap_or(false))
&& zone.local_thermostat_power == Some(true)
&& zone.temporary_quick_thermostat.is_some() {
let current_target = zone.effective_setpoint;
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") {
session.temperature_target = current_target;
session.condition_started_at = None;
}
}
}
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
@@ -1016,6 +1159,7 @@ async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInpu
};
state.db.save_group(&group)?;
state.broadcast("group.created", serde_json::to_value(&group)?);
state.wake_zone_control();
Ok((StatusCode::CREATED, Json(group)))
}
@@ -1032,6 +1176,7 @@ async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Jso
};
state.db.save_group(&group)?;
state.broadcast("group.updated", serde_json::to_value(&group)?);
state.wake_zone_control();
Ok(Json(group))
}
@@ -1041,6 +1186,7 @@ async fn delete_group(State(state): State<AppState>, Path(id): Path<String>) ->
}
if !state.db.delete_group(&id)? { return Err(AppError::NotFound(format!("group {id}"))); }
state.broadcast("group.deleted", json!({"id": id}));
state.wake_zone_control();
Ok(StatusCode::NO_CONTENT)
}
@@ -1224,7 +1370,13 @@ fn clear_all_local_thermostat_overrides(state: &AppState) -> Result<usize, AppEr
let mut cleared = 0;
for mut zone in state.db.list_zones()? {
if zone.local_thermostat_power.is_none() && zone.local_thermostat_resume_at.is_none() && zone.temporary_quick_thermostat.is_none() { continue; }
let temporary_was_active = engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
let temporary_restore = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.restore_zone_enabled);
zone.temporary_quick_thermostat = None;
engine::reset_local_thermostat_override(&mut zone);
if temporary_was_active {
if let Some(enabled) = temporary_restore { zone.enabled = enabled; }
}
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
@@ -1292,13 +1444,9 @@ 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)?;
let failed = command_all_enabled_devices_power(&state, true, "house_mode").await?;
if !failed.is_empty() {
state.log("warn", "house.mode_power_partial", "House mode enabled master power, but some devices could not be powered on", json!({
"mode": mode,
"failed": failed.len(),
}));
}
// 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();
}
state.log("info", "house.mode", &format!("House mode set to {}", mode), json!({"mode": mode, "master_power_enabled": activate_all}));
Ok(Json(payload))
@@ -1328,14 +1476,19 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
engine::clear_all_device_manual_overrides(&state, "house_power_off")?;
clear_all_local_thermostat_overrides(&state)?;
}
let failed = command_all_enabled_devices_power(&state, input.power, "house_power").await?;
let failed = if input.power {
state.wake_zone_control();
Vec::new()
} else {
command_all_enabled_devices_power(&state, false, "house_power").await?
};
let devices = state.db.list_devices()?;
let groups = state.db.list_groups()?;
let settings = state.settings.read().await;
let settings_payload = public_settings(&settings);
drop(settings);
state.log("info", "house.power_all", if input.power { "Whole-house power enabled; all enabled thermostat zones powered on" } else { "Whole-house power disabled; all groups and enabled devices powered off" }, json!({
state.log("info", "house.power_all", if input.power { "Whole-house automation enabled; thermostat arbiter resumed" } else { "Whole-house power disabled; all groups and enabled devices powered off" }, json!({
"power": input.power,
"failed": failed.len(),
}));
@@ -1370,7 +1523,11 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
let schedules = state.db.list_schedules()?;
let mut zones = state.db.list_zones()?;
for zone in &mut zones {
if input.preset == "auto" {
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());
}
} else if input.preset == "auto" {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
@@ -1384,7 +1541,9 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
}
let failed = command_all_enabled_devices_power(&state, true, "house_preset").await?;
// As with house mode/power ON, the central thermostat arbiter performs the physical
// start with a valid mode/target. This prevents unmanaged power-on while house mode=off.
let failed: Vec<Value> = Vec::new();
state.wake_zone_control();
let devices = state.db.list_devices()?;
state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({
@@ -1520,17 +1679,30 @@ fn validate_schedule_conflicts(state: &AppState, item: &Schedule, exclude_id: Op
fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(), AppError> {
let Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); };
if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() && !zone.device_manual_override { return Ok(()); }
let has_temporary_schedule_boundary = zone.temporary_quick_thermostat.as_ref()
.map(|session| session.finish_kind == "schedule_boundary")
.unwrap_or(false);
if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() && !zone.device_manual_override && !has_temporary_schedule_boundary { return Ok(()); }
let schedules = state.db.list_schedules()?;
let boundary = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
// An active Temporary Quick Thermostat explicitly owns its target until its own finish
// rule. Editing/applying schedules must not arm the generic quick-setpoint boundary and
// accidentally clear that target at the next schedule transition.
let temporary_owns_zone = zone.local_thermostat_power == Some(true) && zone.temporary_quick_thermostat.is_some();
let temporary_owns_zone = engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
if (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) && !temporary_owns_zone {
zone.manual_override_until = boundary;
}
if zone.device_manual_override { zone.device_manual_override_until = boundary; zone.control_resume_at = boundary; }
if has_temporary_schedule_boundary {
let reference = zone.temporary_quick_thermostat.as_ref()
.filter(|session| session.activated_at.is_none())
.map(|session| session.started_at.with_timezone(&chrono::Local))
.unwrap_or_else(chrono::Local::now);
let refreshed = engine::next_schedule_boundary_utc(&zone.id, &schedules, reference).or(Some(Utc::now()));
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
session.expires_at = refreshed;
}
}
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
@@ -1550,6 +1722,7 @@ async fn create_schedule(State(state): State<AppState>, Json(input): Json<Schedu
state.db.save_schedule(&item)?;
refresh_zone_override_boundary(&state, &item.zone_id)?;
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> {
@@ -1563,6 +1736,7 @@ async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>,
refresh_zone_override_boundary(&state, &old_zone_id)?;
if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id)?; }
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> {
@@ -1570,6 +1744,7 @@ async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>)
if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); }
refresh_zone_override_boundary(&state, &existing.zone_id)?;
state.broadcast("schedule.deleted", json!({"id": id}));
state.wake_zone_control();
Ok(StatusCode::NO_CONTENT)
}
@@ -2040,6 +2215,11 @@ 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 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(
"house_power_enabled and house_mode must be changed through the House Control API".into(),
));
}
input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600);
input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600);
input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000);
@@ -2138,7 +2318,11 @@ fn validate_night_mode(settings: &mut RuntimeSettings) -> Result<(), AppError> {
async fn export_settings(State(state): State<AppState>) -> Result<Json<ConfigurationExport>, AppError> {
let settings = state.settings.read().await.clone();
Ok(Json(state.db.export_configuration(settings)?))
let mut export = state.db.export_configuration(settings)?;
// Backups are configuration snapshots, not a way to resurrect transient ownership,
// timers or a stale physical device state after restore (K9).
sanitize_configuration_runtime(&mut export);
Ok(Json(export))
}
fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> {
@@ -2262,6 +2446,74 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App
Ok(())
}
fn sanitize_configuration_runtime(export: &mut ConfigurationExport) {
let now = Utc::now();
for device in &mut export.devices {
device.power = false;
device.mode = "cool".into();
device.target_temperature = 23.0;
device.fan_speed = 0;
device.swing_vertical = false;
device.swing_horizontal = false;
device.quiet = false;
device.turbo = false;
device.light = false;
device.air = false;
device.xfan = false;
device.health = false;
device.sleep = false;
device.current_temperature = None;
device.outdoor_temperature = None;
device.online = false;
device.response_time_ms = None;
device.last_seen = None;
device.last_error = None;
device.communication_failures = 0;
device.updated_at = now;
}
for zone in &mut export.zones {
zone.device_temperature = None;
zone.external_temperature = None;
zone.current_temperature = None;
zone.control_temperature_source = "device".into();
zone.active_preset = "comfort".into();
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
zone.local_thermostat_power = None;
zone.local_thermostat_resume_at = None;
zone.local_thermostat_restore_zone_enabled = None;
zone.temporary_quick_thermostat = None;
zone.device_manual_override = false;
zone.device_manual_override_since = None;
zone.device_manual_override_until = None;
zone.device_manual_override_fields.clear();
zone.device_manual_override_baseline = None;
zone.control_owner = "automation".into();
zone.control_source = "automation".into();
zone.control_since = None;
zone.control_resume_at = None;
zone.control_reason = "Imported configuration; runtime ownership reset".into();
zone.last_power_change_at = None;
zone.last_mode_change_at = None;
zone.lockout_until = None;
zone.lockout_reason = None;
zone.effective_mode.clear();
zone.effective_setpoint = None;
zone.device_setpoint = None;
zone.demand = false;
zone.demand_since = None;
zone.target_alerted_at = None;
zone.last_action_at = None;
zone.revision = 0;
zone.updated_at = now;
}
for automation in &mut export.automations {
automation.last_fired_at = None;
automation.updated_at = now;
}
}
async fn import_settings(State(state): State<AppState>, Json(mut export): Json<ConfigurationExport>) -> Result<Json<Value>, AppError> {
validate_configuration_export(&export)?;
export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650);
@@ -2271,9 +2523,57 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
for zone in &mut export.zones { canonicalize_zone_ha_entity(zone, &export.settings); }
validate_night_mode(&mut export.settings)?;
export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650);
// 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.
let imported_zone_map: std::collections::HashMap<String, String> = export.zones.iter()
.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()? {
if imported_zone_map.get(&current.id).map(String::as_str) != Some(current.device_id.as_str()) {
detach_devices.insert(current.device_id);
}
}
for device_id in detach_devices {
ensure_device_stopped_for_detach(&state, &device_id, "configuration.import").await?;
}
// Configuration import never restores ephemeral owners/timers or cached physical state.
// Imported devices are reconciled from a fresh poll and current house/group/zone gates.
sanitize_configuration_runtime(&mut export);
state.initial_device_sync_complete.store(false, Ordering::Release);
state.db.replace_configuration(&export)?;
state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed);
*state.settings.write().await = export.settings.clone();
let disabled_group_zones: std::collections::HashSet<String> = export.groups.iter()
.filter(|group| !group.power_enabled)
.flat_map(|group| group.zone_ids.iter().cloned())
.collect();
let controllable_devices: std::collections::HashSet<String> = export.zones.iter()
.filter(|zone| {
let effective_mode = if zone.inherit_house_mode { export.settings.house_mode.as_str() } else { zone.mode.as_str() };
export.settings.house_power_enabled
&& zone.enabled
&& effective_mode != "off"
&& !disabled_group_zones.contains(&zone.id)
})
.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 {
state.log("error", "settings.import_reconcile_error", &err.to_string(), json!({"device_id": device.id}));
return Err(err);
}
}
// 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?;
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}));
state.broadcast("configuration.imported", json!({"at": Utc::now()}));
Ok(Json(json!({"ok": true})))