1334 lines
51 KiB
Rust
1334 lines
51 KiB
Rust
#[derive(Debug, Deserialize)]
|
|
struct ZoneInput {
|
|
name: String,
|
|
device_id: String,
|
|
#[serde(default = "yes")]
|
|
enabled: bool,
|
|
#[serde(default = "cool")]
|
|
mode: String,
|
|
#[serde(default = "yes")]
|
|
inherit_house_mode: bool,
|
|
#[serde(default = "setpoint")]
|
|
setpoint: f64,
|
|
#[serde(default = "cool_comfort")]
|
|
cool_comfort_setpoint: f64,
|
|
#[serde(default = "cool_sleep")]
|
|
cool_sleep_setpoint: f64,
|
|
#[serde(default = "cool_away")]
|
|
cool_away_setpoint: f64,
|
|
#[serde(default = "heat_comfort")]
|
|
heat_comfort_setpoint: f64,
|
|
#[serde(default = "heat_sleep")]
|
|
heat_sleep_setpoint: f64,
|
|
#[serde(default = "heat_away")]
|
|
heat_away_setpoint: f64,
|
|
#[serde(default = "hysteresis")]
|
|
hysteresis: f64,
|
|
#[serde(default)]
|
|
separate_hysteresis: bool,
|
|
#[serde(default = "hysteresis")]
|
|
cool_hysteresis: f64,
|
|
#[serde(default = "hysteresis")]
|
|
heat_hysteresis: f64,
|
|
#[serde(default = "cycle")]
|
|
min_on_seconds: u64,
|
|
#[serde(default = "cycle")]
|
|
min_off_seconds: u64,
|
|
#[serde(default = "min_adjust")]
|
|
min_adjust_seconds: u64,
|
|
#[serde(default = "standby_offset")]
|
|
standby_offset_c: f64,
|
|
#[serde(default = "yes")]
|
|
smart_fan: bool,
|
|
#[serde(default = "device_source")]
|
|
sensor_source: String,
|
|
#[serde(default)]
|
|
ha_entity_id: Option<String>,
|
|
#[serde(default)]
|
|
ha_outdoor_entity_id: Option<String>,
|
|
#[serde(default = "external_sensor_weight")]
|
|
external_sensor_weight: f64,
|
|
#[serde(default = "max_sensor_difference")]
|
|
max_sensor_difference: f64,
|
|
#[serde(default = "sensor_stale_after")]
|
|
sensor_stale_after_seconds: u64,
|
|
#[serde(default)]
|
|
revision: Option<u64>,
|
|
}
|
|
fn yes() -> bool {
|
|
true
|
|
}
|
|
fn cool() -> String {
|
|
"cool".into()
|
|
}
|
|
fn setpoint() -> f64 {
|
|
24.0
|
|
}
|
|
fn cool_comfort() -> f64 {
|
|
23.0
|
|
}
|
|
fn cool_sleep() -> f64 {
|
|
24.5
|
|
}
|
|
fn cool_away() -> f64 {
|
|
27.0
|
|
}
|
|
fn heat_comfort() -> f64 {
|
|
21.0
|
|
}
|
|
fn heat_sleep() -> f64 {
|
|
19.0
|
|
}
|
|
fn heat_away() -> f64 {
|
|
17.0
|
|
}
|
|
fn hysteresis() -> f64 {
|
|
0.6
|
|
}
|
|
fn cycle() -> u64 {
|
|
180
|
|
}
|
|
fn min_adjust() -> u64 {
|
|
120
|
|
}
|
|
fn standby_offset() -> f64 {
|
|
2.0
|
|
}
|
|
fn external_sensor_weight() -> f64 {
|
|
0.4
|
|
}
|
|
fn max_sensor_difference() -> f64 {
|
|
3.0
|
|
}
|
|
fn sensor_stale_after() -> u64 {
|
|
300
|
|
}
|
|
fn device_source() -> String {
|
|
"device".into()
|
|
}
|
|
|
|
impl ZoneInput {
|
|
fn validate(&self) -> Result<(), AppError> {
|
|
if self.name.trim().is_empty() {
|
|
return Err(AppError::BadRequest("zone name is required".into()));
|
|
}
|
|
for value in [
|
|
self.setpoint,
|
|
self.cool_comfort_setpoint,
|
|
self.cool_sleep_setpoint,
|
|
self.cool_away_setpoint,
|
|
self.heat_comfort_setpoint,
|
|
self.heat_sleep_setpoint,
|
|
self.heat_away_setpoint,
|
|
] {
|
|
if !(8.0..=30.0).contains(&value) {
|
|
return Err(AppError::BadRequest(
|
|
"zone temperatures must be between 8 and 30 C".into(),
|
|
));
|
|
}
|
|
}
|
|
if !(0.1..=5.0).contains(&self.hysteresis) {
|
|
return Err(AppError::BadRequest(
|
|
"hysteresis must be between 0.1 and 5 C".into(),
|
|
));
|
|
}
|
|
if !(0.1..=5.0).contains(&self.cool_hysteresis) {
|
|
return Err(AppError::BadRequest(
|
|
"cooling hysteresis must be between 0.1 and 5 C".into(),
|
|
));
|
|
}
|
|
if !(0.1..=5.0).contains(&self.heat_hysteresis) {
|
|
return Err(AppError::BadRequest(
|
|
"heating hysteresis must be between 0.1 and 5 C".into(),
|
|
));
|
|
}
|
|
if !(0.5..=8.0).contains(&self.standby_offset_c) {
|
|
return Err(AppError::BadRequest(
|
|
"standby offset must be between 0.5 and 8 C".into(),
|
|
));
|
|
}
|
|
if !matches!(self.mode.as_str(), "cool" | "heat") {
|
|
return Err(AppError::BadRequest(
|
|
"zone mode must be cool or heat".into(),
|
|
));
|
|
}
|
|
if !matches!(
|
|
self.sensor_source.as_str(),
|
|
"device" | "home_assistant" | "combined"
|
|
) {
|
|
return Err(AppError::BadRequest("unsupported sensor source".into()));
|
|
}
|
|
if !(0.0..=1.0).contains(&self.external_sensor_weight) {
|
|
return Err(AppError::BadRequest(
|
|
"external sensor weight must be between 0 and 1".into(),
|
|
));
|
|
}
|
|
if !(0.1..=20.0).contains(&self.max_sensor_difference) {
|
|
return Err(AppError::BadRequest(
|
|
"maximum sensor difference must be between 0.1 and 20 C".into(),
|
|
));
|
|
}
|
|
if matches!(self.sensor_source.as_str(), "home_assistant" | "combined")
|
|
&& self
|
|
.ha_entity_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.unwrap_or("")
|
|
.is_empty()
|
|
{
|
|
return Err(AppError::BadRequest(
|
|
"a per-zone Home Assistant room temperature entity_id is required for external or combined temperature control".into(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
fn into_zone(self, id: String, created_at: chrono::DateTime<Utc>) -> Zone {
|
|
Zone {
|
|
id,
|
|
name: self.name.trim().into(),
|
|
device_id: self.device_id,
|
|
enabled: self.enabled,
|
|
mode: self.mode,
|
|
inherit_house_mode: self.inherit_house_mode,
|
|
setpoint: self.setpoint,
|
|
profile_version: 1,
|
|
cool_comfort_setpoint: self.cool_comfort_setpoint,
|
|
cool_sleep_setpoint: self.cool_sleep_setpoint,
|
|
cool_away_setpoint: self.cool_away_setpoint,
|
|
heat_comfort_setpoint: self.heat_comfort_setpoint,
|
|
heat_sleep_setpoint: self.heat_sleep_setpoint,
|
|
heat_away_setpoint: self.heat_away_setpoint,
|
|
hysteresis: self.hysteresis,
|
|
separate_hysteresis: self.separate_hysteresis,
|
|
cool_hysteresis: self.cool_hysteresis,
|
|
heat_hysteresis: self.heat_hysteresis,
|
|
min_on_seconds: self.min_on_seconds,
|
|
min_off_seconds: self.min_off_seconds,
|
|
min_adjust_seconds: self.min_adjust_seconds,
|
|
standby_offset_c: self.standby_offset_c,
|
|
smart_fan: self.smart_fan,
|
|
sensor_source: self.sensor_source,
|
|
ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()),
|
|
ha_outdoor_entity_id: self.ha_outdoor_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,
|
|
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,
|
|
compressor_pending_action: None,
|
|
compressor_pending_since: None,
|
|
compressor_pending_until: None,
|
|
compressor_cancelled_action: None,
|
|
effective_mode: String::new(),
|
|
effective_setpoint: None,
|
|
device_setpoint: None,
|
|
demand: false,
|
|
demand_since: None,
|
|
target_alerted_at: None,
|
|
last_action_at: None,
|
|
created_at,
|
|
updated_at: Utc::now(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn validate_zone_device_assignment(
|
|
state: &AppState,
|
|
device_id: &str,
|
|
current_zone_id: Option<&str>,
|
|
) -> Result<(), AppError> {
|
|
if state
|
|
.db
|
|
.list_zones()?
|
|
.iter()
|
|
.any(|zone| zone.device_id == device_id && current_zone_id != Some(zone.id.as_str()))
|
|
{
|
|
return Err(AppError::BadRequest(
|
|
"a device can belong to only one thermostat zone".into(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn list_zones(State(state): State<AppState>) -> Result<Json<Vec<Zone>>, AppError> {
|
|
Ok(Json(state.db.list_zones()?))
|
|
}
|
|
async fn get_zone(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<Zone>, AppError> {
|
|
state
|
|
.db
|
|
.get_zone(&id)?
|
|
.map(Json)
|
|
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))
|
|
}
|
|
async fn create_zone(
|
|
State(state): State<AppState>,
|
|
Json(input): Json<ZoneInput>,
|
|
) -> Result<(StatusCode, Json<Zone>), AppError> {
|
|
let _configuration_guard = state.lock_configuration_operation().await;
|
|
let _reference_guard = state.lock_automation_operation().await;
|
|
let settings = state.settings.read().await.clone();
|
|
input.validate()?;
|
|
if state.db.get_device(&input.device_id)?.is_none() {
|
|
return Err(AppError::BadRequest("zone device does not exist".into()));
|
|
}
|
|
validate_zone_device_assignment(&state, &input.device_id, None)?;
|
|
// Creating thermostat ownership must not overlap a poll of the device. Otherwise a poll
|
|
// that started before the zone existed could apply its old physical-control snapshot to
|
|
// the newly created zone without participating in the zone operation lock.
|
|
let _device_guard = state.lock_device_operation(&input.device_id).await;
|
|
let mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now());
|
|
canonicalize_zone_ha_entities(&mut zone, &settings.home_assistant);
|
|
state.db.save_zone(&zone)?;
|
|
state.broadcast("zone.created", serde_json::to_value(&zone)?);
|
|
state.wake_zone_control();
|
|
Ok((StatusCode::CREATED, Json(zone)))
|
|
}
|
|
async fn update_zone(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(input): Json<ZoneInput>,
|
|
) -> Result<Json<Zone>, AppError> {
|
|
let _configuration_guard = state.lock_configuration_operation().await;
|
|
let _reference_guard = state.lock_automation_operation().await;
|
|
let settings = state.settings.read().await.clone();
|
|
input.validate()?;
|
|
let _zone_guard = state.lock_zone_operation(&id).await;
|
|
let mut existing = state
|
|
.db
|
|
.get_zone(&id)?
|
|
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
|
if let Some(expected) = input.revision {
|
|
if expected != existing.revision {
|
|
return Err(AppError::Conflict(format!(
|
|
"zone {id} changed; expected revision {expected}, current revision {}",
|
|
existing.revision
|
|
)));
|
|
}
|
|
}
|
|
if state.db.get_device(&input.device_id)?.is_none() {
|
|
return Err(AppError::BadRequest("zone device does not exist".into()));
|
|
}
|
|
validate_zone_device_assignment(&state, &input.device_id, Some(&id))?;
|
|
let device_changed = existing.device_id != input.device_id;
|
|
// Keep the zone lock while taking all involved device locks in stable order. This makes a
|
|
// reassignment atomic against polling of both the old and the new unit and preserves the
|
|
// global zone -> device ordering used by live control paths.
|
|
let mut locked_device_ids = vec![existing.device_id.clone(), input.device_id.clone()];
|
|
locked_device_ids.sort();
|
|
locked_device_ids.dedup();
|
|
let mut device_guards = Vec::with_capacity(locked_device_ids.len());
|
|
for device_id in &locked_device_ids {
|
|
device_guards.push(state.lock_device_operation(device_id).await);
|
|
}
|
|
if !device_changed {
|
|
// Polling may have updated takeover/runtime state while we were waiting for the
|
|
// device lock. Re-read under both locks before building the replacement Zone.
|
|
existing = state
|
|
.db
|
|
.get_zone(&id)?
|
|
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
|
if let Some(expected) = input.revision {
|
|
if expected != existing.revision {
|
|
return Err(AppError::Conflict(format!(
|
|
"zone {id} changed; expected revision {expected}, current revision {}",
|
|
existing.revision
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
let mut zone = input.into_zone(id, existing.created_at);
|
|
if !device_changed {
|
|
zone.device_temperature = existing.device_temperature;
|
|
zone.external_temperature = existing.external_temperature;
|
|
zone.current_temperature = existing.current_temperature;
|
|
zone.control_temperature_source = existing.control_temperature_source;
|
|
zone.active_preset = existing.active_preset;
|
|
zone.manual_preset = existing.manual_preset;
|
|
zone.manual_setpoint = existing.manual_setpoint;
|
|
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;
|
|
zone.device_manual_override_until = existing.device_manual_override_until;
|
|
zone.device_manual_override_fields = existing.device_manual_override_fields;
|
|
zone.device_manual_override_baseline = existing.device_manual_override_baseline;
|
|
zone.revision = existing.revision.saturating_add(1);
|
|
zone.control_owner = existing.control_owner;
|
|
zone.control_source = existing.control_source;
|
|
zone.control_since = existing.control_since;
|
|
zone.control_resume_at = existing.control_resume_at;
|
|
zone.control_reason = existing.control_reason;
|
|
zone.last_power_change_at = existing.last_power_change_at;
|
|
zone.last_mode_change_at = existing.last_mode_change_at;
|
|
zone.lockout_until = existing.lockout_until;
|
|
zone.lockout_reason = existing.lockout_reason;
|
|
zone.compressor_pending_action = existing.compressor_pending_action;
|
|
zone.compressor_pending_since = existing.compressor_pending_since;
|
|
zone.compressor_pending_until = existing.compressor_pending_until;
|
|
zone.compressor_cancelled_action = existing.compressor_cancelled_action;
|
|
zone.effective_mode = existing.effective_mode;
|
|
zone.effective_setpoint = existing.effective_setpoint;
|
|
zone.device_setpoint = existing.device_setpoint;
|
|
zone.demand = existing.demand;
|
|
zone.demand_since = existing.demand_since;
|
|
zone.target_alerted_at = existing.target_alerted_at;
|
|
zone.last_action_at = existing.last_action_at;
|
|
} else {
|
|
// A new physical unit starts with a clean ownership/runtime state. Never transfer
|
|
// demand, sensor cache or remote-control takeover from the previous device.
|
|
ensure_device_stopped_for_detach_locked(
|
|
&state,
|
|
&existing.device_id,
|
|
"zone.device_reassigned",
|
|
)
|
|
.await?;
|
|
zone.revision = existing.revision.saturating_add(1);
|
|
}
|
|
canonicalize_zone_ha_entities(&mut zone, &settings.home_assistant);
|
|
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);
|
|
engine::rearm_compressor_queue(&mut zone);
|
|
zone.enabled = false;
|
|
}
|
|
state.db.save_zone(&zone)?;
|
|
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
|
drop(device_guards);
|
|
if power_off_device {
|
|
power_off_zone_device(&state, &zone, "zone.disabled").await;
|
|
}
|
|
state.wake_zone_control();
|
|
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 = engine::normalize_thermostat_target(target);
|
|
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
|
|
// pointing at an obsolete boundary (and so lock order stays schedule -> zone -> device).
|
|
let _schedule_guard = state.lock_schedule_operation().await;
|
|
// A local thermostat click is an ownership transition, not merely a field update.
|
|
// Serialize it with the complete thermostat cycle so an already-running arbitration
|
|
// pass cannot reach an automatic ON after the user has committed local OFF. Lock order
|
|
// stays schedule -> cycle -> zone -> device, matching schedule mutation paths.
|
|
let _cycle_guard = state.lock_zone_control_cycle().await;
|
|
// Serialize quick-thermostat changes with the same device lock used by GREE polling and
|
|
// manual-takeover detection. Without this, a poll that started just before a Web/HA
|
|
// thermostat action could save an older zone snapshot afterwards and resurrect a false
|
|
// "physical/pilot" takeover.
|
|
let _zone_guard = state.lock_zone_operation(id).await;
|
|
let device_id = state
|
|
.db
|
|
.get_zone(id)?
|
|
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?
|
|
.device_id;
|
|
let device_guard = state.lock_device_operation(&device_id).await;
|
|
let mut zone = state
|
|
.db
|
|
.get_zone(id)?
|
|
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
|
let was_enabled = zone.enabled;
|
|
let schedules = state.db.list_schedules()?;
|
|
let rearm_compressor = patch.power.is_some()
|
|
|| patch.setpoint.is_some()
|
|
|| patch.mode.is_some()
|
|
|| patch.enabled.is_some()
|
|
|| patch.preset.is_some()
|
|
|| patch.clear_override.unwrap_or(false)
|
|
|| patch.clear_device_manual_override.unwrap_or(false)
|
|
|| patch.clear_local_thermostat_override.unwrap_or(false)
|
|
|| patch.temporary_quick_thermostat.is_some()
|
|
|| patch.clear_temporary_quick_thermostat.unwrap_or(false);
|
|
if rearm_compressor {
|
|
engine::rearm_compressor_queue(&mut zone);
|
|
}
|
|
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);
|
|
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();
|
|
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() {
|
|
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.
|
|
zone.temporary_quick_thermostat = None;
|
|
}
|
|
}
|
|
if let Some(request) = patch.temporary_quick_thermostat.as_ref() {
|
|
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
|
|
// 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
|
|
// it is switched off and the delayed hand-back completes, Auto is selected, or the
|
|
// user explicitly resumes automation.
|
|
if power && (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) {
|
|
zone.manual_override_until = None;
|
|
}
|
|
}
|
|
if let Some(value) = patch.setpoint {
|
|
if !(8.0..=30.0).contains(&value) {
|
|
return Err(AppError::BadRequest(
|
|
"zone setpoint must be between 8 and 30 C".into(),
|
|
));
|
|
}
|
|
let value = (value * 10.0).round() / 10.0;
|
|
zone.setpoint = value;
|
|
zone.manual_setpoint = Some(value);
|
|
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.condition_started_at = None;
|
|
session.condition_last_observed_at = 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())
|
|
};
|
|
}
|
|
if let Some(value) = patch.mode.as_deref() {
|
|
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!(),
|
|
}
|
|
}
|
|
}
|
|
if let Some(value) = patch.preset.as_deref() {
|
|
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());
|
|
if value != "custom" {
|
|
session.deferred_setpoint = None;
|
|
}
|
|
}
|
|
} 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!(),
|
|
}
|
|
}
|
|
}
|
|
if patch.clear_override.unwrap_or(false) {
|
|
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());
|
|
session.deferred_setpoint = None;
|
|
}
|
|
} 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;
|
|
}
|
|
}
|
|
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);
|
|
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
|
|
zone.revision = zone.revision.saturating_add(1);
|
|
zone.updated_at = Utc::now();
|
|
state.db.save_zone(&zone)?;
|
|
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
|
// Release before any device command below; engine::send_command acquires this same lock.
|
|
drop(device_guard);
|
|
if was_enabled && !zone.enabled {
|
|
power_off_zone_device(state, &zone, "zone.quick_disabled").await;
|
|
} else if patch.power == Some(false) {
|
|
// Local OFF is an explicit safety/user intent. Force one OFF frame instead of
|
|
// trusting the cached device.power flag; a stale cache must never let a running
|
|
// unit survive a thermostat OFF click. The cycle lock held by this handler keeps
|
|
// automatic arbitration out until the OFF marker and physical command are complete.
|
|
if let Err(err) = engine::force_power_off_device(state, &zone.device_id).await {
|
|
state.log(
|
|
"error",
|
|
"zone.local_power_error",
|
|
&err.to_string(),
|
|
json!({
|
|
"zone_id": zone.id, "device_id": zone.device_id, "power": false
|
|
}),
|
|
);
|
|
}
|
|
state.wake_zone_control();
|
|
} else {
|
|
state.wake_zone_control();
|
|
}
|
|
state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({
|
|
"zone_id": zone.id, "setpoint": zone.setpoint, "manual_setpoint": zone.manual_setpoint, "mode": zone.mode,
|
|
"inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset,
|
|
"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)
|
|
}
|
|
|
|
async fn update_zone_control(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(patch): Json<ZoneControlPatch>,
|
|
) -> Result<Json<Zone>, AppError> {
|
|
Ok(Json(
|
|
apply_zone_control_patch(&state, &id, patch, "web.zone_thermostat").await?,
|
|
))
|
|
}
|
|
|
|
async fn ensure_device_stopped_for_detach_locked(
|
|
state: &AppState,
|
|
device_id: &str,
|
|
source: &str,
|
|
) -> Result<(), AppError> {
|
|
let Some(device) = state.db.get_device(device_id)? else {
|
|
return Ok(());
|
|
};
|
|
if !device.enabled {
|
|
return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into()));
|
|
}
|
|
// Force one OFF transition even when the cached state already says OFF. A remote change
|
|
// may not have been polled yet and detaching must not leave a running unit without owner.
|
|
engine::force_power_off_device_locked(state, device_id).await?;
|
|
state.log(
|
|
"info",
|
|
"zone.detach_power_off",
|
|
&format!(
|
|
"Powered off {} before detaching thermostat ownership",
|
|
device.name
|
|
),
|
|
json!({
|
|
"device_id": device.id, "source": source
|
|
}),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn ensure_device_stopped_for_detach(
|
|
state: &AppState,
|
|
device_id: &str,
|
|
source: &str,
|
|
) -> Result<(), AppError> {
|
|
let _device_guard = state.lock_device_operation(device_id).await;
|
|
ensure_device_stopped_for_detach_locked(state, device_id, source).await
|
|
}
|
|
|
|
async fn power_off_zone_device(state: &AppState, zone: &Zone, source: &str) {
|
|
let Ok(Some(device)) = state.db.get_device(&zone.device_id) else {
|
|
return;
|
|
};
|
|
if !device.enabled {
|
|
return;
|
|
}
|
|
if let Err(err) = engine::force_power_off_device(state, &device.id).await {
|
|
state.log(
|
|
"error",
|
|
"zone.disable_power_error",
|
|
&err.to_string(),
|
|
json!({
|
|
"zone_id": zone.id,
|
|
"device_id": device.id,
|
|
"device_name": device.name,
|
|
"source": source,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
async fn cancel_zone_compressor_queue(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
// Serialize cancellation with the thermostat cycle. If cancellation wins this lock, the
|
|
// pending action cannot expire and execute between the UI click and the persisted marker.
|
|
let _cycle_guard = state.lock_zone_control_cycle().await;
|
|
let _zone_guard = state.lock_zone_operation(&id).await;
|
|
let mut zone = state
|
|
.db
|
|
.get_zone(&id)?
|
|
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
|
let cancelled = zone.compressor_pending_action.clone();
|
|
if let Some(action) = cancelled.clone() {
|
|
zone.compressor_cancelled_action = Some(action);
|
|
zone.compressor_pending_action = None;
|
|
zone.compressor_pending_since = None;
|
|
zone.compressor_pending_until = None;
|
|
zone.lockout_until = None;
|
|
zone.lockout_reason = None;
|
|
zone.revision = zone.revision.saturating_add(1);
|
|
zone.updated_at = Utc::now();
|
|
state.db.save_zone(&zone)?;
|
|
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
|
state.log(
|
|
"info",
|
|
"zone.compressor_queue_cancelled",
|
|
&format!("Cancelled compressor-protection task for {}", zone.name),
|
|
json!({
|
|
"zone_id": zone.id, "device_id": zone.device_id, "action": cancelled.clone()
|
|
}),
|
|
);
|
|
}
|
|
if cancelled.is_some() {
|
|
state.wake_zone_control();
|
|
}
|
|
Ok(Json(
|
|
json!({"cancelled": cancelled.is_some(), "action": cancelled, "zone": zone}),
|
|
))
|
|
}
|
|
|
|
async fn cancel_all_compressor_queues(
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
// Stop the thermostat arbiter while taking all zone locks, so a task cannot expire and
|
|
// execute between discovering it and persisting the cancellation marker.
|
|
let _cycle_guard = state.lock_zone_control_cycle().await;
|
|
let mut zone_ids: Vec<String> = state
|
|
.db
|
|
.list_zones()?
|
|
.into_iter()
|
|
.map(|zone| zone.id)
|
|
.collect();
|
|
zone_ids.sort();
|
|
zone_ids.dedup();
|
|
let mut guards = Vec::with_capacity(zone_ids.len());
|
|
for zone_id in &zone_ids {
|
|
guards.push(state.lock_zone_operation(zone_id).await);
|
|
}
|
|
|
|
let mut cancelled = 0usize;
|
|
let mut zones = Vec::new();
|
|
for zone_id in &zone_ids {
|
|
let Some(mut zone) = state.db.get_zone(zone_id)? else {
|
|
continue;
|
|
};
|
|
let Some(action) = zone.compressor_pending_action.clone() else {
|
|
continue;
|
|
};
|
|
zone.compressor_cancelled_action = Some(action.clone());
|
|
zone.compressor_pending_action = None;
|
|
zone.compressor_pending_since = None;
|
|
zone.compressor_pending_until = None;
|
|
zone.lockout_until = None;
|
|
zone.lockout_reason = None;
|
|
zone.revision = zone.revision.saturating_add(1);
|
|
zone.updated_at = Utc::now();
|
|
state.db.save_zone(&zone)?;
|
|
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
|
state.log("info", "zone.compressor_queue_cancelled", &format!("Cancelled compressor-protection task for {}", zone.name), json!({
|
|
"zone_id": zone.id, "device_id": zone.device_id, "action": action, "source": "cancel_all"
|
|
}));
|
|
cancelled += 1;
|
|
zones.push(zone);
|
|
}
|
|
if cancelled > 0 {
|
|
state.wake_zone_control();
|
|
}
|
|
Ok(Json(json!({"cancelled": cancelled, "zones": zones})))
|
|
}
|