v0.7.10
This commit is contained in:
+81
-22
@@ -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?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user