v0.8.4
This commit is contained in:
+71
-20
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user