v0.12.0
This commit is contained in:
+236
-203
@@ -252,6 +252,240 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
||||
Ok(Json(zone))
|
||||
}
|
||||
|
||||
struct TemporaryStartTiming {
|
||||
start_kind: String,
|
||||
started_at: chrono::DateTime<Utc>,
|
||||
activated_at: Option<chrono::DateTime<Utc>>,
|
||||
editing_active: bool,
|
||||
}
|
||||
|
||||
struct TemporaryTargetSettings {
|
||||
target: f64,
|
||||
tolerance: f64,
|
||||
temperature_operator: String,
|
||||
is_temperature_condition: bool,
|
||||
}
|
||||
|
||||
struct TemporaryFinishTiming {
|
||||
duration_seconds: Option<u64>,
|
||||
hold_seconds: u64,
|
||||
safety_duration_seconds: Option<u64>,
|
||||
expires_at: Option<chrono::DateTime<Utc>>,
|
||||
safety_expires_at: Option<chrono::DateTime<Utc>>,
|
||||
}
|
||||
|
||||
fn resolve_temporary_start(
|
||||
zone: &Zone,
|
||||
request: &TemporaryQuickThermostatRequest,
|
||||
now: chrono::DateTime<Utc>,
|
||||
) -> Result<TemporaryStartTiming, AppError> {
|
||||
let existing = zone.temporary_quick_thermostat.as_ref();
|
||||
let editing_active = engine::temporary_quick_thermostat_is_active(zone, now.clone());
|
||||
if !matches!(request.start_kind.as_str(), "now" | "delay" | "at") {
|
||||
return Err(AppError::BadRequest("unsupported temporary thermostat start kind".into()));
|
||||
}
|
||||
if editing_active {
|
||||
let existing = existing.expect("active temporary session must exist");
|
||||
return Ok(TemporaryStartTiming {
|
||||
start_kind: existing.start_kind.clone(),
|
||||
started_at: existing.started_at.clone(),
|
||||
activated_at: existing.activated_at.clone(),
|
||||
editing_active: true,
|
||||
});
|
||||
}
|
||||
let started_at = match request.start_kind.as_str() {
|
||||
"now" => now,
|
||||
"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 + 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 + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat start time cannot be more than 30 days away".into())); }
|
||||
at
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
Ok(TemporaryStartTiming {
|
||||
start_kind: request.start_kind.clone(),
|
||||
started_at,
|
||||
activated_at: None,
|
||||
editing_active: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_temporary_target(
|
||||
zone: &Zone,
|
||||
request: &TemporaryQuickThermostatRequest,
|
||||
) -> Result<TemporaryTargetSettings, AppError> {
|
||||
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()));
|
||||
}
|
||||
let target = request.target_temperature.unwrap_or(zone.manual_setpoint.unwrap_or(zone.effective_setpoint.unwrap_or(zone.setpoint)));
|
||||
if !(8.0..=30.0).contains(&target) {
|
||||
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_mode = if zone.effective_mode.is_empty() { zone.mode.as_str() } else { zone.effective_mode.as_str() };
|
||||
let min_stable_tolerance = (zone.hysteresis_for_mode(tolerance_mode) / 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 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()));
|
||||
}
|
||||
Ok(TemporaryTargetSettings {
|
||||
target,
|
||||
tolerance: if finish_kind == "temperature_stable" { requested_tolerance.max(min_stable_tolerance) } else { requested_tolerance },
|
||||
temperature_operator: temperature_operator.to_string(),
|
||||
is_temperature_condition: matches!(finish_kind, "temperature_reached" | "temperature_stable"),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_temporary_finish(
|
||||
zone: &Zone,
|
||||
schedules: &[Schedule],
|
||||
request: &TemporaryQuickThermostatRequest,
|
||||
start: &TemporaryStartTiming,
|
||||
now: chrono::DateTime<Utc>,
|
||||
is_temperature_condition: bool,
|
||||
) -> Result<TemporaryFinishTiming, AppError> {
|
||||
let finish_kind = request.finish_kind.as_str();
|
||||
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 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_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(minutes.saturating_mul(60))
|
||||
}).transpose()?
|
||||
} else { None };
|
||||
let active_base = start.activated_at.clone().unwrap_or(now.clone());
|
||||
let expires_at = match finish_kind {
|
||||
"duration" if start.editing_active => duration_seconds.map(|seconds| active_base + ChronoDuration::seconds(seconds as i64)),
|
||||
"until" => {
|
||||
let until = request.until.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat end time is required".into()))?;
|
||||
let comparison_start = if start.editing_active { now.clone() } else { start.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 + 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 start.editing_active { chrono::Local::now() } else { start.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 start.editing_active {
|
||||
safety_duration_seconds.map(|seconds| active_base + ChronoDuration::seconds(seconds as i64))
|
||||
} else { None };
|
||||
Ok(TemporaryFinishTiming { duration_seconds, hold_seconds, safety_duration_seconds, expires_at, safety_expires_at })
|
||||
}
|
||||
|
||||
async fn apply_temporary_quick_thermostat_request(
|
||||
state: &AppState,
|
||||
zone: &mut Zone,
|
||||
schedules: &[Schedule],
|
||||
request: &TemporaryQuickThermostatRequest,
|
||||
) -> Result<(), AppError> {
|
||||
let now = Utc::now();
|
||||
let runtime = state.settings.read().await.clone();
|
||||
let existing_session = zone.temporary_quick_thermostat.clone();
|
||||
let start = resolve_temporary_start(zone, request, now.clone())?;
|
||||
let target = resolve_temporary_target(zone, request)?;
|
||||
let finish = resolve_temporary_finish(zone, schedules, request, &start, now.clone(), target.is_temperature_condition)?;
|
||||
let starts_now = start.start_kind == "now";
|
||||
let immediate_activation = !start.editing_active && starts_now && !zone.device_manual_override;
|
||||
let restore_zone_enabled = if start.editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.restore_zone_enabled)
|
||||
} else if immediate_activation { Some(zone.enabled) } else { 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 start.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 target.is_temperature_condition
|
||||
&& ((condition_mode == "heat" && target.temperature_operator == "at_or_below")
|
||||
|| (condition_mode == "cool" && target.temperature_operator == "at_or_above"))
|
||||
{
|
||||
return Err(AppError::BadRequest("temporary thermostat temperature condition conflicts with the active heating/cooling direction".into()));
|
||||
}
|
||||
let state_value = if start.editing_active {
|
||||
if zone.device_manual_override { "paused_manual" } else { "active" }
|
||||
} else if starts_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.manual_setpoint = Some(target.target);
|
||||
zone.effective_setpoint = Some(target.target);
|
||||
zone.manual_override_until = None;
|
||||
} else if start.editing_active {
|
||||
zone.manual_setpoint = Some(target.target);
|
||||
zone.effective_setpoint = Some(target.target);
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
|
||||
zone.temporary_quick_thermostat = Some(TemporaryQuickThermostat {
|
||||
start_kind: start.start_kind,
|
||||
finish_kind: request.finish_kind.clone(),
|
||||
started_at: start.started_at.clone(),
|
||||
activated_at: if immediate_activation { Some(now.clone()) } else { start.activated_at.clone() },
|
||||
state: state_value.into(),
|
||||
active_mode,
|
||||
restore_zone_enabled,
|
||||
restore_local_thermostat_power: if start.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 start.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 start.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 start.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 start.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 start.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 && request.finish_kind == "duration" { finish.duration_seconds.map(|seconds| now + ChronoDuration::seconds(seconds as i64)) } else { finish.expires_at },
|
||||
duration_seconds: finish.duration_seconds,
|
||||
safety_duration_seconds: finish.safety_duration_seconds,
|
||||
temperature_target: Some(target.target),
|
||||
temperature_operator: target.is_temperature_condition.then(|| target.temperature_operator.clone()),
|
||||
tolerance_c: target.tolerance,
|
||||
hold_seconds: finish.hold_seconds,
|
||||
condition_started_at: None,
|
||||
condition_last_observed_at: None,
|
||||
paused_at: if zone.device_manual_override && (start.editing_active || starts_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()),
|
||||
deferred_setpoint: existing_session.as_ref().and_then(|session| session.deferred_setpoint),
|
||||
safety_expires_at: if immediate_activation && target.is_temperature_condition { finish.safety_duration_seconds.map(|seconds| now + ChronoDuration::seconds(seconds as i64)) } else { finish.safety_expires_at },
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch, source: &str) -> Result<Zone, AppError> {
|
||||
// A quick preset/setpoint derives its resume boundary from schedules. Take the schedule
|
||||
// lock before the per-zone lock so a concurrent schedule edit cannot leave an override
|
||||
@@ -310,208 +544,7 @@ 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 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()));
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
"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)
|
||||
};
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
let target = request.target_temperature.unwrap_or(zone.manual_setpoint.unwrap_or(zone.effective_setpoint.unwrap_or(zone.setpoint)));
|
||||
if !(8.0..=30.0).contains(&target) {
|
||||
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_mode = if zone.effective_mode.is_empty() { zone.mode.as_str() } else { zone.effective_mode.as_str() };
|
||||
let min_stable_tolerance = (zone.hysteresis_for_mode(tolerance_mode) / 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 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_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(minutes.saturating_mul(60))
|
||||
}).transpose()?
|
||||
} else { 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 starts_now = start_kind == "now";
|
||||
let immediate_activation = !editing_active && starts_now && !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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let state_value = if editing_active {
|
||||
if zone.device_manual_override { "paused_manual" } else { "active" }
|
||||
} else if starts_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.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,
|
||||
finish_kind: finish_kind.into(),
|
||||
started_at,
|
||||
activated_at: if immediate_activation { Some(now.clone()) } else { activated_at.clone() },
|
||||
state: state_value.into(),
|
||||
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,
|
||||
condition_last_observed_at: None,
|
||||
paused_at: if zone.device_manual_override && (editing_active || starts_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()),
|
||||
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 },
|
||||
});
|
||||
apply_temporary_quick_thermostat_request(state, &mut zone, &schedules, request).await?;
|
||||
}
|
||||
if let Some(power) = patch.power {
|
||||
// The neighbouring quick-power control and the explicit Stop button must use the
|
||||
@@ -632,7 +665,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|
||||
let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false };
|
||||
let runtime = state.settings.read().await.clone();
|
||||
let house_mode = runtime.house_mode.clone();
|
||||
engine::refresh_control_ownership(&mut zone, true);
|
||||
engine::refresh_control_ownership(&mut zone);
|
||||
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
|
||||
Reference in New Issue
Block a user