v0.8.3
This commit is contained in:
+111
-7
@@ -23,7 +23,7 @@ use crate::{
|
||||
home_assistant,
|
||||
influxdb,
|
||||
notifications,
|
||||
models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GroupControlPatch, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
|
||||
models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GroupControlPatch, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPatch, ZoneReading},
|
||||
protocol::merge_discovered,
|
||||
state::AppState,
|
||||
};
|
||||
@@ -540,7 +540,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,
|
||||
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,
|
||||
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 +611,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.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;
|
||||
zone.device_manual_override_until = existing.device_manual_override_until;
|
||||
@@ -666,19 +667,102 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|
||||
let schedules = state.db.list_schedules()?;
|
||||
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.
|
||||
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();
|
||||
if resume_device_automation || resume_local_thermostat {
|
||||
|| patch.preset.is_some() || patch.enabled.is_some() || patch.temporary_quick_thermostat.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() };
|
||||
}
|
||||
|
||||
if resume_local_thermostat {
|
||||
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 let Some(request) = patch.temporary_quick_thermostat.as_ref() {
|
||||
let now = Utc::now();
|
||||
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 = request.tolerance_c.unwrap_or(0.3);
|
||||
if !(0.1..=3.0).contains(&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()));
|
||||
}
|
||||
|
||||
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(now.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())); }
|
||||
Some(until)
|
||||
}
|
||||
"schedule_boundary" => Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now())
|
||||
.ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?),
|
||||
_ => 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 {
|
||||
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(now.clone() + ChronoDuration::minutes(minutes as i64))
|
||||
}).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);
|
||||
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 {
|
||||
finish_kind: finish_kind.into(),
|
||||
started_at: now,
|
||||
restore_zone_enabled: Some(restore_zone_enabled),
|
||||
expires_at,
|
||||
temperature_target: is_temperature_condition.then_some(target),
|
||||
temperature_operator: is_temperature_condition.then(|| temperature_operator.to_string()),
|
||||
tolerance_c: tolerance,
|
||||
hold_seconds,
|
||||
condition_started_at: None,
|
||||
safety_expires_at,
|
||||
});
|
||||
}
|
||||
if let Some(power) = patch.power {
|
||||
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
|
||||
@@ -694,6 +778,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;
|
||||
}
|
||||
}
|
||||
zone.manual_override_until = if zone.local_thermostat_power == Some(true) {
|
||||
None
|
||||
} else {
|
||||
@@ -735,8 +825,12 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
if let Some(value) = patch.enabled {
|
||||
zone.enabled = value;
|
||||
if !value { engine::reset_local_thermostat_override(&mut zone); }
|
||||
if !value {
|
||||
engine::reset_local_thermostat_override(&mut zone);
|
||||
zone.enabled = false;
|
||||
} else {
|
||||
zone.enabled = true;
|
||||
}
|
||||
}
|
||||
let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false };
|
||||
let runtime = state.settings.read().await.clone();
|
||||
@@ -745,6 +839,15 @@ 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() {
|
||||
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)?;
|
||||
@@ -773,6 +876,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|
||||
"override_until": zone.manual_override_until, "enabled": zone.enabled,
|
||||
"local_thermostat_power": zone.local_thermostat_power,
|
||||
"local_thermostat_resume_at": zone.local_thermostat_resume_at,
|
||||
"temporary_quick_thermostat": zone.temporary_quick_thermostat,
|
||||
"device_manual_override_cleared": device_override_cleared
|
||||
}));
|
||||
Ok(zone)
|
||||
@@ -1074,7 +1178,7 @@ fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> {
|
||||
fn clear_all_local_thermostat_overrides(state: &AppState) -> Result<usize, AppError> {
|
||||
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() { continue; }
|
||||
if zone.local_thermostat_power.is_none() && zone.local_thermostat_resume_at.is_none() && zone.temporary_quick_thermostat.is_none() { continue; }
|
||||
engine::reset_local_thermostat_override(&mut zone);
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
|
||||
Reference in New Issue
Block a user