This commit is contained in:
Mateusz Gruszczyński
2026-08-25 22:14:14 +02:00
parent 0d65a6a946
commit 9fa0a5399e
16 changed files with 540 additions and 84 deletions
+69 -10
View File
@@ -78,7 +78,7 @@ pub fn router(state: AppState) -> Router {
let home_assistant_api = Router::new()
.route("/api/integrations/home-assistant/devices", get(list_devices))
.route("/api/integrations/home-assistant/devices/:id/command", post(command_device))
.route("/api/integrations/home-assistant/devices/:id/command", post(command_home_assistant_device))
.route("/api/integrations/home-assistant/control-plan", get(control_plan))
.route("/api/integrations/home-assistant/groups", get(list_home_assistant_groups))
.route("/api/integrations/home-assistant/groups/:id/control", post(update_home_assistant_group_control))
@@ -413,7 +413,14 @@ async fn poll_device(State(state): State<AppState>, Path(id): Path<String>) -> R
}
async fn command_device(State(state): State<AppState>, Path(id): Path<String>, Json(command): Json<DeviceCommand>) -> Result<Json<Device>, AppError> {
Ok(Json(engine::send_command(&state, &id, command).await?))
Ok(Json(engine::send_manual_command(&state, &id, command, "device.manual_control").await?))
}
async fn command_home_assistant_device(State(state): State<AppState>, Path(id): Path<String>, Json(command): Json<DeviceCommand>) -> Result<Json<Device>, AppError> {
if state.db.list_zones()?.iter().any(|zone| zone.device_id == id && !zone.enabled) {
return Err(AppError::BadRequest("device belongs to a disabled thermostat zone; use technical device control for manual operation".into()));
}
Ok(Json(engine::send_manual_command(&state, &id, command, "home_assistant.device_manual_control").await?))
}
#[derive(Debug, Deserialize)]
@@ -509,6 +516,7 @@ impl ZoneInput {
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,
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(),
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(),
@@ -552,6 +560,10 @@ 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.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.effective_mode = existing.effective_mode;
zone.effective_setpoint = existing.effective_setpoint;
zone.device_setpoint = existing.device_setpoint;
@@ -561,13 +573,22 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
zone.last_action_at = existing.last_action_at;
let settings = state.settings.read().await.clone();
canonicalize_zone_ha_entity(&mut zone, &settings);
let power_off_device = existing.enabled && !zone.enabled;
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
if power_off_device {
power_off_zone_device(&state, &zone, "zone.disabled").await;
}
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}")))?;
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();
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())); }
@@ -608,17 +629,35 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
zone.manual_override_until = None;
}
if let Some(value) = patch.enabled { zone.enabled = value; }
let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false };
zone.updated_at = Utc::now();
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;
}
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
"override_until": zone.manual_override_until, "enabled": zone.enabled,
"device_manual_override_cleared": device_override_cleared
}));
Ok(Json(zone))
}
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 || !device.power { return; }
if let Err(err) = engine::send_command(state, &device.id, DeviceCommand { power: Some(false), ..Default::default() }).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,
}));
}
}
#[derive(Debug, Deserialize)]
struct GroupInput {
name: String,
@@ -825,6 +864,8 @@ 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,
"device_manual_override": zone.device_manual_override,
"device_manual_override_until": zone.device_manual_override_until,
})).collect::<Vec<_>>();
output.push(json!({
@@ -863,10 +904,10 @@ async fn update_home_assistant_group_control(
#[derive(Debug, Deserialize)]
struct HouseControlPatch { mode: String }
fn enable_all_groups(state: &AppState) -> Result<(), AppError> {
fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> {
for mut group in state.db.list_groups()? {
if group.power_enabled { continue; }
group.power_enabled = true;
if group.power_enabled == power { continue; }
group.power_enabled = power;
group.updated_at = Utc::now();
state.db.save_group(&group)?;
state.broadcast("group.updated", serde_json::to_value(&group)?);
@@ -876,8 +917,19 @@ fn enable_all_groups(state: &AppState) -> Result<(), AppError> {
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)
.map(|zone| zone.device_id)
.collect()
} else {
std::collections::HashSet::new()
};
for device in state.db.list_devices()? {
if !device.enabled || device.power == power { continue; }
// Whole-house ON only operates thermostat-managed, enabled zones. Devices with
// a disabled zone (or no zone at all) remain manual/technical Devices controls.
if power && !enabled_zone_devices.contains(&device.id) { continue; }
let command = DeviceCommand { power: Some(power), ..Default::default() };
if let Err(err) = engine::send_command(state, &device.id, command).await {
state.log("error", "house.power_all_error", &err.to_string(), json!({
@@ -914,7 +966,7 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
};
state.broadcast("settings.updated", payload.clone());
if activate_all {
enable_all_groups(&state)?;
set_all_groups_power(&state, true)?;
let failed = command_all_enabled_devices_power(&state, true, "house_mode").await?;
if !failed.is_empty() {
state.log("warn", "house.mode_power_partial", "House mode enabled master power, but some devices could not be powered on", json!({
@@ -933,6 +985,9 @@ struct HousePowerPatch { power: bool }
async fn update_house_power(State(state): State<AppState>, Json(input): Json<HousePowerPatch>) -> Result<Json<Value>, AppError> {
// Whole-house power is independent from the thermostat mode. Turning it off is
// authoritative, while house mode `off` remains a separate "do not control" state.
if !input.power {
engine::clear_all_device_manual_overrides(&state, "house_power_off")?;
}
{
let mut settings = state.settings.write().await;
if settings.house_power_enabled != input.power {
@@ -943,20 +998,24 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
}
}
if input.power { enable_all_groups(&state)?; }
// Global power is a true cascade across group gates. Disabled thermostat zones stay
// disabled and are therefore not powered by the ON direction of this command.
set_all_groups_power(&state, input.power)?;
let failed = command_all_enabled_devices_power(&state, input.power, "house_power").await?;
let devices = state.db.list_devices()?;
let groups = state.db.list_groups()?;
let settings = state.settings.read().await;
let settings_payload = public_settings(&settings);
drop(settings);
state.log("info", "house.power_all", if input.power { "Whole-house power enabled; all enabled devices powered on" } else { "Whole-house power disabled; all enabled devices powered off" }, json!({
state.log("info", "house.power_all", if input.power { "Whole-house power enabled; all enabled thermostat zones powered on" } else { "Whole-house power disabled; all groups and enabled devices powered off" }, json!({
"power": input.power,
"failed": failed.len(),
}));
Ok(Json(json!({
"power": input.power,
"devices": devices,
"groups": groups,
"settings": settings_payload,
"failed": failed,
})))
@@ -979,7 +1038,7 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
public_settings(&settings)
};
state.broadcast("settings.updated", settings_payload.clone());
enable_all_groups(&state)?;
set_all_groups_power(&state, true)?;
let schedules = state.db.list_schedules()?;
let mut zones = state.db.list_zones()?;