This commit is contained in:
Mateusz Gruszczyński
2026-08-28 10:04:35 +02:00
parent d159795267
commit eb325cc0c8
15 changed files with 392 additions and 653 deletions
+71 -20
View File
@@ -681,10 +681,37 @@ 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() {
engine::reset_local_thermostat_override(&mut zone);
if zone.local_thermostat_power == Some(true) {
engine::reset_local_thermostat_override(&mut zone);
} else {
// Cancelling a delayed session before it starts must not erase unrelated
// quick preset/setpoint state that automation may be using in the meantime.
zone.temporary_quick_thermostat = None;
}
}
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") {
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()));
}
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!(),
};
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()));
@@ -708,15 +735,15 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
"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(now.clone() + ChronoDuration::minutes(minutes as i64))
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 <= now { return Err(AppError::BadRequest("temporary thermostat end time must be in the future".into())); }
if until > now.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days away".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, chrono::Local::now())
"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,
};
@@ -732,7 +759,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
if !(1..=14_400).contains(&minutes) {
return Err(AppError::BadRequest("temporary thermostat safety limit must be between 1 minute and 10 days".into()));
}
Ok(now.clone() + ChronoDuration::minutes(minutes as i64))
Ok(started_at.clone() + ChronoDuration::minutes(minutes as i64))
}).transpose()?
} else { None };
@@ -742,18 +769,32 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
let restore_zone_enabled = zone.temporary_quick_thermostat.as_ref()
.and_then(|session| session.restore_zone_enabled)
.unwrap_or(zone.enabled);
engine::set_local_thermostat_power(&mut zone, true, now.clone());
zone.enabled = true;
zone.setpoint = target;
zone.manual_setpoint = Some(target);
zone.effective_setpoint = Some(target);
zone.manual_override_until = None;
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;
}
}
if activate_now {
engine::set_local_thermostat_power(&mut zone, true, now.clone());
zone.enabled = true;
zone.setpoint = target;
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(),
finish_kind: finish_kind.into(),
started_at: now,
started_at,
activated_at: activate_now.then_some(now),
restore_zone_enabled: Some(restore_zone_enabled),
expires_at,
temperature_target: is_temperature_condition.then_some(target),
// Keep the requested thermostat target even for delayed time-based sessions;
// it is applied only when ownership actually starts.
temperature_target: Some(target),
temperature_operator: is_temperature_condition.then(|| temperature_operator.to_string()),
tolerance_c: tolerance,
hold_seconds,
@@ -778,10 +819,12 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
zone.setpoint = value;
zone.manual_setpoint = Some(value);
zone.effective_setpoint = Some(value);
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") {
session.temperature_target = Some(value);
session.condition_started_at = None;
if zone.local_thermostat_power == Some(true) {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") {
session.temperature_target = Some(value);
session.condition_started_at = None;
}
}
}
zone.manual_override_until = if zone.local_thermostat_power == Some(true) {
@@ -839,7 +882,9 @@ 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.temporary_quick_thermostat.is_some() {
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") {
@@ -1478,7 +1523,13 @@ fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(),
if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() && !zone.device_manual_override { return Ok(()); }
let schedules = state.db.list_schedules()?;
let boundary = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
if zone.manual_preset.is_some() || zone.manual_setpoint.is_some() { zone.manual_override_until = boundary; }
// 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();
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; }
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();