This commit is contained in:
Mateusz Gruszczyński
2026-08-25 18:10:51 +02:00
parent 440c7c3a8d
commit 26d8f9f83a
21 changed files with 1031 additions and 98 deletions
+158 -21
View File
@@ -1,13 +1,13 @@
use std::time::{Duration, Instant};
use anyhow::Result;
use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday};
use serde_json::json;
use serde_json::{json, Value};
use tokio::time::sleep;
use crate::{
error::AppError,
home_assistant,
influxdb,
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, HaReading, NightModeSettings, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
state::AppState,
};
@@ -331,8 +331,103 @@ fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
Ok(())
}
pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControlPatch, source: &str) -> Result<Value, AppError> {
if let Some(mode) = patch.mode.as_deref() {
if !matches!(mode, "house" | "auto" | "cool" | "heat") {
return Err(AppError::BadRequest("group mode must be house, cool or heat".into()));
}
}
if let Some(preset) = patch.preset.as_deref() {
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
return Err(AppError::BadRequest("group preset must be auto, comfort, sleep or away".into()));
}
}
let mut group = state.db.get_group(group_id)?
.ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?;
let schedules = state.db.list_schedules()?;
let activates_group = patch.mode.is_some() || patch.preset.is_some();
if let Some(power) = patch.power {
group.power_enabled = power;
} else if activates_group {
group.power_enabled = true;
}
group.updated_at = Utc::now();
state.db.save_group(&group)?;
state.broadcast("group.updated", serde_json::to_value(&group)?);
let mut zones = Vec::new();
for zone_id in &group.zone_ids {
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
if let Some(mode) = patch.mode.as_deref() {
match mode {
"house" | "auto" => zone.inherit_house_mode = true,
"cool" | "heat" => {
zone.inherit_house_mode = false;
zone.mode = mode.to_string();
}
_ => {}
}
}
if let Some(preset) = patch.preset.as_deref() {
if preset == "auto" {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
} else {
zone.manual_preset = Some(preset.to_string());
zone.manual_setpoint = None;
zone.manual_override_until = Some(next_schedule_boundary_utc(&zone.id, &schedules, Local::now()));
}
}
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
zones.push(zone);
}
let runtime = state.settings.read().await.clone();
let master_power_enabled = runtime.house_power_enabled;
let should_command_power = patch.power.is_some() || activates_group;
let desired_power = group.power_enabled;
let mut failed = Vec::new();
if should_command_power && (!desired_power || master_power_enabled) {
let mut seen = std::collections::HashSet::new();
for zone in &zones {
if !seen.insert(zone.device_id.clone()) { continue; }
let Some(device) = state.db.get_device(&zone.device_id)? else { continue; };
if !device.enabled || device.power == desired_power { continue; }
if desired_power {
if !zone.enabled { continue; }
let zone_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() };
if zone_mode == "off" { continue; }
}
if let Err(err) = send_command(state, &device.id, DeviceCommand { power: Some(desired_power), ..Default::default() }).await {
state.log("error", "group.power_error", &err.to_string(), json!({
"group_id": group.id, "device_id": device.id, "device_name": device.name,
"power": desired_power, "source": source,
}));
failed.push(json!({"device_id": device.id, "device_name": device.name, "error": err.to_string()}));
}
}
}
state.log("info", source, &format!("Updated group {}", group.name), json!({
"group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset,
"zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled,
}));
Ok(json!({
"group": group,
"zones": zones,
"devices": state.db.list_devices()?,
"failed": failed,
"master_power_enabled": master_power_enabled,
}))
}
async fn control_zones(state: &AppState) -> Result<()> {
let schedules = state.db.list_schedules()?;
let groups = state.db.list_groups()?;
let settings = state.settings.read().await.clone();
// Outdoor temperature is deliberately optional. Prefer the configured Home
@@ -441,6 +536,23 @@ async fn control_zones(state: &AppState) -> Result<()> {
zone.control_temperature_source = control_source;
zone.updated_at = Utc::now();
let blocked_by_group = groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
if blocked_by_group {
zone.effective_mode = "off".into();
zone.demand = false;
zone.demand_since = None;
zone.device_setpoint = None;
if device.power {
if let Err(err) = send_command(state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await {
state.log("error", "group.power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id}));
}
}
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
continue;
}
if discrepancy && previous_source != "device_discrepancy_fallback" {
state.log("warn", "zone.sensor_discrepancy", &format!("Zone {} sensors differ by more than {:.1} C; using GREE sensor", zone.name, zone.max_sensor_difference), json!({
"zone_id": zone.id,
@@ -932,6 +1044,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
let schedules = state.db.list_schedules()?;
let devices = state.db.list_devices()?;
let zones = state.db.list_zones()?;
let groups = state.db.list_groups()?;
let house_preset = zones.first().and_then(|first| {
let first_preset = first.manual_preset.as_deref().unwrap_or("auto");
zones.iter().all(|zone| zone.manual_preset.as_deref().unwrap_or("auto") == first_preset)
@@ -945,22 +1058,20 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
for zone in zones {
let device = devices.iter().find(|item| item.id == zone.device_id);
let effective_mode = if zone.inherit_house_mode {
let configured_effective_mode = if zone.inherit_house_mode {
settings.house_mode.as_str()
} else {
zone.mode.as_str()
};
let active = if effective_mode == "off" {
None
} else {
active_schedule_for_zone(&zone, &schedules, now)
};
let (preset, target) = if effective_mode == "off" {
("manual".to_string(), None)
} else {
let (preset, target) = resolve_zone_target(&zone, active, effective_mode);
(preset, Some(target))
};
let blocked_by_group = groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
let effective_mode = if blocked_by_group { "off" } else { configured_effective_mode };
// Keep the thermostat target readable even while the zone/group/house control is off.
// Home Assistant climate entities otherwise expose target_temperature as unknown.
let target_mode = if configured_effective_mode == "off" { zone.mode.as_str() } else { configured_effective_mode };
let active_for_target = active_schedule_for_zone(&zone, &schedules, now);
let (resolved_preset, resolved_target) = resolve_zone_target(&zone, active_for_target, target_mode);
let active = if effective_mode == "off" { None } else { active_for_target };
let next_events = if effective_mode == "off" {
Vec::new()
} else {
@@ -976,13 +1087,17 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
zone_name: zone.name.clone(),
device_id: zone.device_id.clone(),
device_name: device.map(|item| item.name.clone()).unwrap_or_else(|| zone.device_id.clone()),
enabled: zone.enabled,
enabled: zone.enabled && !blocked_by_group,
mode: effective_mode.to_string(),
configured_mode: zone.mode.clone(),
inherit_house_mode: zone.inherit_house_mode,
preset: if effective_mode == "off" { "manual".into() } else if zone.active_preset.is_empty() { preset } else { zone.active_preset.clone() },
preset: if zone.active_preset.is_empty() { resolved_preset } else { zone.active_preset.clone() },
current_temperature: zone.current_temperature,
target_temperature: if effective_mode == "off" { None } else { zone.effective_setpoint.or(target) },
target_temperature: if !zone.enabled || effective_mode == "off" {
Some(resolved_target)
} else {
zone.effective_setpoint.or(Some(resolved_target))
},
device_setpoint: zone.device_setpoint.or_else(|| device.map(|item| item.target_temperature)),
demand: settings.house_power_enabled && zone.enabled && effective_mode != "off" && zone.demand,
control_source: zone.control_temperature_source.clone(),
@@ -994,7 +1109,14 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
}
let mut rules = Vec::new();
for item in state.db.list_automations()? {
let action_name = devices.iter().find(|device| device.id == item.action_device_id).map(|device| device.name.clone()).unwrap_or_else(|| item.action_device_id.clone());
let action_group_name = item.action_group_id.as_deref()
.and_then(|id| groups.iter().find(|group| group.id == id))
.map(|group| group.name.clone());
let action_name = action_group_name.clone().unwrap_or_else(|| {
devices.iter().find(|device| device.id == item.action_device_id)
.map(|device| device.name.clone())
.unwrap_or_else(|| item.action_device_id.clone())
});
let trigger_name = item.trigger_device_id.as_deref().and_then(|id| devices.iter().find(|device| device.id == id)).map(|device| device.name.clone());
let next_ready_at = item.last_fired_at.map(|last| last + chrono::Duration::seconds(item.cooldown_seconds as i64));
if item.enabled && item.trigger_kind == "time" {
@@ -1013,6 +1135,9 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
at_time: item.at_time,
action_device_id: item.action_device_id,
action_device_name: action_name,
action_group_id: item.action_group_id,
action_group_name,
action_preset: item.action_preset,
action: item.action,
last_fired_at: item.last_fired_at,
next_ready_at,
@@ -1128,12 +1253,24 @@ async fn run_automations(state: &AppState) -> Result<()> {
_ => false,
};
if !should_fire { continue; }
match send_command(state, &item.action_device_id, item.action.clone()).await {
Ok(_) => {
let result = if let Some(group_id) = item.action_group_id.as_deref() {
let group_mode = item.action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() });
control_group(state, group_id, GroupControlPatch {
power: item.action.power,
mode: group_mode,
preset: item.action_preset.clone(),
}, "automation.group").await.map(|_| ())
} else {
send_command(state, &item.action_device_id, item.action.clone()).await.map(|_| ())
};
match result {
Ok(()) => {
item.last_fired_at = Some(Utc::now());
item.updated_at = Utc::now();
state.db.save_automation(&item)?;
state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({"automation_id": item.id}));
state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({
"automation_id": item.id, "group_id": item.action_group_id, "device_id": item.action_device_id
}));
}
Err(err) => state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id})),
}