v0.6.8
This commit is contained in:
+180
-6
@@ -23,7 +23,7 @@ use crate::{
|
||||
home_assistant,
|
||||
influxdb,
|
||||
notifications,
|
||||
models::{ApiTokenInfo, Automation, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
|
||||
models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GroupControlPatch, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
|
||||
protocol::merge_discovered,
|
||||
state::AppState,
|
||||
};
|
||||
@@ -51,6 +51,9 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/zones/:id", get(get_zone).put(update_zone).delete(delete_zone))
|
||||
.route("/api/zones/:id/control", post(update_zone_control))
|
||||
.route("/api/zones/:id/schedule-template", post(apply_schedule_template))
|
||||
.route("/api/groups", get(list_groups).post(create_group))
|
||||
.route("/api/groups/:id", get(get_group).put(update_group).delete(delete_group))
|
||||
.route("/api/groups/:id/control", post(update_group_control))
|
||||
.route("/api/house/control", post(update_house_control))
|
||||
.route("/api/house/power", post(update_house_power))
|
||||
.route("/api/house/preset", post(update_house_preset))
|
||||
@@ -205,6 +208,7 @@ async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
|
||||
Ok(json!({
|
||||
"devices": state.db.list_devices()?,
|
||||
"zones": state.db.list_zones()?,
|
||||
"groups": state.db.list_groups()?,
|
||||
"schedules": state.db.list_schedules()?,
|
||||
"automations": state.db.list_automations()?,
|
||||
"access_tokens": state.db.list_api_tokens()?,
|
||||
@@ -367,7 +371,12 @@ async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Jso
|
||||
}
|
||||
|
||||
async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
let removed_zone_ids: std::collections::HashSet<String> = state.db.list_zones()?.into_iter()
|
||||
.filter(|zone| zone.device_id == id)
|
||||
.map(|zone| zone.id)
|
||||
.collect();
|
||||
if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); }
|
||||
remove_zone_ids_from_groups(&state, &removed_zone_ids)?;
|
||||
state.log("info", "device.deleted", "Device deleted", json!({"device_id": id}));
|
||||
state.broadcast("device.deleted", json!({"id": id}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
@@ -591,9 +600,121 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
|
||||
Ok(Json(zone))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GroupInput {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
zone_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
power_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
fn normalize_group_zone_ids(zone_ids: Vec<String>) -> Vec<String> {
|
||||
let mut values: Vec<String> = zone_ids.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect();
|
||||
values.sort();
|
||||
values.dedup();
|
||||
values
|
||||
}
|
||||
|
||||
fn validate_group_input(state: &AppState, input: &GroupInput) -> Result<Vec<String>, AppError> {
|
||||
if input.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("group name is required".into()));
|
||||
}
|
||||
let zone_ids = normalize_group_zone_ids(input.zone_ids.clone());
|
||||
if zone_ids.is_empty() {
|
||||
return Err(AppError::BadRequest("group must contain at least one zone".into()));
|
||||
}
|
||||
for zone_id in &zone_ids {
|
||||
if state.db.get_zone(zone_id)?.is_none() {
|
||||
return Err(AppError::BadRequest(format!("group references missing zone {zone_id}")));
|
||||
}
|
||||
}
|
||||
Ok(zone_ids)
|
||||
}
|
||||
|
||||
async fn list_groups(State(state): State<AppState>) -> Result<Json<Vec<ClimateGroup>>, AppError> {
|
||||
Ok(Json(state.db.list_groups()?))
|
||||
}
|
||||
|
||||
async fn get_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<ClimateGroup>, AppError> {
|
||||
state.db.get_group(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("group {id}")))
|
||||
}
|
||||
|
||||
async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInput>) -> Result<(StatusCode, Json<ClimateGroup>), AppError> {
|
||||
let zone_ids = validate_group_input(&state, &input)?;
|
||||
let now = Utc::now();
|
||||
let group = ClimateGroup {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name: input.name.trim().to_string(),
|
||||
zone_ids,
|
||||
power_enabled: input.power_enabled.unwrap_or(true),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state.db.save_group(&group)?;
|
||||
state.broadcast("group.created", serde_json::to_value(&group)?);
|
||||
Ok((StatusCode::CREATED, Json(group)))
|
||||
}
|
||||
|
||||
async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<GroupInput>) -> Result<Json<ClimateGroup>, AppError> {
|
||||
let existing = state.db.get_group(&id)?.ok_or_else(|| AppError::NotFound(format!("group {id}")))?;
|
||||
let zone_ids = validate_group_input(&state, &input)?;
|
||||
let group = ClimateGroup {
|
||||
id,
|
||||
name: input.name.trim().to_string(),
|
||||
zone_ids,
|
||||
power_enabled: input.power_enabled.unwrap_or(existing.power_enabled),
|
||||
created_at: existing.created_at,
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
state.db.save_group(&group)?;
|
||||
state.broadcast("group.updated", serde_json::to_value(&group)?);
|
||||
Ok(Json(group))
|
||||
}
|
||||
|
||||
async fn delete_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
if state.db.list_automations()?.iter().any(|item| item.action_group_id.as_deref() == Some(id.as_str())) {
|
||||
return Err(AppError::BadRequest("group is used by an automation; remove or retarget that automation first".into()));
|
||||
}
|
||||
if !state.db.delete_group(&id)? { return Err(AppError::NotFound(format!("group {id}"))); }
|
||||
state.broadcast("group.deleted", json!({"id": id}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
fn remove_zone_ids_from_groups(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
|
||||
if zone_ids.is_empty() { return Ok(()); }
|
||||
for mut group in state.db.list_groups()? {
|
||||
let before = group.zone_ids.len();
|
||||
group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id));
|
||||
if group.zone_ids.len() == before { continue; }
|
||||
group.updated_at = Utc::now();
|
||||
state.db.save_group(&group)?;
|
||||
state.broadcast("group.updated", serde_json::to_value(&group)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_group_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<GroupControlPatch>) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(engine::control_group(&state, &id, patch, "group.quick_control").await?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HouseControlPatch { mode: String }
|
||||
|
||||
fn enable_all_groups(state: &AppState) -> Result<(), AppError> {
|
||||
for mut group in state.db.list_groups()? {
|
||||
if group.power_enabled { continue; }
|
||||
group.power_enabled = true;
|
||||
group.updated_at = Utc::now();
|
||||
state.db.save_group(&group)?;
|
||||
state.broadcast("group.updated", serde_json::to_value(&group)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result<Vec<Value>, AppError> {
|
||||
let mut failed = Vec::new();
|
||||
for device in state.db.list_devices()? {
|
||||
@@ -634,6 +755,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)?;
|
||||
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!({
|
||||
@@ -662,6 +784,7 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
|
||||
}
|
||||
}
|
||||
|
||||
if input.power { enable_all_groups(&state)?; }
|
||||
let failed = command_all_enabled_devices_power(&state, input.power, "house_power").await?;
|
||||
|
||||
let devices = state.db.list_devices()?;
|
||||
@@ -697,6 +820,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)?;
|
||||
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let mut zones = state.db.list_zones()?;
|
||||
@@ -778,6 +902,9 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
|
||||
|
||||
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); }
|
||||
let mut removed = std::collections::HashSet::new();
|
||||
removed.insert(id.clone());
|
||||
remove_zone_ids_from_groups(&state, &removed)?;
|
||||
state.broadcast("zone.deleted", json!({"id": id}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -851,8 +978,13 @@ struct AutomationInput {
|
||||
threshold: Option<f64>,
|
||||
#[serde(default)]
|
||||
at_time: Option<String>,
|
||||
#[serde(default)]
|
||||
action_device_id: String,
|
||||
#[serde(default)]
|
||||
action_group_id: Option<String>,
|
||||
#[serde(default)]
|
||||
action_preset: Option<String>,
|
||||
#[serde(default)]
|
||||
action: DeviceCommand,
|
||||
#[serde(default = "automation_cooldown")]
|
||||
cooldown_seconds: u64,
|
||||
@@ -873,12 +1005,36 @@ impl AutomationInput {
|
||||
}
|
||||
_ => return Err(AppError::BadRequest("unsupported automation trigger".into())),
|
||||
}
|
||||
let action_group_id = self.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty());
|
||||
if action_group_id.is_none() && self.action_device_id.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("automation action needs a device or group".into()));
|
||||
}
|
||||
if let Some(preset) = self.action_preset.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if action_group_id.is_none() {
|
||||
return Err(AppError::BadRequest("automation preset actions require a group target".into()));
|
||||
}
|
||||
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
|
||||
return Err(AppError::BadRequest("unsupported group automation preset".into()));
|
||||
}
|
||||
}
|
||||
if action_group_id.is_some() {
|
||||
if let Some(mode) = self.action.mode.as_deref() {
|
||||
if !matches!(mode, "auto" | "house" | "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("group automation mode must be house, cool or heat".into()));
|
||||
}
|
||||
}
|
||||
if self.action.target_temperature.is_some() {
|
||||
return Err(AppError::BadRequest("group automation target temperature is not supported; use a group preset".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn into_automation(self, id: String, created_at: chrono::DateTime<Utc>, last_fired_at: Option<chrono::DateTime<Utc>>) -> Automation {
|
||||
Automation { id, name: self.name.trim().into(), enabled: self.enabled,
|
||||
trigger_kind: self.trigger_kind, trigger_device_id: self.trigger_device_id,
|
||||
threshold: self.threshold, at_time: self.at_time, action_device_id: self.action_device_id,
|
||||
threshold: self.threshold, at_time: self.at_time, action_device_id: self.action_device_id.trim().to_string(),
|
||||
action_group_id: self.action_group_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||
action_preset: self.action_preset.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||
action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at,
|
||||
created_at, updated_at: Utc::now() }
|
||||
}
|
||||
@@ -889,7 +1045,11 @@ async fn get_automation(State(state): State<AppState>, Path(id): Path<String>) -
|
||||
}
|
||||
async fn create_automation(State(state): State<AppState>, Json(input): Json<AutomationInput>) -> Result<(StatusCode, Json<Automation>), AppError> {
|
||||
input.validate()?;
|
||||
if state.db.get_device(&input.action_device_id)?.is_none() { return Err(AppError::BadRequest("automation action device does not exist".into())); }
|
||||
if let Some(group_id) = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if state.db.get_group(group_id)?.is_none() { return Err(AppError::BadRequest("automation action group does not exist".into())); }
|
||||
} else if state.db.get_device(input.action_device_id.trim())?.is_none() {
|
||||
return Err(AppError::BadRequest("automation action device does not exist".into()));
|
||||
}
|
||||
let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None);
|
||||
state.db.save_automation(&item)?;
|
||||
state.broadcast("automation.created", serde_json::to_value(&item)?);
|
||||
@@ -898,7 +1058,11 @@ async fn create_automation(State(state): State<AppState>, Json(input): Json<Auto
|
||||
async fn update_automation(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<AutomationInput>) -> Result<Json<Automation>, AppError> {
|
||||
input.validate()?;
|
||||
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
||||
if state.db.get_device(&input.action_device_id)?.is_none() { return Err(AppError::BadRequest("automation action device does not exist".into())); }
|
||||
if let Some(group_id) = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if state.db.get_group(group_id)?.is_none() { return Err(AppError::BadRequest("automation action group does not exist".into())); }
|
||||
} else if state.db.get_device(input.action_device_id.trim())?.is_none() {
|
||||
return Err(AppError::BadRequest("automation action device does not exist".into()));
|
||||
}
|
||||
let item = input.into_automation(id, existing.created_at, existing.last_fired_at);
|
||||
state.db.save_automation(&item)?;
|
||||
state.broadcast("automation.updated", serde_json::to_value(&item)?);
|
||||
@@ -1352,8 +1516,18 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App
|
||||
if export.schedules.iter().any(|item| !zones.contains(item.zone_id.as_str())) {
|
||||
return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into()));
|
||||
}
|
||||
if export.automations.iter().any(|item| !devices.contains(item.action_device_id.as_str())) {
|
||||
return Err(AppError::BadRequest("import contains an automation referencing a missing device".into()));
|
||||
if export.groups.iter().any(|group| group.zone_ids.iter().any(|zone_id| !zones.contains(zone_id.as_str()))) {
|
||||
return Err(AppError::BadRequest("import contains a group referencing a missing zone".into()));
|
||||
}
|
||||
let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect();
|
||||
if export.automations.iter().any(|item| {
|
||||
if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) {
|
||||
!groups.contains(group_id)
|
||||
} else {
|
||||
!devices.contains(item.action_device_id.as_str())
|
||||
}
|
||||
}) {
|
||||
return Err(AppError::BadRequest("import contains an automation referencing a missing device or group".into()));
|
||||
}
|
||||
if export.automations.iter().any(|item| item.trigger_device_id.as_deref().is_some_and(|id| !devices.contains(id))) {
|
||||
return Err(AppError::BadRequest("import contains an automation trigger referencing a missing device".into()));
|
||||
|
||||
@@ -5,7 +5,7 @@ use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use serde_json::Value;
|
||||
use crate::{
|
||||
models::{ApiTokenInfo, Automation, ConfigurationExport, Device, EventLog, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading},
|
||||
models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, Device, EventLog, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading},
|
||||
queries,
|
||||
};
|
||||
|
||||
@@ -110,6 +110,28 @@ impl Db {
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn save_group(&self, group: &ClimateGroup) -> Result<()> {
|
||||
let payload = Self::to_json(group)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_GROUP,
|
||||
params![group.id, payload, group.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_groups(&self) -> Result<Vec<ClimateGroup>> {
|
||||
self.list_payloads(queries::LIST_GROUPS)
|
||||
}
|
||||
|
||||
pub fn get_group(&self, id: &str) -> Result<Option<ClimateGroup>> {
|
||||
self.get_payload(queries::GET_GROUP, id)
|
||||
}
|
||||
|
||||
pub fn delete_group(&self, id: &str) -> Result<bool> {
|
||||
self.delete_by_id("groups", id)
|
||||
}
|
||||
|
||||
pub fn save_schedule(&self, schedule: &Schedule) -> Result<()> {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
let conn = self.lock()?;
|
||||
@@ -187,6 +209,7 @@ impl Db {
|
||||
let sql = match table {
|
||||
"schedules" => queries::DELETE_SCHEDULE,
|
||||
"automations" => queries::DELETE_AUTOMATION,
|
||||
"groups" => queries::DELETE_GROUP,
|
||||
_ => anyhow::bail!("unsupported table"),
|
||||
};
|
||||
let conn = self.lock()?;
|
||||
@@ -523,6 +546,7 @@ impl Db {
|
||||
settings,
|
||||
devices: self.list_devices()?,
|
||||
zones: self.list_zones()?,
|
||||
groups: self.list_groups()?,
|
||||
schedules: self.list_schedules()?,
|
||||
automations: self.list_automations()?,
|
||||
})
|
||||
@@ -540,6 +564,10 @@ impl Db {
|
||||
let payload = Self::to_json(zone)?;
|
||||
tx.execute(queries::UPSERT_ZONE, params![zone.id, payload, zone.updated_at.to_rfc3339()])?;
|
||||
}
|
||||
for group in &export.groups {
|
||||
let payload = Self::to_json(group)?;
|
||||
tx.execute(queries::UPSERT_GROUP, params![group.id, payload, group.updated_at.to_rfc3339()])?;
|
||||
}
|
||||
for schedule in &export.schedules {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
tx.execute(queries::UPSERT_SCHEDULE, params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()])?;
|
||||
|
||||
+158
-21
@@ -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})),
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ fn default_influx_threshold_days() -> u32 { 30 }
|
||||
fn default_night_start() -> String { "22:00".into() }
|
||||
fn default_night_end() -> String { "06:00".into() }
|
||||
fn default_night_max_fan_speed() -> u8 { 1 }
|
||||
fn default_group_power_enabled() -> bool { true }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Device {
|
||||
@@ -342,6 +343,30 @@ pub struct Zone {
|
||||
fn default_sensor_source() -> String { "device".into() }
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClimateGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub zone_ids: Vec<String>,
|
||||
#[serde(default = "default_group_power_enabled")]
|
||||
pub power_enabled: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct GroupControlPatch {
|
||||
#[serde(default)]
|
||||
pub power: Option<bool>,
|
||||
/// house follows the global house mode; cool/heat set an explicit mode on every member zone.
|
||||
#[serde(default)]
|
||||
pub mode: Option<String>,
|
||||
/// auto clears temporary overrides; comfort/sleep/away apply a temporary preset to every member zone.
|
||||
#[serde(default)]
|
||||
pub preset: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ZoneControlPatch {
|
||||
#[serde(default)]
|
||||
@@ -392,7 +417,15 @@ pub struct Automation {
|
||||
pub threshold: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub at_time: Option<String>,
|
||||
/// Legacy/direct-device target. Empty when this automation targets a group.
|
||||
#[serde(default)]
|
||||
pub action_device_id: String,
|
||||
/// Optional climate group target. When set, the action is applied to every member zone/device.
|
||||
#[serde(default)]
|
||||
pub action_group_id: Option<String>,
|
||||
/// Optional thermostat preset used only for group actions.
|
||||
#[serde(default)]
|
||||
pub action_preset: Option<String>,
|
||||
#[serde(default)]
|
||||
pub action: DeviceCommand,
|
||||
#[serde(default = "default_cooldown")]
|
||||
@@ -613,6 +646,8 @@ pub struct ConfigurationExport {
|
||||
pub settings: RuntimeSettings,
|
||||
pub devices: Vec<Device>,
|
||||
pub zones: Vec<Zone>,
|
||||
#[serde(default)]
|
||||
pub groups: Vec<ClimateGroup>,
|
||||
pub schedules: Vec<Schedule>,
|
||||
pub automations: Vec<Automation>,
|
||||
}
|
||||
@@ -662,6 +697,12 @@ pub struct AutomationPlanRule {
|
||||
pub at_time: Option<String>,
|
||||
pub action_device_id: String,
|
||||
pub action_device_name: String,
|
||||
#[serde(default)]
|
||||
pub action_group_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub action_group_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub action_preset: Option<String>,
|
||||
pub action: DeviceCommand,
|
||||
pub last_fired_at: Option<DateTime<Utc>>,
|
||||
pub next_ready_at: Option<DateTime<Utc>>,
|
||||
|
||||
@@ -36,6 +36,12 @@ CREATE TABLE IF NOT EXISTS zones (
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS climate_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedules (
|
||||
id TEXT PRIMARY KEY,
|
||||
zone_id TEXT NOT NULL,
|
||||
@@ -125,6 +131,8 @@ INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (3, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (4, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (5, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
"#;
|
||||
|
||||
pub const COUNT_DEVICES: &str = "SELECT COUNT(*) FROM devices";
|
||||
@@ -161,6 +169,17 @@ pub const GET_ZONE: &str = "SELECT payload FROM zones WHERE id=?1";
|
||||
pub const DELETE_SCHEDULES_BY_ZONE_ID: &str = "DELETE FROM schedules WHERE zone_id=?1";
|
||||
pub const DELETE_ZONE: &str = "DELETE FROM zones WHERE id=?1";
|
||||
|
||||
pub const UPSERT_GROUP: &str = r#"
|
||||
INSERT INTO climate_groups(id,payload,updated_at) VALUES(?1,?2,?3)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
payload=excluded.payload,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
pub const LIST_GROUPS: &str =
|
||||
"SELECT payload FROM climate_groups ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
|
||||
pub const GET_GROUP: &str = "SELECT payload FROM climate_groups WHERE id=?1";
|
||||
pub const DELETE_GROUP: &str = "DELETE FROM climate_groups WHERE id=?1";
|
||||
|
||||
pub const UPSERT_SCHEDULE: &str = r#"
|
||||
INSERT INTO schedules(id,zone_id,payload,updated_at) VALUES(?1,?2,?3,?4)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
@@ -368,6 +387,7 @@ DELETE FROM ha_readings WHERE id IN (
|
||||
pub const CLEAR_CONFIGURATION: &str = r#"
|
||||
DELETE FROM schedules;
|
||||
DELETE FROM automations;
|
||||
DELETE FROM climate_groups;
|
||||
DELETE FROM zones;
|
||||
DELETE FROM devices;
|
||||
"#;
|
||||
|
||||
Reference in New Issue
Block a user