This commit is contained in:
Mateusz Gruszczyński
2026-08-27 16:38:51 +02:00
parent 7102d05cb3
commit ba61cb77b1
19 changed files with 406 additions and 109 deletions
+81 -22
View File
@@ -530,7 +530,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,
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,
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: 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,
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None,
@@ -577,6 +577,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
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.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;
@@ -605,22 +606,44 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
Ok(Json(zone))
}
async fn update_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, AppError> {
let mut zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch) -> Result<Zone, AppError> {
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()?;
// Any explicit thermostat action means the user is handing ownership back to the zone
// controller. This includes the dedicated Resume automation button.
let resume_device_automation = patch.clear_device_manual_override.unwrap_or(false)
|| patch.setpoint.is_some() || patch.mode.is_some() || patch.preset.is_some() || patch.enabled.is_some();
let resume_device_takeover = patch.clear_device_manual_override.unwrap_or(false);
let resume_local_thermostat = patch.clear_local_thermostat_override.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_local_thermostat {
zone.local_thermostat_power = None;
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
}
if let Some(power) = patch.power {
zone.local_thermostat_power = Some(power);
if power { zone.enabled = true; }
// A manually started local thermostat keeps an already selected target/profile until
// the user chooses Auto or resumes global 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 * 2.0).round() / 2.0;
zone.setpoint = value;
zone.manual_setpoint = Some(value);
zone.effective_setpoint = Some(value);
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
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() {
match value {
@@ -642,7 +665,11 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
"comfort" | "sleep" | "away" | "custom" => {
zone.manual_preset = Some(value.to_string());
zone.manual_setpoint = None;
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
zone.manual_override_until = if zone.local_thermostat_power == Some(true) {
None
} else {
engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now())
};
}
_ => return Err(AppError::BadRequest("unsupported zone preset".into())),
}
@@ -652,7 +679,10 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
zone.manual_setpoint = None;
zone.manual_override_until = None;
}
if let Some(value) = patch.enabled { zone.enabled = value; }
if let Some(value) = patch.enabled {
zone.enabled = value;
if !value { zone.local_thermostat_power = None; }
}
let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false };
let house_mode = state.settings.read().await.house_mode.clone();
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
@@ -660,20 +690,33 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
if was_enabled && !zone.enabled {
power_off_zone_device(&state, &zone, "zone.quick_disabled").await;
power_off_zone_device(state, &zone, "zone.quick_disabled").await;
} else if patch.power == Some(false) {
if let Err(err) = engine::send_command(
state,
&zone.device_id,
DeviceCommand { power: Some(false), ..Default::default() },
).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 {
// Do not force users to wait for the fixed zone interval after selecting a profile,
// changing the target/mode or re-enabling a thermostat. The regulator still owns the
// physical command and therefore keeps all group/master/manual-override safeguards.
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,
"device_manual_override_cleared": device_override_cleared
}));
Ok(Json(zone))
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).await?))
}
@@ -685,17 +728,18 @@ async fn update_zone_manual_power(
Path(id): Path<String>,
Json(input): Json<ZoneManualPowerPatch>,
) -> Result<Json<Value>, AppError> {
let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let device = engine::send_manual_command(
// Backward-compatible route: despite the historical name this now controls the local
// thermostat, not the physical unit like a pilot. Direct/pilot semantics stay on /devices.
let zone = apply_zone_control_patch(
&state,
&zone.device_id,
DeviceCommand { power: Some(input.power), ..Default::default() },
"zone.quick_manual_power",
&id,
ZoneControlPatch { power: Some(input.power), ..Default::default() },
).await?;
let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let device = state.db.get_device(&zone.device_id)?;
Ok(Json(json!({"zone": zone, "device": device})))
}
async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> {
let Some(device) = state.db.get_device(device_id)? else { return Ok(()); };
if !device.enabled {
@@ -930,6 +974,7 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
"demand": zone.demand,
"control_source": zone.control_source,
"current_schedule": zone.current_schedule_name,
"local_thermostat_power": zone.local_thermostat_power,
"device_manual_override": zone.device_manual_override,
"device_manual_override_until": zone.device_manual_override_until,
})).collect::<Vec<_>>();
@@ -981,11 +1026,24 @@ fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> {
Ok(())
}
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() { continue; }
zone.local_thermostat_power = None;
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
cleared += 1;
}
Ok(cleared)
}
async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result<Vec<Value>, AppError> {
let mut failed = Vec::new();
let enabled_zone_devices: std::collections::HashSet<String> = if power {
state.db.list_zones()?.into_iter()
.filter(|zone| zone.enabled && !zone.device_manual_override)
.filter(|zone| zone.enabled && !zone.device_manual_override && zone.local_thermostat_power != Some(false))
.map(|zone| zone.device_id)
.collect()
} else {
@@ -1074,6 +1132,7 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
set_all_groups_power(&state, input.power)?;
if !input.power {
engine::clear_all_device_manual_overrides(&state, "house_power_off")?;
clear_all_local_thermostat_overrides(&state)?;
}
let failed = command_all_enabled_devices_power(&state, input.power, "house_power").await?;