v0.12.0-preety_code

This commit is contained in:
Mateusz Gruszczyński
2026-09-03 15:33:37 +02:00
parent be67932b47
commit 07be4b85d9
87 changed files with 11691 additions and 3172 deletions
+7 -2
View File
@@ -226,7 +226,10 @@ fn main() {
.file_stem() .file_stem()
.and_then(|value| value.to_str()) .and_then(|value| value.to_str())
.expect("UTF-8 preset id"); .expect("UTF-8 preset id");
if !stem.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') { if !stem
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
{
panic!("{filename}: filename may only contain ASCII letters, digits, '-' and '_'"); panic!("{filename}: filename may only contain ASCII letters, digits, '-' and '_'");
} }
let source = fs::read_to_string(&path) let source = fs::read_to_string(&path)
@@ -260,7 +263,9 @@ fn main() {
.get("flow") .get("flow")
.and_then(Value::as_object) .and_then(Value::as_object)
.unwrap_or_else(|| panic!("{filename}: flow must be an object")); .unwrap_or_else(|| panic!("{filename}: flow must be an object"));
if !flow.get("nodes").is_some_and(Value::is_array) || !flow.get("edges").is_some_and(Value::is_array) { if !flow.get("nodes").is_some_and(Value::is_array)
|| !flow.get("edges").is_some_and(Value::is_array)
{
panic!("{filename}: flow.nodes and flow.edges must be arrays"); panic!("{filename}: flow.nodes and flow.edges must be arrays");
} }
preset_manifest.push(json!({ preset_manifest.push(json!({
@@ -6,4 +6,4 @@
"device_id": "gree-aabbccddeeff" "device_id": "gree-aabbccddeeff"
} }
] ]
} }
+1 -1
View File
@@ -1168,4 +1168,4 @@
"schedules.enabledState": "Enabled", "schedules.enabledState": "Enabled",
"schedules.disabledState": "Disabled" "schedules.disabledState": "Disabled"
} }
} }
+1 -1
View File
@@ -1168,4 +1168,4 @@
"schedules.enabledState": "Włączony", "schedules.enabledState": "Włączony",
"schedules.disabledState": "Wyłączony" "schedules.disabledState": "Wyłączony"
} }
} }
+176 -54
View File
@@ -1,7 +1,27 @@
use std::{net::IpAddr, sync::atomic::Ordering, time::{Duration, Instant}}; use crate::{
engine,
error::AppError,
home_assistant, influxdb,
models::{
ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup, ConfigurationExport,
DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GreeSettings,
GroupControlPatch, HaReading, HistorySettings, HomeAssistantSettings,
HomeAssistantSettingsUpdate, HomeAssistantSettingsView, InfluxDbSettings,
InfluxDbSettingsUpdate, InfluxDbSettingsView, ManualDeviceRequest, NightModeSettings,
NotificationSettings, NotificationSettingsUpdate, NotificationSettingsView, Reading,
RuntimeSettings, Schedule, TemporaryQuickThermostat, TemporaryQuickThermostatRequest, Zone,
ZoneControlPatch, ZoneReading,
},
notifications,
protocol::merge_discovered,
state::AppState,
};
use axum::{ use axum::{
body::Body, body::Body,
extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}}, extract::{
ws::{Message, WebSocket},
Path, Query, Request, State, WebSocketUpgrade,
},
http::{header, HeaderMap, HeaderValue, StatusCode}, http::{header, HeaderMap, HeaderValue, StatusCode},
middleware::{self, Next}, middleware::{self, Next},
response::{Redirect, Response}, response::{Redirect, Response},
@@ -13,20 +33,15 @@ use chrono::{Duration as ChronoDuration, NaiveTime, Utc};
use futures_util::StreamExt; use futures_util::StreamExt;
use rand::{rngs::OsRng, RngCore}; use rand::{rngs::OsRng, RngCore};
use serde::Deserialize; use serde::Deserialize;
use sha2::{Digest, Sha256};
use serde_json::{json, Value}; use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::{
net::IpAddr,
sync::atomic::Ordering,
time::{Duration, Instant},
};
use tower_http::{compression::CompressionLayer, trace::TraceLayer}; use tower_http::{compression::CompressionLayer, trace::TraceLayer};
use uuid::Uuid; use uuid::Uuid;
use crate::{
engine,
error::AppError,
home_assistant,
influxdb,
notifications,
models::{ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GreeSettings, GroupControlPatch, HaReading, HistorySettings, HomeAssistantSettings, HomeAssistantSettingsUpdate, HomeAssistantSettingsView, InfluxDbSettings, InfluxDbSettingsUpdate, InfluxDbSettingsView, ManualDeviceRequest, NightModeSettings, NotificationSettings, NotificationSettingsUpdate, NotificationSettingsView, Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, TemporaryQuickThermostatRequest, Zone, ZoneControlPatch, ZoneReading},
protocol::merge_discovered,
state::AppState,
};
mod openapi; mod openapi;
@@ -67,65 +82,168 @@ pub fn router(state: AppState) -> Router {
.route("/api/system/info", get(system_info)) .route("/api/system/info", get(system_info))
.route("/api/discovery", post(discover)) .route("/api/discovery", post(discover))
.route("/api/devices", get(list_devices).post(add_device)) .route("/api/devices", get(list_devices).post(add_device))
.route("/api/devices/:id", get(get_device).patch(patch_device).delete(delete_device)) .route(
"/api/devices/:id",
get(get_device).patch(patch_device).delete(delete_device),
)
.route("/api/devices/:id/bind", post(bind_device)) .route("/api/devices/:id/bind", post(bind_device))
.route("/api/devices/:id/poll", post(poll_device)) .route("/api/devices/:id/poll", post(poll_device))
.route("/api/devices/:id/probe", post(probe_device)) .route("/api/devices/:id/probe", post(probe_device))
.route("/api/devices/:id/command", post(command_device)) .route("/api/devices/:id/command", post(command_device))
.route("/api/zones", get(list_zones).post(create_zone)) .route("/api/zones", get(list_zones).post(create_zone))
.route("/api/zones/:id", get(get_zone).put(update_zone).delete(delete_zone)) .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/control", post(update_zone_control))
.route("/api/zones/:id/compressor-queue/cancel", post(cancel_zone_compressor_queue)) .route(
.route("/api/compressor-queue/cancel-all", post(cancel_all_compressor_queues)) "/api/zones/:id/compressor-queue/cancel",
.route("/api/zones/:id/schedule-template", post(apply_schedule_template)) post(cancel_zone_compressor_queue),
)
.route(
"/api/compressor-queue/cancel-all",
post(cancel_all_compressor_queues),
)
.route(
"/api/zones/:id/schedule-template",
post(apply_schedule_template),
)
.route("/api/groups", get(list_groups).post(create_group)) .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",
get(get_group).put(update_group).delete(delete_group),
)
.route("/api/groups/:id/control", post(update_group_control)) .route("/api/groups/:id/control", post(update_group_control))
.route("/api/house/control", post(update_house_control)) .route("/api/house/control", post(update_house_control))
.route("/api/house/power", post(update_house_power)) .route("/api/house/power", post(update_house_power))
.route("/api/house/preset", post(update_house_preset)) .route("/api/house/preset", post(update_house_preset))
.route("/api/schedules", get(list_schedules).post(create_schedule)) .route("/api/schedules", get(list_schedules).post(create_schedule))
.route("/api/schedules/:id", get(get_schedule).put(update_schedule).delete(delete_schedule)) .route(
.route("/api/automations", get(list_automations).post(create_automation)) "/api/schedules/:id",
.route("/api/automations/:id", get(get_automation).put(update_automation).delete(delete_automation)) get(get_schedule)
.put(update_schedule)
.delete(delete_schedule),
)
.route(
"/api/automations",
get(list_automations).post(create_automation),
)
.route(
"/api/automations/:id",
get(get_automation)
.put(update_automation)
.delete(delete_automation),
)
.route("/api/flows", get(list_flows).post(create_flow)) .route("/api/flows", get(list_flows).post(create_flow))
.route("/api/flows/import", post(import_flow)) .route("/api/flows/import", post(import_flow))
.route("/api/flows/simulate", post(simulate_flow)) .route("/api/flows/simulate", post(simulate_flow))
.route("/api/flows/:id/export", get(export_flow)) .route("/api/flows/:id/export", get(export_flow))
.route("/api/flows/:id/logs", get(flow_logs)) .route("/api/flows/:id/logs", get(flow_logs))
.route("/api/flows/:id", get(get_flow).put(update_flow).delete(delete_flow)) .route(
"/api/flows/:id",
get(get_flow).put(update_flow).delete(delete_flow),
)
.route("/api/readings", get(readings)) .route("/api/readings", get(readings))
.route("/api/history", get(history)) .route("/api/history", get(history))
.route("/api/control-plan", get(control_plan)) .route("/api/control-plan", get(control_plan))
.route("/api/events", get(events)) .route("/api/events", get(events))
.route("/api/settings/application", get(get_application_settings).put(update_application_settings)) .route(
.route("/api/settings/gree", get(get_gree_settings).put(update_gree_settings)) "/api/settings/application",
.route("/api/settings/history", get(get_history_settings).put(update_history_settings)) get(get_application_settings).put(update_application_settings),
.route("/api/settings/influxdb", get(get_influxdb_settings).put(update_influxdb_settings)) )
.route("/api/settings/notifications", get(get_notification_settings).put(update_notification_settings)) .route(
.route("/api/settings/night", get(get_night_settings).put(update_night_settings)) "/api/settings/gree",
.route("/api/settings/home-assistant", get(get_home_assistant_settings).put(update_home_assistant_settings)) get(get_gree_settings).put(update_gree_settings),
.route("/api/settings/debug", get(get_debug_settings).put(update_debug_settings)) )
.route(
"/api/settings/history",
get(get_history_settings).put(update_history_settings),
)
.route(
"/api/settings/influxdb",
get(get_influxdb_settings).put(update_influxdb_settings),
)
.route(
"/api/settings/notifications",
get(get_notification_settings).put(update_notification_settings),
)
.route(
"/api/settings/night",
get(get_night_settings).put(update_night_settings),
)
.route(
"/api/settings/home-assistant",
get(get_home_assistant_settings).put(update_home_assistant_settings),
)
.route(
"/api/settings/debug",
get(get_debug_settings).put(update_debug_settings),
)
.route("/api/configuration/export", get(export_configuration)) .route("/api/configuration/export", get(export_configuration))
.route("/api/configuration/import", post(import_configuration)) .route("/api/configuration/import", post(import_configuration))
.route("/api/access-tokens", get(list_access_tokens).post(create_access_token)) .route(
.route("/api/access-tokens/:id", axum::routing::delete(delete_access_token)) "/api/access-tokens",
.route("/api/integrations/home-assistant/test", post(test_home_assistant)) get(list_access_tokens).post(create_access_token),
.route("/api/integrations/home-assistant/entity", post(inspect_home_assistant_entity)) )
.route("/api/integrations/notifications/test", post(test_notifications)) .route(
"/api/access-tokens/:id",
axum::routing::delete(delete_access_token),
)
.route(
"/api/integrations/home-assistant/test",
post(test_home_assistant),
)
.route(
"/api/integrations/home-assistant/entity",
post(inspect_home_assistant_entity),
)
.route(
"/api/integrations/notifications/test",
post(test_notifications),
)
.route_layer(middleware::from_fn_with_state(state.clone(), auth)); .route_layer(middleware::from_fn_with_state(state.clone(), auth));
let home_assistant_api = Router::new() let home_assistant_api = Router::new()
.route("/api/integrations/home-assistant/devices", get(list_devices)) .route(
.route("/api/integrations/home-assistant/devices/:id/command", post(command_home_assistant_device)) "/api/integrations/home-assistant/devices",
.route("/api/integrations/home-assistant/control-plan", get(control_plan)) get(list_devices),
.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)) .route(
.route("/api/integrations/home-assistant/house/control", post(update_house_control)) "/api/integrations/home-assistant/devices/:id/command",
.route("/api/integrations/home-assistant/house/preset", post(update_house_preset)) post(command_home_assistant_device),
.route("/api/integrations/home-assistant/house/power", post(update_house_power)) )
.route("/api/integrations/home-assistant/zones/:id/control", post(update_home_assistant_zone_control)) .route(
.route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth)); "/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),
)
.route(
"/api/integrations/home-assistant/house/control",
post(update_house_control),
)
.route(
"/api/integrations/home-assistant/house/preset",
post(update_house_preset),
)
.route(
"/api/integrations/home-assistant/house/power",
post(update_house_power),
)
.route(
"/api/integrations/home-assistant/zones/:id/control",
post(update_home_assistant_zone_control),
)
.route_layer(middleware::from_fn_with_state(
state.clone(),
home_assistant_auth,
));
let mut app = Router::new() let mut app = Router::new()
.route("/api/health", get(health)) .route("/api/health", get(health))
@@ -161,23 +279,27 @@ pub fn router(state: AppState) -> Router {
let base = state.config.base_path.clone(); let base = state.config.base_path.clone();
let redirect_to = format!("{base}/"); let redirect_to = format!("{base}/");
Router::new() Router::new()
.route(&base, get(move || { .route(
let redirect_to = redirect_to.clone(); &base,
async move { Redirect::permanent(&redirect_to) } get(move || {
})) let redirect_to = redirect_to.clone();
async move { Redirect::permanent(&redirect_to) }
}),
)
.nest(&base, app) .nest(&base, app)
.fallback(not_found) .fallback(not_found)
}; };
app app.layer(CompressionLayer::new())
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer(middleware::from_fn(security_headers)) .layer(middleware::from_fn(security_headers))
.layer(middleware::from_fn_with_state(state.clone(), debug_api_requests)) .layer(middleware::from_fn_with_state(
state.clone(),
debug_api_requests,
))
.with_state(state) .with_state(state)
} }
// Functional source split intentionally keeps items in the existing module namespace. // Functional source split intentionally keeps items in the existing module namespace.
include!("api/auth.rs"); include!("api/auth.rs");
include!("api/system.rs"); include!("api/system.rs");
+136 -30
View File
@@ -1,15 +1,31 @@
async fn not_found(State(state): State<AppState>, headers: HeaderMap) -> Response { async fn not_found(State(state): State<AppState>, headers: HeaderMap) -> Response {
let base = request_base_path(&state, &headers); let base = request_base_path(&state, &headers);
let home = if base.is_empty() { "/".to_owned() } else { format!("{base}/") }; let home = if base.is_empty() {
"/".to_owned()
} else {
format!("{base}/")
};
let body = NOT_FOUND_HTML let body = NOT_FOUND_HTML
.replace("__GREE_BASE_PATH__", &base) .replace("__GREE_BASE_PATH__", &base)
.replace("__GREE_HOME_PATH__", &home) .replace("__GREE_HOME_PATH__", &home)
.replace("__GREE_THEME_INIT_ASSET__", &format!("{base}{THEME_INIT_ASSET_PATH}")) .replace(
.replace("__GREE_STYLES_ASSET__", &format!("{base}{STYLES_CSS_ASSET_PATH}")); "__GREE_THEME_INIT_ASSET__",
&format!("{base}{THEME_INIT_ASSET_PATH}"),
)
.replace(
"__GREE_STYLES_ASSET__",
&format!("{base}{STYLES_CSS_ASSET_PATH}"),
);
let mut response = Response::new(Body::from(body)); let mut response = Response::new(Body::from(body));
*response.status_mut() = StatusCode::NOT_FOUND; *response.status_mut() = StatusCode::NOT_FOUND;
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8")); response.headers_mut().insert(
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("private, no-store, no-cache, must-revalidate")); header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store, no-cache, must-revalidate"),
);
response response
} }
@@ -18,11 +34,23 @@ async fn index(State(state): State<AppState>, headers: HeaderMap) -> Response {
let body = INDEX_HTML let body = INDEX_HTML
.replace("__GREE_BASE_PATH__", &base) .replace("__GREE_BASE_PATH__", &base)
.replace("__GREE_APP_ASSET__", &format!("{base}{APP_JS_ASSET_PATH}")) .replace("__GREE_APP_ASSET__", &format!("{base}{APP_JS_ASSET_PATH}"))
.replace("__GREE_THEME_INIT_ASSET__", &format!("{base}{THEME_INIT_ASSET_PATH}")) .replace(
.replace("__GREE_STYLES_ASSET__", &format!("{base}{STYLES_CSS_ASSET_PATH}")); "__GREE_THEME_INIT_ASSET__",
&format!("{base}{THEME_INIT_ASSET_PATH}"),
)
.replace(
"__GREE_STYLES_ASSET__",
&format!("{base}{STYLES_CSS_ASSET_PATH}"),
);
let mut response = Response::new(Body::from(body)); let mut response = Response::new(Body::from(body));
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8")); response.headers_mut().insert(
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("private, no-store, no-cache, must-revalidate")); header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store, no-cache, must-revalidate"),
);
response response
} }
@@ -35,18 +63,65 @@ fn request_base_path(state: &AppState, headers: &HeaderMap) -> String {
} }
fn forwarded_prefix(headers: &HeaderMap) -> Option<String> { fn forwarded_prefix(headers: &HeaderMap) -> Option<String> {
let raw = headers.get("x-forwarded-prefix")?.to_str().ok()?.split(',').next()?.trim(); let raw = headers
if raw.is_empty() || raw == "/" { return Some(String::new()); } .get("x-forwarded-prefix")?
if raw.contains('?') || raw.contains('#') || raw.split('/').any(|part| matches!(part, "." | "..")) { return None; } .to_str()
.ok()?
.split(',')
.next()?
.trim();
if raw.is_empty() || raw == "/" {
return Some(String::new());
}
if raw.contains('?')
|| raw.contains('#')
|| raw.split('/').any(|part| matches!(part, "." | ".."))
{
return None;
}
Some(format!("/{}", raw.trim_matches('/'))) Some(format!("/{}", raw.trim_matches('/')))
} }
async fn app_js() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "public, max-age=31536000, immutable") } async fn app_js() -> Response {
async fn app_js_legacy() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "no-cache") } static_response(
async fn theme_init_js() -> Response { static_response(THEME_INIT_JS, "application/javascript; charset=utf-8", "public, max-age=31536000, immutable") } APP_JS,
async fn theme_init_js_legacy() -> Response { static_response(THEME_INIT_JS, "application/javascript; charset=utf-8", "no-cache") } "application/javascript; charset=utf-8",
async fn styles_css() -> Response { static_response(STYLES_CSS, "text/css; charset=utf-8", "public, max-age=31536000, immutable") } "public, max-age=31536000, immutable",
async fn styles_css_legacy() -> Response { static_response(STYLES_CSS, "text/css; charset=utf-8", "no-cache") } )
async fn manifest() -> Response { static_response(MANIFEST, "application/manifest+json", "public, max-age=3600") } }
async fn app_js_legacy() -> Response {
static_response(APP_JS, "application/javascript; charset=utf-8", "no-cache")
}
async fn theme_init_js() -> Response {
static_response(
THEME_INIT_JS,
"application/javascript; charset=utf-8",
"public, max-age=31536000, immutable",
)
}
async fn theme_init_js_legacy() -> Response {
static_response(
THEME_INIT_JS,
"application/javascript; charset=utf-8",
"no-cache",
)
}
async fn styles_css() -> Response {
static_response(
STYLES_CSS,
"text/css; charset=utf-8",
"public, max-age=31536000, immutable",
)
}
async fn styles_css_legacy() -> Response {
static_response(STYLES_CSS, "text/css; charset=utf-8", "no-cache")
}
async fn manifest() -> Response {
static_response(
MANIFEST,
"application/manifest+json",
"public, max-age=3600",
)
}
async fn service_worker() -> Response { async fn service_worker() -> Response {
let body = SERVICE_WORKER let body = SERVICE_WORKER
.replace("__GREE_ASSET_CACHE__", ASSET_BUILD_ID) .replace("__GREE_ASSET_CACHE__", ASSET_BUILD_ID)
@@ -55,22 +130,38 @@ async fn service_worker() -> Response {
.replace("__GREE_STYLES_ASSET__", STYLES_CSS_ASSET_PATH); .replace("__GREE_STYLES_ASSET__", STYLES_CSS_ASSET_PATH);
owned_response(body, "application/javascript; charset=utf-8", "no-cache") owned_response(body, "application/javascript; charset=utf-8", "no-cache")
} }
async fn favicon() -> Response { static_response(FAVICON, "image/svg+xml", "public, max-age=86400") } async fn favicon() -> Response {
static_response(FAVICON, "image/svg+xml", "public, max-age=86400")
}
async fn language_index() -> Response { async fn language_index() -> Response {
static_response(LANGUAGE_MANIFEST_JSON, "application/json; charset=utf-8", "no-cache") static_response(
LANGUAGE_MANIFEST_JSON,
"application/json; charset=utf-8",
"no-cache",
)
} }
async fn language_file(Path(file): Path<String>) -> Response { async fn language_file(Path(file): Path<String>) -> Response {
let code = file.strip_suffix(".json").unwrap_or(&file); let code = file.strip_suffix(".json").unwrap_or(&file);
if let Some((_, body)) = LANGUAGE_ASSETS.iter().find(|(language, _)| *language == code) { if let Some((_, body)) = LANGUAGE_ASSETS
.iter()
.find(|(language, _)| *language == code)
{
return static_response(*body, "application/json; charset=utf-8", "no-cache"); return static_response(*body, "application/json; charset=utf-8", "no-cache");
} }
let mut response = Response::new(Body::from("Language not found")); let mut response = Response::new(Body::from("Language not found"));
*response.status_mut() = StatusCode::NOT_FOUND; *response.status_mut() = StatusCode::NOT_FOUND;
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8")); response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
response response
} }
async fn preset_index() -> Response { async fn preset_index() -> Response {
static_response(PRESET_MANIFEST_JSON, "application/json; charset=utf-8", "no-cache") static_response(
PRESET_MANIFEST_JSON,
"application/json; charset=utf-8",
"no-cache",
)
} }
async fn preset_file(Path(file): Path<String>) -> Response { async fn preset_file(Path(file): Path<String>) -> Response {
if let Some((_, body)) = PRESET_ASSETS.iter().find(|(filename, _)| *filename == file) { if let Some((_, body)) = PRESET_ASSETS.iter().find(|(filename, _)| *filename == file) {
@@ -78,19 +169,34 @@ async fn preset_file(Path(file): Path<String>) -> Response {
} }
let mut response = Response::new(Body::from("Preset not found")); let mut response = Response::new(Body::from("Preset not found"));
*response.status_mut() = StatusCode::NOT_FOUND; *response.status_mut() = StatusCode::NOT_FOUND;
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8")); response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
response response
} }
fn static_response(body: &'static str, content_type: &'static str, cache: &'static str) -> Response { fn static_response(
body: &'static str,
content_type: &'static str,
cache: &'static str,
) -> Response {
let mut response = Response::new(Body::from(body)); let mut response = Response::new(Body::from(body));
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); response
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(cache)); .headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
response response
} }
fn owned_response(body: String, content_type: &'static str, cache: &'static str) -> Response { fn owned_response(body: String, content_type: &'static str, cache: &'static str) -> Response {
let mut response = Response::new(Body::from(body)); let mut response = Response::new(Body::from(body));
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); response
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(cache)); .headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
response response
} }
+28 -11
View File
@@ -1,4 +1,8 @@
async fn debug_api_requests(State(state): State<AppState>, request: Request, next: Next) -> Response { async fn debug_api_requests(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
if !state.settings.read().await.debug.overlay_enabled { if !state.settings.read().await.debug.overlay_enabled {
return next.run(request).await; return next.run(request).await;
} }
@@ -6,16 +10,23 @@ async fn debug_api_requests(State(state): State<AppState>, request: Request, nex
let path = request.uri().path().to_string(); let path = request.uri().path().to_string();
let started = Instant::now(); let started = Instant::now();
let response = next.run(request).await; let response = next.run(request).await;
state.broadcast("api.request", json!({ state.broadcast(
"method": method.as_str(), "api.request",
"path": path, json!({
"status": response.status().as_u16(), "method": method.as_str(),
"duration_ms": started.elapsed().as_millis(), "path": path,
})); "status": response.status().as_u16(),
"duration_ms": started.elapsed().as_millis(),
}),
);
response response
} }
async fn auth(State(state): State<AppState>, request: Request, next: Next) -> Result<Response, AppError> { async fn auth(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Result<Response, AppError> {
let expected = state.config.app_token.trim(); let expected = state.config.app_token.trim();
if expected.is_empty() { if expected.is_empty() {
return Ok(next.run(request).await); return Ok(next.run(request).await);
@@ -44,10 +55,17 @@ async fn home_assistant_auth(
} }
fn request_token(request: &Request) -> Option<String> { fn request_token(request: &Request) -> Option<String> {
request.headers().get(header::AUTHORIZATION) request
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer ")) .and_then(|value| value.strip_prefix("Bearer "))
.or_else(|| request.headers().get("x-api-token").and_then(|value| value.to_str().ok())) .or_else(|| {
request
.headers()
.get("x-api-token")
.and_then(|value| value.to_str().ok())
})
.map(str::to_owned) .map(str::to_owned)
} }
@@ -61,4 +79,3 @@ fn generate_access_token() -> String {
rng.fill_bytes(&mut bytes); rng.fill_bytes(&mut bytes);
format!("gree_controller_{}", URL_SAFE_NO_PAD.encode(bytes)) format!("gree_controller_{}", URL_SAFE_NO_PAD.encode(bytes))
} }
+213 -53
View File
@@ -21,38 +21,75 @@ struct AutomationInput {
#[serde(default = "automation_cooldown")] #[serde(default = "automation_cooldown")]
cooldown_seconds: u64, cooldown_seconds: u64,
} }
fn automation_cooldown() -> u64 { 300 } fn automation_cooldown() -> u64 {
300
}
impl AutomationInput { impl AutomationInput {
fn validate(&self) -> Result<(), AppError> { fn validate(&self) -> Result<(), AppError> {
if self.name.trim().is_empty() { return Err(AppError::BadRequest("automation name is required".into())); } if self.name.trim().is_empty() {
return Err(AppError::BadRequest("automation name is required".into()));
}
match self.trigger_kind.as_str() { match self.trigger_kind.as_str() {
"temperature_above" | "temperature_below" => { "temperature_above" | "temperature_below" => {
if self.trigger_device_id.as_deref().unwrap_or_default().is_empty() || self.threshold.is_none() { if self
return Err(AppError::BadRequest("temperature trigger needs device and threshold".into())); .trigger_device_id
.as_deref()
.unwrap_or_default()
.is_empty()
|| self.threshold.is_none()
{
return Err(AppError::BadRequest(
"temperature trigger needs device and threshold".into(),
));
} }
} }
"time" => { "time" => {
let at = self.at_time.as_deref().ok_or_else(|| AppError::BadRequest("time trigger needs at_time".into()))?; let at = self
chrono::NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("invalid automation time".into()))?; .at_time
.as_deref()
.ok_or_else(|| AppError::BadRequest("time trigger needs at_time".into()))?;
chrono::NaiveTime::parse_from_str(at, "%H:%M")
.map_err(|_| AppError::BadRequest("invalid automation time".into()))?;
}
_ => {
return Err(AppError::BadRequest(
"unsupported automation trigger".into(),
))
} }
_ => 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()); 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() { 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())); 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 let Some(preset) = self
.action_preset
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
if action_group_id.is_none() { if action_group_id.is_none() {
return Err(AppError::BadRequest("automation preset actions require a group target".into())); return Err(AppError::BadRequest(
"automation preset actions require a group target".into(),
));
} }
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") { if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
return Err(AppError::BadRequest("unsupported group automation preset".into())); return Err(AppError::BadRequest(
"unsupported group automation preset".into(),
));
} }
} }
if action_group_id.is_some() { if action_group_id.is_some() {
if let Some(mode) = self.action.mode.as_deref() { if let Some(mode) = self.action.mode.as_deref() {
if !matches!(mode, "auto" | "house" | "cool" | "heat") { if !matches!(mode, "auto" | "house" | "cool" | "heat") {
return Err(AppError::BadRequest("group automation mode must be house, cool or heat".into())); return Err(AppError::BadRequest(
"group automation mode must be house, cool or heat".into(),
));
} }
} }
if self.action.target_temperature.is_some() if self.action.target_temperature.is_some()
@@ -67,48 +104,121 @@ impl AutomationInput {
|| self.action.health.is_some() || self.action.health.is_some()
|| self.action.sleep.is_some() || self.action.sleep.is_some()
{ {
return Err(AppError::BadRequest("group automations support only power, heat/cool/house mode and a group preset".into())); return Err(AppError::BadRequest(
"group automations support only power, heat/cool/house mode and a group preset"
.into(),
));
} }
if self.action.power.is_none() && self.action.mode.is_none() && self.action_preset.as_deref().map(str::trim).filter(|v| !v.is_empty()).is_none() { if self.action.power.is_none()
return Err(AppError::BadRequest("group automation action cannot be empty".into())); && self.action.mode.is_none()
&& self
.action_preset
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.is_none()
{
return Err(AppError::BadRequest(
"group automation action cannot be empty".into(),
));
} }
} else { } else {
engine::validate_command(&self.action)?; engine::validate_command(&self.action)?;
if self.action.is_empty() { if self.action.is_empty() {
return Err(AppError::BadRequest("automation action cannot be empty".into())); return Err(AppError::BadRequest(
"automation action cannot be empty".into(),
));
} }
} }
Ok(()) Ok(())
} }
fn into_automation(self, id: String, created_at: chrono::DateTime<Utc>, last_fired_at: Option<chrono::DateTime<Utc>>) -> Automation { fn into_automation(
Automation { id, name: self.name.trim().into(), enabled: self.enabled, self,
trigger_kind: self.trigger_kind, trigger_device_id: self.trigger_device_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()), id: String,
threshold: self.threshold, at_time: self.at_time, action_device_id: self.action_device_id.trim().to_string(), created_at: chrono::DateTime<Utc>,
action_group_id: self.action_group_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()), last_fired_at: Option<chrono::DateTime<Utc>>,
action_preset: self.action_preset.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()), ) -> Automation {
action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at, Automation {
action_zone_id: None, action_zone_preset: None, action_ha_domain: None, action_ha_service: None, action_ha_entity_id: None, action_ha_data: Value::Null, flow_conditions: vec![], flow_id: None, flow_node_id: None, flow_runtime: Default::default(), id,
created_at, updated_at: Utc::now() } name: self.name.trim().into(),
} enabled: self.enabled,
} trigger_kind: self.trigger_kind,
fn validate_automation_references(state: &AppState, input: &AutomationInput) -> Result<(), AppError> { trigger_device_id: self
if matches!(input.trigger_kind.as_str(), "temperature_above" | "temperature_below") { .trigger_device_id
let trigger_id = input.trigger_device_id.as_deref().map(str::trim).unwrap_or_default(); .map(|value| value.trim().to_string())
if state.db.get_device(trigger_id)?.is_none() { .filter(|value| !value.is_empty()),
return Err(AppError::BadRequest("automation trigger device does not exist".into())); 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,
action_zone_id: None,
action_zone_preset: None,
action_ha_domain: None,
action_ha_service: None,
action_ha_entity_id: None,
action_ha_data: Value::Null,
flow_conditions: vec![],
flow_id: None,
flow_node_id: None,
flow_runtime: Default::default(),
created_at,
updated_at: Utc::now(),
} }
} }
if let Some(group_id) = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) { }
fn validate_automation_references(
state: &AppState,
input: &AutomationInput,
) -> Result<(), AppError> {
if matches!(
input.trigger_kind.as_str(),
"temperature_above" | "temperature_below"
) {
let trigger_id = input
.trigger_device_id
.as_deref()
.map(str::trim)
.unwrap_or_default();
if state.db.get_device(trigger_id)?.is_none() {
return Err(AppError::BadRequest(
"automation trigger 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() { if state.db.get_group(group_id)?.is_none() {
return Err(AppError::BadRequest("automation action group does not exist".into())); return Err(AppError::BadRequest(
"automation action group does not exist".into(),
));
} }
} else { } else {
let device_id = input.action_device_id.trim(); let device_id = input.action_device_id.trim();
if state.db.get_device(device_id)?.is_none() { if state.db.get_device(device_id)?.is_none() {
return Err(AppError::BadRequest("automation action device does not exist".into())); return Err(AppError::BadRequest(
"automation action device does not exist".into(),
));
} }
if engine::automation_action_conflicts_with_thermostat(&input.action) if engine::automation_action_conflicts_with_thermostat(&input.action)
&& state.db.list_zones()?.iter().any(|zone| zone.enabled && zone.device_id == device_id) && state
.db
.list_zones()?
.iter()
.any(|zone| zone.enabled && zone.device_id == device_id)
{ {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"direct fan/quiet/sleep automation conflicts with an enabled thermostat zone; use thermostat/group policy instead".into(), "direct fan/quiet/sleep automation conflicts with an enabled thermostat zone; use thermostat/group policy instead".into(),
@@ -118,47 +228,97 @@ fn validate_automation_references(state: &AppState, input: &AutomationInput) ->
Ok(()) Ok(())
} }
async fn list_automations(State(state): State<AppState>) -> Result<Json<Vec<Automation>>, AppError> { Ok(Json(state.db.list_automations()?)) } async fn list_automations(
async fn get_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Automation>, AppError> { State(state): State<AppState>,
state.db.get_automation(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("automation {id}"))) ) -> Result<Json<Vec<Automation>>, AppError> {
Ok(Json(state.db.list_automations()?))
} }
async fn create_automation(State(state): State<AppState>, Json(input): Json<AutomationInput>) -> Result<(StatusCode, Json<Automation>), AppError> { async fn get_automation(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Automation>, AppError> {
state
.db
.get_automation(&id)?
.map(Json)
.ok_or_else(|| AppError::NotFound(format!("automation {id}")))
}
async fn create_automation(
State(state): State<AppState>,
Json(input): Json<AutomationInput>,
) -> Result<(StatusCode, Json<Automation>), AppError> {
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
let _automation_guard = state.lock_automation_operation().await; let _automation_guard = state.lock_automation_operation().await;
input.validate()?; input.validate()?;
let action_group_id = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string); let action_group_id = input
.action_group_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let _group_guard = if let Some(group_id) = action_group_id.as_deref() { let _group_guard = if let Some(group_id) = action_group_id.as_deref() {
Some(state.lock_group_operation(group_id).await) Some(state.lock_group_operation(group_id).await)
} else { None }; } else {
None
};
validate_automation_references(&state, &input)?; validate_automation_references(&state, &input)?;
let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None); let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None);
state.db.save_automation(&item)?; state.db.save_automation(&item)?;
state.broadcast("automation.created", serde_json::to_value(&item)?); state.broadcast("automation.created", serde_json::to_value(&item)?);
Ok((StatusCode::CREATED, Json(item))) Ok((StatusCode::CREATED, Json(item)))
} }
async fn update_automation(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<AutomationInput>) -> Result<Json<Automation>, AppError> { async fn update_automation(
State(state): State<AppState>,
Path(id): Path<String>,
Json(input): Json<AutomationInput>,
) -> Result<Json<Automation>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
let _automation_guard = state.lock_automation_operation().await; let _automation_guard = state.lock_automation_operation().await;
input.validate()?; input.validate()?;
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?; let existing = state
if existing.flow_id.is_some() { return Err(AppError::BadRequest("this automation is generated by Flow; edit it in the Flow editor".into())); } .db
let action_group_id = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string); .get_automation(&id)?
.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
if existing.flow_id.is_some() {
return Err(AppError::BadRequest(
"this automation is generated by Flow; edit it in the Flow editor".into(),
));
}
let action_group_id = input
.action_group_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let _group_guard = if let Some(group_id) = action_group_id.as_deref() { let _group_guard = if let Some(group_id) = action_group_id.as_deref() {
Some(state.lock_group_operation(group_id).await) Some(state.lock_group_operation(group_id).await)
} else { None }; } else {
None
};
validate_automation_references(&state, &input)?; validate_automation_references(&state, &input)?;
let item = input.into_automation(id, existing.created_at, existing.last_fired_at); let item = input.into_automation(id, existing.created_at, existing.last_fired_at);
state.db.save_automation(&item)?; state.db.save_automation(&item)?;
state.broadcast("automation.updated", serde_json::to_value(&item)?); state.broadcast("automation.updated", serde_json::to_value(&item)?);
Ok(Json(item)) Ok(Json(item))
} }
async fn delete_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> { async fn delete_automation(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, AppError> {
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
let _automation_guard = state.lock_automation_operation().await; let _automation_guard = state.lock_automation_operation().await;
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?; let existing = state
if existing.flow_id.is_some() { return Err(AppError::BadRequest("this automation is generated by Flow; delete it from the Flow editor".into())); } .db
if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); } .get_automation(&id)?
.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
if existing.flow_id.is_some() {
return Err(AppError::BadRequest(
"this automation is generated by Flow; delete it from the Flow editor".into(),
));
}
if !state.db.delete_automation(&id)? {
return Err(AppError::NotFound(format!("automation {id}")));
}
state.broadcast("automation.deleted", json!({"id": id})); state.broadcast("automation.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
+347 -111
View File
@@ -11,7 +11,9 @@ struct ConfigurationResourceGuards {
_devices: Vec<tokio::sync::OwnedMutexGuard<()>>, _devices: Vec<tokio::sync::OwnedMutexGuard<()>>,
} }
async fn export_configuration(State(state): State<AppState>) -> Result<Json<ConfigurationExport>, AppError> { async fn export_configuration(
State(state): State<AppState>,
) -> Result<Json<ConfigurationExport>, AppError> {
let settings = state.settings.read().await.clone(); let settings = state.settings.read().await.clone();
let mut export = state.db.export_configuration(settings)?; let mut export = state.db.export_configuration(settings)?;
sanitize_configuration_runtime(&mut export); sanitize_configuration_runtime(&mut export);
@@ -23,21 +25,36 @@ fn validate_configuration_header(export: &ConfigurationExport) -> Result<(), App
return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.12.x".into())); return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.12.x".into()));
} }
if export.settings.control_strategy != "setpoint" { if export.settings.control_strategy != "setpoint" {
return Err(AppError::BadRequest("import contains an unsupported control strategy".into())); return Err(AppError::BadRequest(
"import contains an unsupported control strategy".into(),
));
} }
influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?; influxdb::validate(&export.settings.influxdb)
.map_err(|err| AppError::BadRequest(err.to_string()))?;
if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") { if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") {
return Err(AppError::BadRequest("import contains an invalid house mode".into())); return Err(AppError::BadRequest(
"import contains an invalid house mode".into(),
));
} }
Ok(()) Ok(())
} }
fn collect_configuration_ids(export: &ConfigurationExport) -> Result<ConfigurationIds<'_>, AppError> { fn collect_configuration_ids(
export: &ConfigurationExport,
) -> Result<ConfigurationIds<'_>, AppError> {
let ids = ConfigurationIds { let ids = ConfigurationIds {
devices: export.devices.iter().map(|item| item.id.as_str()).collect(), devices: export.devices.iter().map(|item| item.id.as_str()).collect(),
zones: export.zones.iter().map(|item| item.id.as_str()).collect(), zones: export.zones.iter().map(|item| item.id.as_str()).collect(),
schedules: export.schedules.iter().map(|item| item.id.as_str()).collect(), schedules: export
automations: export.automations.iter().map(|item| item.id.as_str()).collect(), .schedules
.iter()
.map(|item| item.id.as_str())
.collect(),
automations: export
.automations
.iter()
.map(|item| item.id.as_str())
.collect(),
flows: export.flows.iter().map(|item| item.id.as_str()).collect(), flows: export.flows.iter().map(|item| item.id.as_str()).collect(),
}; };
let duplicate_or_empty = ids.devices.len() != export.devices.len() let duplicate_or_empty = ids.devices.len() != export.devices.len()
@@ -51,22 +68,38 @@ fn collect_configuration_ids(export: &ConfigurationExport) -> Result<Configurati
|| ids.automations.contains("") || ids.automations.contains("")
|| ids.flows.contains(""); || ids.flows.contains("");
if duplicate_or_empty { if duplicate_or_empty {
return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into())); return Err(AppError::BadRequest(
"import contains duplicate or empty resource IDs".into(),
));
} }
Ok(ids) Ok(ids)
} }
fn validate_configuration_flows(export: &ConfigurationExport) -> Result<(), AppError> { fn validate_configuration_flows(export: &ConfigurationExport) -> Result<(), AppError> {
let draft_flows: std::collections::HashSet<&str> = export.flows.iter() let draft_flows: std::collections::HashSet<&str> = export
.flows
.iter()
.filter(|item| item.draft) .filter(|item| item.draft)
.map(|item| item.id.as_str()) .map(|item| item.id.as_str())
.collect(); .collect();
let executable_draft = export.flows.iter().any(|item| item.draft let executable_draft = export.flows.iter().any(|item| {
&& (item.enabled || !item.compiled_schedule_ids.is_empty() || !item.compiled_automation_ids.is_empty())) item.draft
|| export.schedules.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id))) && (item.enabled
|| export.automations.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id))); || !item.compiled_schedule_ids.is_empty()
|| !item.compiled_automation_ids.is_empty())
}) || export.schedules.iter().any(|item| {
item.flow_id
.as_deref()
.is_some_and(|id| draft_flows.contains(id))
}) || export.automations.iter().any(|item| {
item.flow_id
.as_deref()
.is_some_and(|id| draft_flows.contains(id))
});
if executable_draft { if executable_draft {
return Err(AppError::BadRequest("import contains an executable Flow draft".into())); return Err(AppError::BadRequest(
"import contains an executable Flow draft".into(),
));
} }
Ok(()) Ok(())
} }
@@ -75,23 +108,44 @@ fn validate_configuration_devices_and_zones(
export: &ConfigurationExport, export: &ConfigurationExport,
ids: &ConfigurationIds<'_>, ids: &ConfigurationIds<'_>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let device_macs: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.mac.as_str()).collect(); let device_macs: std::collections::HashSet<&str> = export
.devices
.iter()
.map(|item| item.mac.as_str())
.collect();
if device_macs.len() != export.devices.len() { if device_macs.len() != export.devices.len() {
return Err(AppError::BadRequest("import contains duplicate device MAC addresses".into())); return Err(AppError::BadRequest(
"import contains duplicate device MAC addresses".into(),
));
} }
if export.zones.iter().any(|item| !ids.devices.contains(item.device_id.as_str())) { if export
return Err(AppError::BadRequest("import contains a zone referencing a missing device".into())); .zones
.iter()
.any(|item| !ids.devices.contains(item.device_id.as_str()))
{
return Err(AppError::BadRequest(
"import contains a zone referencing a missing device".into(),
));
} }
let mut zone_devices = std::collections::HashSet::new(); let mut zone_devices = std::collections::HashSet::new();
for zone in &export.zones { for zone in &export.zones {
if !zone_devices.insert(zone.device_id.as_str()) { if !zone_devices.insert(zone.device_id.as_str()) {
return Err(AppError::BadRequest("import assigns one device to more than one thermostat zone".into())); return Err(AppError::BadRequest(
"import assigns one device to more than one thermostat zone".into(),
));
} }
if !matches!(zone.mode.as_str(), "cool" | "heat") { if !matches!(zone.mode.as_str(), "cool" | "heat") {
return Err(AppError::BadRequest("import contains an invalid zone mode".into())); return Err(AppError::BadRequest(
"import contains an invalid zone mode".into(),
));
} }
if !matches!(zone.sensor_source.as_str(), "device" | "home_assistant" | "combined") { if !matches!(
return Err(AppError::BadRequest("import contains an invalid zone sensor source".into())); zone.sensor_source.as_str(),
"device" | "home_assistant" | "combined"
) {
return Err(AppError::BadRequest(
"import contains an invalid zone sensor source".into(),
));
} }
} }
Ok(()) Ok(())
@@ -101,25 +155,48 @@ fn validate_configuration_schedules(
export: &ConfigurationExport, export: &ConfigurationExport,
ids: &ConfigurationIds<'_>, ids: &ConfigurationIds<'_>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
if export.schedules.iter().any(|item| !ids.zones.contains(item.zone_id.as_str())) { if export
return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into())); .schedules
.iter()
.any(|item| !ids.zones.contains(item.zone_id.as_str()))
{
return Err(AppError::BadRequest(
"import contains a schedule referencing a missing zone".into(),
));
} }
for item in &export.schedules { for item in &export.schedules {
if item.flow_id.as_deref().is_some_and(|flow_id| !ids.flows.contains(flow_id)) { if item
return Err(AppError::BadRequest("import contains a Flow-generated schedule referencing a missing Flow".into())); .flow_id
.as_deref()
.is_some_and(|flow_id| !ids.flows.contains(flow_id))
{
return Err(AppError::BadRequest(
"import contains a Flow-generated schedule referencing a missing Flow".into(),
));
} }
if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) { if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) {
return Err(AppError::BadRequest("import contains invalid schedule weekdays".into())); return Err(AppError::BadRequest(
"import contains invalid schedule weekdays".into(),
));
} }
NaiveTime::parse_from_str(&item.start_time, "%H:%M") NaiveTime::parse_from_str(&item.start_time, "%H:%M").map_err(|_| {
.map_err(|_| AppError::BadRequest("import contains an invalid schedule start time".into()))?; AppError::BadRequest("import contains an invalid schedule start time".into())
NaiveTime::parse_from_str(&item.end_time, "%H:%M") })?;
.map_err(|_| AppError::BadRequest("import contains an invalid schedule end time".into()))?; NaiveTime::parse_from_str(&item.end_time, "%H:%M").map_err(|_| {
if !matches!(item.preset.as_str(), "comfort" | "sleep" | "away" | "custom") { AppError::BadRequest("import contains an invalid schedule end time".into())
return Err(AppError::BadRequest("import contains an invalid schedule preset".into())); })?;
if !matches!(
item.preset.as_str(),
"comfort" | "sleep" | "away" | "custom"
) {
return Err(AppError::BadRequest(
"import contains an invalid schedule preset".into(),
));
} }
if item.preset == "custom" && !(8.0..=30.0).contains(&item.setpoint) { if item.preset == "custom" && !(8.0..=30.0).contains(&item.setpoint) {
return Err(AppError::BadRequest("import contains an invalid schedule setpoint".into())); return Err(AppError::BadRequest(
"import contains an invalid schedule setpoint".into(),
));
} }
} }
validate_schedule_set(&export.schedules)?; validate_schedule_set(&export.schedules)?;
@@ -131,17 +208,27 @@ fn validate_configuration_groups<'a>(
ids: &ConfigurationIds<'_>, ids: &ConfigurationIds<'_>,
) -> Result<std::collections::HashSet<&'a str>, AppError> { ) -> Result<std::collections::HashSet<&'a str>, AppError> {
if export.groups.iter().any(|group| { if export.groups.iter().any(|group| {
let members: std::collections::HashSet<&str> = group.zone_ids.iter().map(String::as_str).collect(); let members: std::collections::HashSet<&str> =
group.zone_ids.iter().map(String::as_str).collect();
group.id.trim().is_empty() group.id.trim().is_empty()
|| group.zone_ids.is_empty() || group.zone_ids.is_empty()
|| members.len() != group.zone_ids.len() || members.len() != group.zone_ids.len()
|| group.zone_ids.iter().any(|zone_id| !ids.zones.contains(zone_id.as_str())) || group
.zone_ids
.iter()
.any(|zone_id| !ids.zones.contains(zone_id.as_str()))
}) { }) {
return Err(AppError::BadRequest("import contains an invalid group, duplicate members or a missing zone reference".into())); return Err(AppError::BadRequest(
"import contains an invalid group, duplicate members or a missing zone reference"
.into(),
));
} }
let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect(); let groups: std::collections::HashSet<&str> =
export.groups.iter().map(|item| item.id.as_str()).collect();
if groups.len() != export.groups.len() { if groups.len() != export.groups.len() {
return Err(AppError::BadRequest("import contains duplicate group IDs".into())); return Err(AppError::BadRequest(
"import contains duplicate group IDs".into(),
));
} }
Ok(groups) Ok(groups)
} }
@@ -153,42 +240,78 @@ fn validate_configuration_automation_trigger(
match item.trigger_kind.as_str() { match item.trigger_kind.as_str() {
"temperature_above" | "temperature_below" => { "temperature_above" | "temperature_below" => {
let Some(trigger_id) = item.trigger_device_id.as_deref() else { let Some(trigger_id) = item.trigger_device_id.as_deref() else {
return Err(AppError::BadRequest("import contains a temperature automation without a trigger device".into())); return Err(AppError::BadRequest(
"import contains a temperature automation without a trigger device".into(),
));
}; };
if !ids.devices.contains(trigger_id) || item.threshold.is_none() { if !ids.devices.contains(trigger_id) || item.threshold.is_none() {
return Err(AppError::BadRequest("import contains an invalid temperature automation trigger".into())); return Err(AppError::BadRequest(
"import contains an invalid temperature automation trigger".into(),
));
} }
} }
"time" => { "time" => {
let at = item.at_time.as_deref() let at = item.at_time.as_deref().ok_or_else(|| {
.ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?; AppError::BadRequest("import contains a time automation without at_time".into())
NaiveTime::parse_from_str(at, "%H:%M") })?;
.map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?; NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| {
AppError::BadRequest("import contains an invalid automation time".into())
})?;
} }
"flow" => { "flow" => {
if item.flow_id.as_deref().filter(|id| ids.flows.contains(*id)).is_none() || item.flow_conditions.is_empty() { if item
return Err(AppError::BadRequest("import contains an invalid Flow-generated automation".into())); .flow_id
.as_deref()
.filter(|id| ids.flows.contains(*id))
.is_none()
|| item.flow_conditions.is_empty()
{
return Err(AppError::BadRequest(
"import contains an invalid Flow-generated automation".into(),
));
} }
} }
_ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())), _ => {
return Err(AppError::BadRequest(
"import contains an unsupported automation trigger".into(),
))
}
} }
Ok(()) Ok(())
} }
fn validate_configuration_zone_automation(item: &Automation, ids: &ConfigurationIds<'_>) -> Result<(), AppError> { fn validate_configuration_zone_automation(
let Some(zone_id) = item.action_zone_id.as_deref().filter(|value| !value.is_empty()) else { return Ok(()); }; item: &Automation,
ids: &ConfigurationIds<'_>,
) -> Result<(), AppError> {
let Some(zone_id) = item
.action_zone_id
.as_deref()
.filter(|value| !value.is_empty())
else {
return Ok(());
};
if !ids.zones.contains(zone_id) { if !ids.zones.contains(zone_id) {
return Err(AppError::BadRequest("import contains a Flow automation referencing a missing zone".into())); return Err(AppError::BadRequest(
"import contains a Flow automation referencing a missing zone".into(),
));
} }
if let Some(preset) = item.action_zone_preset.as_deref() { if let Some(preset) = item.action_zone_preset.as_deref() {
if !matches!(preset, "auto" | "custom" | "comfort" | "sleep" | "away") { if !matches!(preset, "auto" | "custom" | "comfort" | "sleep" | "away") {
return Err(AppError::BadRequest("import contains an invalid Flow thermostat preset".into())); return Err(AppError::BadRequest(
"import contains an invalid Flow thermostat preset".into(),
));
} }
} }
if item.action_zone_preset.as_deref() == Some("custom") if item.action_zone_preset.as_deref() == Some("custom")
&& item.action.target_temperature.is_some_and(|value| !(8.0..=30.0).contains(&value)) && item
.action
.target_temperature
.is_some_and(|value| !(8.0..=30.0).contains(&value))
{ {
return Err(AppError::BadRequest("import contains an invalid Flow thermostat target".into())); return Err(AppError::BadRequest(
"import contains an invalid Flow thermostat target".into(),
));
} }
Ok(()) Ok(())
} }
@@ -197,30 +320,51 @@ fn validate_configuration_group_automation(
item: &Automation, item: &Automation,
groups: &std::collections::HashSet<&str>, groups: &std::collections::HashSet<&str>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) else { return Ok(()); }; let Some(group_id) = item
.action_group_id
.as_deref()
.filter(|value| !value.is_empty())
else {
return Ok(());
};
if !groups.contains(group_id) { if !groups.contains(group_id) {
return Err(AppError::BadRequest("import contains an automation referencing a missing group".into())); return Err(AppError::BadRequest(
"import contains an automation referencing a missing group".into(),
));
} }
if let Some(mode) = item.action.mode.as_deref() { if let Some(mode) = item.action.mode.as_deref() {
if !matches!(mode, "auto" | "house" | "cool" | "heat") { if !matches!(mode, "auto" | "house" | "cool" | "heat") {
return Err(AppError::BadRequest("import contains an invalid group automation mode".into())); return Err(AppError::BadRequest(
"import contains an invalid group automation mode".into(),
));
} }
} }
let flow_custom_group = item.flow_id.is_some() && item.action_preset.as_deref() == Some("custom"); let flow_custom_group =
item.flow_id.is_some() && item.action_preset.as_deref() == Some("custom");
if let Some(preset) = item.action_preset.as_deref() { if let Some(preset) = item.action_preset.as_deref() {
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") && !(flow_custom_group && preset == "custom") { if !matches!(preset, "auto" | "comfort" | "sleep" | "away")
return Err(AppError::BadRequest("import contains an invalid group automation preset".into())); && !(flow_custom_group && preset == "custom")
{
return Err(AppError::BadRequest(
"import contains an invalid group automation preset".into(),
));
} }
} }
if flow_custom_group { if flow_custom_group {
let Some(target) = item.action.target_temperature else { let Some(target) = item.action.target_temperature else {
return Err(AppError::BadRequest("import contains a Flow custom group preset without a target".into())); return Err(AppError::BadRequest(
"import contains a Flow custom group preset without a target".into(),
));
}; };
if !(8.0..=30.0).contains(&target) { if !(8.0..=30.0).contains(&target) {
return Err(AppError::BadRequest("import contains an invalid Flow group target".into())); return Err(AppError::BadRequest(
"import contains an invalid Flow group target".into(),
));
} }
} else if item.action.target_temperature.is_some() { } else if item.action.target_temperature.is_some() {
return Err(AppError::BadRequest("import contains unsupported target temperature in a group automation".into())); return Err(AppError::BadRequest(
"import contains unsupported target temperature in a group automation".into(),
));
} }
if item.action.fan_speed.is_some() if item.action.fan_speed.is_some()
|| item.action.swing_vertical.is_some() || item.action.swing_vertical.is_some()
@@ -233,13 +377,21 @@ fn validate_configuration_group_automation(
|| item.action.health.is_some() || item.action.health.is_some()
|| item.action.sleep.is_some() || item.action.sleep.is_some()
{ {
return Err(AppError::BadRequest("import contains unsupported device fields in a group automation".into())); return Err(AppError::BadRequest(
"import contains unsupported device fields in a group automation".into(),
));
} }
if item.action.power.is_none() if item.action.power.is_none()
&& item.action.mode.is_none() && item.action.mode.is_none()
&& item.action_preset.as_deref().filter(|value| !value.is_empty()).is_none() && item
.action_preset
.as_deref()
.filter(|value| !value.is_empty())
.is_none()
{ {
return Err(AppError::BadRequest("import contains an empty group automation action".into())); return Err(AppError::BadRequest(
"import contains an empty group automation action".into(),
));
} }
Ok(()) Ok(())
} }
@@ -250,14 +402,18 @@ fn validate_configuration_shared_inputs(
groups: &std::collections::HashSet<&str>, groups: &std::collections::HashSet<&str>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
for item in &export.settings.home_assistant.flow_inputs { for item in &export.settings.home_assistant.flow_inputs {
let Some(reference) = shared_input_resource_reference(&item.kind, &item.config)? else { continue; }; let Some(reference) = shared_input_resource_reference(&item.kind, &item.config)? else {
continue;
};
let exists = match reference { let exists = match reference {
SharedInputResourceReference::Device(id) => ids.devices.contains(id.as_str()), SharedInputResourceReference::Device(id) => ids.devices.contains(id.as_str()),
SharedInputResourceReference::Zone(id) => ids.zones.contains(id.as_str()), SharedInputResourceReference::Zone(id) => ids.zones.contains(id.as_str()),
SharedInputResourceReference::Group(id) => groups.contains(id.as_str()), SharedInputResourceReference::Group(id) => groups.contains(id.as_str()),
}; };
if !exists { if !exists {
return Err(AppError::BadRequest("import contains a shared Flow input referencing a missing resource".into())); return Err(AppError::BadRequest(
"import contains a shared Flow input referencing a missing resource".into(),
));
} }
} }
Ok(()) Ok(())
@@ -270,17 +426,29 @@ fn validate_configuration_automations(
) -> Result<(), AppError> { ) -> Result<(), AppError> {
for item in &export.automations { for item in &export.automations {
validate_configuration_automation_trigger(item, ids)?; validate_configuration_automation_trigger(item, ids)?;
if item.action_zone_id.as_deref().is_some_and(|value| !value.is_empty()) { if item
.action_zone_id
.as_deref()
.is_some_and(|value| !value.is_empty())
{
validate_configuration_zone_automation(item, ids)?; validate_configuration_zone_automation(item, ids)?;
} else if item.action_group_id.as_deref().is_some_and(|value| !value.is_empty()) { } else if item
.action_group_id
.as_deref()
.is_some_and(|value| !value.is_empty())
{
validate_configuration_group_automation(item, groups)?; validate_configuration_group_automation(item, groups)?;
} else { } else {
if !ids.devices.contains(item.action_device_id.as_str()) { if !ids.devices.contains(item.action_device_id.as_str()) {
return Err(AppError::BadRequest("import contains an automation referencing a missing device".into())); return Err(AppError::BadRequest(
"import contains an automation referencing a missing device".into(),
));
} }
engine::validate_command(&item.action)?; engine::validate_command(&item.action)?;
if item.action.is_empty() { if item.action.is_empty() {
return Err(AppError::BadRequest("import contains an empty automation action".into())); return Err(AppError::BadRequest(
"import contains an empty automation action".into(),
));
} }
} }
} }
@@ -366,8 +534,12 @@ fn sanitize_imported_zone(zone: &mut Zone, now: chrono::DateTime<Utc>) {
fn sanitize_configuration_runtime(export: &mut ConfigurationExport) { fn sanitize_configuration_runtime(export: &mut ConfigurationExport) {
let now = Utc::now(); let now = Utc::now();
for device in &mut export.devices { sanitize_imported_device(device, now.clone()); } for device in &mut export.devices {
for zone in &mut export.zones { sanitize_imported_zone(zone, now.clone()); } sanitize_imported_device(device, now.clone());
}
for zone in &mut export.zones {
sanitize_imported_zone(zone, now.clone());
}
for automation in &mut export.automations { for automation in &mut export.automations {
automation.last_fired_at = None; automation.last_fired_at = None;
automation.updated_at = now.clone(); automation.updated_at = now.clone();
@@ -387,25 +559,32 @@ fn normalize_imported_runtime_settings(settings: &mut RuntimeSettings) -> Result
settings.history_retention_days = settings.history_retention_days.clamp(1, 3650); settings.history_retention_days = settings.history_retention_days.clamp(1, 3650);
settings.event_log_retention_days = settings.event_log_retention_days.clamp(1, 3650); settings.event_log_retention_days = settings.event_log_retention_days.clamp(1, 3650);
settings.influxdb.history_threshold_days = settings.influxdb.history_threshold_days.clamp(1, 3650); settings.influxdb.history_threshold_days =
settings.influxdb.history_threshold_days.clamp(1, 3650);
influxdb::validate(&settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?; influxdb::validate(&settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
let current_notifications = settings.notifications.clone(); let current_notifications = settings.notifications.clone();
settings.notifications = apply_notification_update(&current_notifications, NotificationSettingsUpdate { settings.notifications = apply_notification_update(
enabled: current_notifications.enabled, &current_notifications,
mode: current_notifications.mode.clone(), NotificationSettingsUpdate {
provider: current_notifications.provider.clone(), enabled: current_notifications.enabled,
pushover_app_token: Some(current_notifications.pushover_app_token.clone()), mode: current_notifications.mode.clone(),
pushover_user_key: Some(current_notifications.pushover_user_key.clone()), provider: current_notifications.provider.clone(),
slack_webhook_url: Some(current_notifications.slack_webhook_url.clone()), pushover_app_token: Some(current_notifications.pushover_app_token.clone()),
discord_webhook_url: Some(current_notifications.discord_webhook_url.clone()), pushover_user_key: Some(current_notifications.pushover_user_key.clone()),
cooldown_seconds: current_notifications.cooldown_seconds, slack_webhook_url: Some(current_notifications.slack_webhook_url.clone()),
communication_failure_threshold: current_notifications.communication_failure_threshold, discord_webhook_url: Some(current_notifications.discord_webhook_url.clone()),
target_timeout_minutes: current_notifications.target_timeout_minutes, cooldown_seconds: current_notifications.cooldown_seconds,
alert_types: current_notifications.alert_types.clone(), communication_failure_threshold: current_notifications.communication_failure_threshold,
})?; target_timeout_minutes: current_notifications.target_timeout_minutes,
alert_types: current_notifications.alert_types.clone(),
},
)?;
settings.home_assistant.sensor_stale_after_seconds = settings.home_assistant.sensor_stale_after_seconds.clamp(30, 86_400); settings.home_assistant.sensor_stale_after_seconds = settings
.home_assistant
.sensor_stale_after_seconds
.clamp(30, 86_400);
normalize_sensor_aliases(&mut settings.home_assistant); normalize_sensor_aliases(&mut settings.home_assistant);
normalize_flow_shared_inputs(&mut settings.home_assistant)?; normalize_flow_shared_inputs(&mut settings.home_assistant)?;
canonicalize_home_assistant_entities(&mut settings.home_assistant); canonicalize_home_assistant_entities(&mut settings.home_assistant);
@@ -428,23 +607,34 @@ async fn lock_configuration_resources(
current_devices: &[Device], current_devices: &[Device],
export: &ConfigurationExport, export: &ConfigurationExport,
) -> ConfigurationResourceGuards { ) -> ConfigurationResourceGuards {
let mut zone_ids: Vec<String> = current_zones.iter().map(|zone| zone.id.clone()) let mut zone_ids: Vec<String> = current_zones
.iter()
.map(|zone| zone.id.clone())
.chain(export.zones.iter().map(|zone| zone.id.clone())) .chain(export.zones.iter().map(|zone| zone.id.clone()))
.collect(); .collect();
zone_ids.sort(); zone_ids.sort();
zone_ids.dedup(); zone_ids.dedup();
let mut zone_guards = Vec::with_capacity(zone_ids.len()); let mut zone_guards = Vec::with_capacity(zone_ids.len());
for zone_id in &zone_ids { zone_guards.push(state.lock_zone_operation(zone_id).await); } for zone_id in &zone_ids {
zone_guards.push(state.lock_zone_operation(zone_id).await);
}
let mut device_ids: Vec<String> = current_devices.iter().map(|device| device.id.clone()) let mut device_ids: Vec<String> = current_devices
.iter()
.map(|device| device.id.clone())
.chain(export.devices.iter().map(|device| device.id.clone())) .chain(export.devices.iter().map(|device| device.id.clone()))
.collect(); .collect();
device_ids.sort(); device_ids.sort();
device_ids.dedup(); device_ids.dedup();
let mut device_guards = Vec::with_capacity(device_ids.len()); let mut device_guards = Vec::with_capacity(device_ids.len());
for device_id in &device_ids { device_guards.push(state.lock_device_operation(device_id).await); } for device_id in &device_ids {
device_guards.push(state.lock_device_operation(device_id).await);
}
ConfigurationResourceGuards { _zones: zone_guards, _devices: device_guards } ConfigurationResourceGuards {
_zones: zone_guards,
_devices: device_guards,
}
} }
async fn power_off_detached_devices( async fn power_off_detached_devices(
@@ -452,37 +642,71 @@ async fn power_off_detached_devices(
current_zones: &[Zone], current_zones: &[Zone],
export: &ConfigurationExport, export: &ConfigurationExport,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let imported_zone_map: std::collections::HashMap<String, String> = export.zones.iter() let imported_zone_map: std::collections::HashMap<String, String> = export
.zones
.iter()
.map(|zone| (zone.id.clone(), zone.device_id.clone())) .map(|zone| (zone.id.clone(), zone.device_id.clone()))
.collect(); .collect();
let detach_devices: std::collections::HashSet<String> = current_zones.iter() let detach_devices: std::collections::HashSet<String> = current_zones
.filter(|current| imported_zone_map.get(&current.id).map(String::as_str) != Some(current.device_id.as_str())) .iter()
.filter(|current| {
imported_zone_map.get(&current.id).map(String::as_str)
!= Some(current.device_id.as_str())
})
.map(|current| current.device_id.clone()) .map(|current| current.device_id.clone())
.collect(); .collect();
for device_id in detach_devices { for device_id in detach_devices {
let Some(device) = state.db.get_device(&device_id)? else { continue; }; let Some(device) = state.db.get_device(&device_id)? else {
continue;
};
if !device.enabled { if !device.enabled {
return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into())); return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into()));
} }
engine::force_power_off_device_locked(state, &device_id).await?; engine::force_power_off_device_locked(state, &device_id).await?;
state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({ state.log(
"device_id": device.id, "source": "configuration.import" "info",
})); "zone.detach_power_off",
&format!(
"Powered off {} before detaching thermostat ownership",
device.name
),
json!({
"device_id": device.id, "source": "configuration.import"
}),
);
} }
Ok(()) Ok(())
} }
async fn reconcile_imported_devices(state: &AppState, export: &ConfigurationExport) -> Result<(), AppError> { async fn reconcile_imported_devices(
let controllable_devices: std::collections::HashSet<String> = export.zones.iter() state: &AppState,
export: &ConfigurationExport,
) -> Result<(), AppError> {
let controllable_devices: std::collections::HashSet<String> = export
.zones
.iter()
.filter(|zone| { .filter(|zone| {
let effective_mode = if zone.inherit_house_mode { export.settings.house_mode.as_str() } else { zone.mode.as_str() }; let effective_mode = if zone.inherit_house_mode {
export.settings.house_mode.as_str()
} else {
zone.mode.as_str()
};
zone.enabled && effective_mode != "off" zone.enabled && effective_mode != "off"
}) })
.map(|zone| zone.device_id.clone()) .map(|zone| zone.device_id.clone())
.collect(); .collect();
for device in export.devices.iter().filter(|device| device.enabled && !controllable_devices.contains(&device.id)) { for device in export
.devices
.iter()
.filter(|device| device.enabled && !controllable_devices.contains(&device.id))
{
if let Err(err) = engine::force_power_off_device_locked(state, &device.id).await { if let Err(err) = engine::force_power_off_device_locked(state, &device.id).await {
state.log("error", "configuration.import_reconcile_error", &err.to_string(), json!({"device_id": device.id})); state.log(
"error",
"configuration.import_reconcile_error",
&err.to_string(),
json!({"device_id": device.id}),
);
return Err(err); return Err(err);
} }
} }
@@ -504,18 +728,30 @@ async fn import_configuration(
let _cycle_guard = state.lock_zone_control_cycle().await; let _cycle_guard = state.lock_zone_control_cycle().await;
let current_zones = state.db.list_zones()?; let current_zones = state.db.list_zones()?;
let current_devices = state.db.list_devices()?; let current_devices = state.db.list_devices()?;
let _resource_guards = lock_configuration_resources(&state, &current_zones, &current_devices, &export).await; let _resource_guards =
lock_configuration_resources(&state, &current_zones, &current_devices, &export).await;
power_off_detached_devices(&state, &current_zones, &export).await?; power_off_detached_devices(&state, &current_zones, &export).await?;
sanitize_configuration_runtime(&mut export); sanitize_configuration_runtime(&mut export);
state.initial_device_sync_complete.store(false, Ordering::Release); state
.initial_device_sync_complete
.store(false, Ordering::Release);
state.db.replace_configuration(&export)?; state.db.replace_configuration(&export)?;
state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed); state
.debug_gree_frames
.store(export.settings.debug.gree_frames, Ordering::Relaxed);
*state.settings.write().await = export.settings.clone(); *state.settings.write().await = export.settings.clone();
reconcile_imported_devices(&state, &export).await?; reconcile_imported_devices(&state, &export).await?;
state.initial_device_sync_complete.store(true, Ordering::Release); state
.initial_device_sync_complete
.store(true, Ordering::Release);
state.wake_zone_control(); state.wake_zone_control();
state.log("info", "configuration.imported", "Application configuration imported", json!({"format_version": export.format_version})); state.log(
"info",
"configuration.imported",
"Application configuration imported",
json!({"format_version": export.format_version}),
);
state.broadcast("configuration.imported", json!({"at": Utc::now()})); state.broadcast("configuration.imported", json!({"at": Utc::now()}));
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }
+19 -6
View File
@@ -3,7 +3,9 @@ struct CreateAccessTokenRequest {
name: Option<String>, name: Option<String>,
} }
async fn list_access_tokens(State(state): State<AppState>) -> Result<Json<Vec<ApiTokenInfo>>, AppError> { async fn list_access_tokens(
State(state): State<AppState>,
) -> Result<Json<Vec<ApiTokenInfo>>, AppError> {
Ok(Json(state.db.list_api_tokens()?)) Ok(Json(state.db.list_api_tokens()?))
} }
@@ -11,9 +13,15 @@ async fn create_access_token(
State(state): State<AppState>, State(state): State<AppState>,
Json(input): Json<CreateAccessTokenRequest>, Json(input): Json<CreateAccessTokenRequest>,
) -> Result<(StatusCode, Json<Value>), AppError> { ) -> Result<(StatusCode, Json<Value>), AppError> {
let name = input.name.unwrap_or_else(|| "Home Assistant".into()).trim().to_string(); let name = input
.name
.unwrap_or_else(|| "Home Assistant".into())
.trim()
.to_string();
if name.is_empty() || name.len() > 80 { if name.is_empty() || name.len() > 80 {
return Err(AppError::BadRequest("token name must contain 1 to 80 characters".into())); return Err(AppError::BadRequest(
"token name must contain 1 to 80 characters".into(),
));
} }
let secret = generate_access_token(); let secret = generate_access_token();
@@ -30,10 +38,16 @@ async fn create_access_token(
"Created a Home Assistant access token", "Created a Home Assistant access token",
json!({"token_id": item.id.clone(), "name": item.name.clone()}), json!({"token_id": item.id.clone(), "name": item.name.clone()}),
); );
Ok((StatusCode::CREATED, Json(json!({"token": secret, "item": item})))) Ok((
StatusCode::CREATED,
Json(json!({"token": secret, "item": item})),
))
} }
async fn delete_access_token(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> { async fn delete_access_token(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, AppError> {
if !state.db.delete_api_token(&id)? { if !state.db.delete_api_token(&id)? {
return Err(AppError::NotFound(format!("access token {id}"))); return Err(AppError::NotFound(format!("access token {id}")));
} }
@@ -45,4 +59,3 @@ async fn delete_access_token(State(state): State<AppState>, Path(id): Path<Strin
); );
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
+175 -51
View File
@@ -1,11 +1,25 @@
async fn discover(State(state): State<AppState>, Json(request): Json<DiscoveryRequest>) -> Result<Json<Value>, AppError> { async fn discover(
State(state): State<AppState>,
Json(request): Json<DiscoveryRequest>,
) -> Result<Json<Value>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
let settings = state.settings.read().await.clone(); let settings = state.settings.read().await.clone();
let timeout_ms = request.timeout_ms.unwrap_or(settings.discovery_timeout_ms).clamp(500, 30_000); let timeout_ms = request
.timeout_ms
.unwrap_or(settings.discovery_timeout_ms)
.clamp(500, 30_000);
let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast); let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast);
let protocol_version = request.protocol_version.unwrap_or(0).min(2); let protocol_version = request.protocol_version.unwrap_or(0).min(2);
let passes = request.passes.unwrap_or(3).clamp(1, 10); let passes = request.passes.unwrap_or(3).clamp(1, 10);
let discovered = state.gree.discover(&broadcast, Duration::from_millis(timeout_ms), protocol_version, passes).await let discovered = state
.gree
.discover(
&broadcast,
Duration::from_millis(timeout_ms),
protocol_version,
passes,
)
.await
.map_err(|e| AppError::Device(e.to_string()))?; .map_err(|e| AppError::Device(e.to_string()))?;
let mut saved = Vec::new(); let mut saved = Vec::new();
let mut new_device_ids = Vec::new(); let mut new_device_ids = Vec::new();
@@ -33,31 +47,48 @@ async fn discover(State(state): State<AppState>, Json(request): Json<DiscoveryRe
} }
Err(err) => { Err(err) => {
merged.last_error = Some(format!("discovered, bind pending: {err}")); merged.last_error = Some(format!("discovered, bind pending: {err}"));
state.log("warn", "device.bind_after_discovery", &format!("{}: {err}", merged.name), json!({"device_id": merged.id})); state.log(
"warn",
"device.bind_after_discovery",
&format!("{}: {err}", merged.name),
json!({"device_id": merged.id}),
);
} }
} }
} }
state.db.save_device(&merged)?; state.db.save_device(&merged)?;
if is_new { new_device_ids.push(merged.id.clone()); } if is_new {
new_device_ids.push(merged.id.clone());
}
saved.push(merged); saved.push(merged);
} }
state.log("info", "discovery.complete", &format!("Discovery found {} device(s)", saved.len()), json!({"count": saved.len(), "protocol_version": protocol_version, "passes": passes, "new_devices": new_device_ids.len()})); state.log("info", "discovery.complete", &format!("Discovery found {} device(s)", saved.len()), json!({"count": saved.len(), "protocol_version": protocol_version, "passes": passes, "new_devices": new_device_ids.len()}));
state.broadcast("devices.discovered", json!({"devices": saved})); state.broadcast("devices.discovered", json!({"devices": saved}));
Ok(Json(json!({"count": saved.len(), "devices": saved, "new_device_ids": new_device_ids}))) Ok(Json(
json!({"count": saved.len(), "devices": saved, "new_device_ids": new_device_ids}),
))
} }
async fn list_devices(State(state): State<AppState>) -> Result<Json<Vec<Device>>, AppError> { async fn list_devices(State(state): State<AppState>) -> Result<Json<Vec<Device>>, AppError> {
Ok(Json(state.db.list_devices()?)) Ok(Json(state.db.list_devices()?))
} }
async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDeviceRequest>) -> Result<(StatusCode, Json<Device>), AppError> { async fn add_device(
State(state): State<AppState>,
Json(input): Json<ManualDeviceRequest>,
) -> Result<(StatusCode, Json<Device>), AppError> {
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
if input.name.trim().is_empty() || input.mac.trim().is_empty() || input.ip.trim().is_empty() { if input.name.trim().is_empty() || input.mac.trim().is_empty() || input.ip.trim().is_empty() {
return Err(AppError::BadRequest("name, mac and ip are required".into())); return Err(AppError::BadRequest("name, mac and ip are required".into()));
} }
input.ip.parse::<IpAddr>().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; input
.ip
.parse::<IpAddr>()
.map_err(|_| AppError::BadRequest("invalid IP address".into()))?;
if state.db.get_device_by_mac(&input.mac)?.is_some() { if state.db.get_device_by_mac(&input.mac)?.is_some() {
return Err(AppError::BadRequest("a device with this MAC already exists".into())); return Err(AppError::BadRequest(
"a device with this MAC already exists".into(),
));
} }
let now = Utc::now(); let now = Utc::now();
let normalized_mac = input.mac.replace([':', '-'], "").to_ascii_uppercase(); let normalized_mac = input.mac.replace([':', '-'], "").to_ascii_uppercase();
@@ -106,25 +137,54 @@ async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDevic
updated_at: now, updated_at: now,
}; };
state.db.save_device(&device)?; state.db.save_device(&device)?;
state.log("info", "device.created", &format!("Added {}", device.name), json!({"device_id": device.id})); state.log(
"info",
"device.created",
&format!("Added {}", device.name),
json!({"device_id": device.id}),
);
state.broadcast("device.created", serde_json::to_value(&device)?); state.broadcast("device.created", serde_json::to_value(&device)?);
Ok((StatusCode::CREATED, Json(device))) Ok((StatusCode::CREATED, Json(device)))
} }
async fn get_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> { async fn get_device(
state.db.get_device(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("device {id}"))) State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Device>, AppError> {
state
.db
.get_device(&id)?
.map(Json)
.ok_or_else(|| AppError::NotFound(format!("device {id}")))
} }
async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<DevicePatch>) -> Result<Json<Device>, AppError> { async fn patch_device(
State(state): State<AppState>,
Path(id): Path<String>,
Json(patch): Json<DevicePatch>,
) -> Result<Json<Device>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
if patch.enabled == Some(false) { if patch.enabled == Some(false) {
engine::disable_device_safely(&state, &id).await?; engine::disable_device_safely(&state, &id).await?;
} }
let _device_guard = state.lock_device_operation(&id).await; let _device_guard = state.lock_device_operation(&id).await;
let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; let mut device = state
if let Some(v) = patch.name { if !v.trim().is_empty() { device.name = v.trim().to_string(); } } .db
if let Some(v) = patch.ip { v.parse::<IpAddr>().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; device.ip = v; } .get_device(&id)?
if let Some(v) = patch.port { device.port = v; } .ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
if let Some(v) = patch.name {
if !v.trim().is_empty() {
device.name = v.trim().to_string();
}
}
if let Some(v) = patch.ip {
v.parse::<IpAddr>()
.map_err(|_| AppError::BadRequest("invalid IP address".into()))?;
device.ip = v;
}
if let Some(v) = patch.port {
device.port = v;
}
if let Some(v) = patch.protocol_version { if let Some(v) = patch.protocol_version {
let v = v.min(2); let v = v.min(2);
if device.protocol_version != v { if device.protocol_version != v {
@@ -139,8 +199,12 @@ async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Jso
device.supports_sleep = None; device.supports_sleep = None;
} }
} }
if let Some(v) = patch.key { device.key = v.filter(|x| !x.trim().is_empty()); } if let Some(v) = patch.key {
if let Some(v) = patch.enabled { device.enabled = v; } device.key = v.filter(|x| !x.trim().is_empty());
}
if let Some(v) = patch.enabled {
device.enabled = v;
}
device.updated_at = Utc::now(); device.updated_at = Utc::now();
state.db.save_device(&device)?; state.db.save_device(&device)?;
state.broadcast("device.updated", serde_json::to_value(&device)?); state.broadcast("device.updated", serde_json::to_value(&device)?);
@@ -151,7 +215,10 @@ async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Jso
Ok(Json(device)) Ok(Json(device))
} }
async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> { async fn delete_device(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, AppError> {
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
// Keep reference validation and the destructive DB operation in one serialized window. // Keep reference validation and the destructive DB operation in one serialized window.
// Lock order for cross-resource destructive operations: configuration -> automation -> house -> schedule -> cycle -> zones -> device. // Lock order for cross-resource destructive operations: configuration -> automation -> house -> schedule -> cycle -> zones -> device.
@@ -159,14 +226,21 @@ async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) ->
let _house_guard = state.lock_house_operation().await; let _house_guard = state.lock_house_operation().await;
let _schedule_guard = state.lock_schedule_operation().await; let _schedule_guard = state.lock_schedule_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await; let _cycle_guard = state.lock_zone_control_cycle().await;
if state.db.get_device(&id)?.is_none() { return Err(AppError::NotFound(format!("device {id}"))); } if state.db.get_device(&id)?.is_none() {
return Err(AppError::NotFound(format!("device {id}")));
}
if state.db.list_automations()?.iter().any(|item| { if state.db.list_automations()?.iter().any(|item| {
item.trigger_device_id.as_deref() == Some(id.as_str()) item.trigger_device_id.as_deref() == Some(id.as_str())
|| (item.action_group_id.is_none() && item.action_device_id == id) || (item.action_group_id.is_none() && item.action_device_id == id)
}) { }) {
return Err(AppError::BadRequest("device is used by an automation; remove or retarget that automation first".into())); return Err(AppError::BadRequest(
"device is used by an automation; remove or retarget that automation first".into(),
));
} }
let removed_zone_ids: std::collections::HashSet<String> = state.db.list_zones()?.into_iter() let removed_zone_ids: std::collections::HashSet<String> = state
.db
.list_zones()?
.into_iter()
.filter(|zone| zone.device_id == id) .filter(|zone| zone.device_id == id)
.map(|zone| zone.id) .map(|zone| zone.id)
.collect(); .collect();
@@ -178,20 +252,39 @@ async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) ->
} }
ensure_zone_removal_safe(&state, &removed_zone_ids)?; ensure_zone_removal_safe(&state, &removed_zone_ids)?;
ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?; ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?;
if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); } if !state.db.delete_device(&id)? {
return Err(AppError::NotFound(format!("device {id}")));
}
drop(zone_guards); drop(zone_guards);
remove_zone_ids_from_groups_locked(&state, &removed_zone_ids).await?; remove_zone_ids_from_groups_locked(&state, &removed_zone_ids).await?;
state.log("info", "device.deleted", "Device deleted", json!({"device_id": id})); state.log(
"info",
"device.deleted",
"Device deleted",
json!({"device_id": id}),
);
state.broadcast("device.deleted", json!({"id": id})); state.broadcast("device.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
async fn bind_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> { async fn bind_device(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Device>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
let _device_guard = state.lock_device_operation(&id).await; let _device_guard = state.lock_device_operation(&id).await;
let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; let mut device = state
if device.simulated { return Ok(Json(device)); } .db
let bound = state.gree.bind(&device).await.map_err(|e| AppError::Device(e.to_string()))?; .get_device(&id)?
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
if device.simulated {
return Ok(Json(device));
}
let bound = state
.gree
.bind(&device)
.await
.map_err(|e| AppError::Device(e.to_string()))?;
device.key = Some(bound.key); device.key = Some(bound.key);
device.protocol_version = bound.protocol_version; device.protocol_version = bound.protocol_version;
device.communication_failures = 0; device.communication_failures = 0;
@@ -200,17 +293,35 @@ async fn bind_device(State(state): State<AppState>, Path(id): Path<String>) -> R
device.last_error = None; device.last_error = None;
device.updated_at = Utc::now(); device.updated_at = Utc::now();
state.db.save_device(&device)?; state.db.save_device(&device)?;
state.log("info", "device.bound", &format!("Bound {}", device.name), json!({"device_id": id})); state.log(
"info",
"device.bound",
&format!("Bound {}", device.name),
json!({"device_id": id}),
);
Ok(Json(device)) Ok(Json(device))
} }
async fn poll_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> { async fn poll_device(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Device>, AppError> {
Ok(Json(engine::poll_one(&state, &id).await?)) Ok(Json(engine::poll_one(&state, &id).await?))
} }
async fn probe_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, AppError> { async fn probe_device(
let device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; State(state): State<AppState>,
let response_time_ms = state.gree.probe(&device).await.map_err(|err| AppError::Device(err.to_string()))?; Path(id): Path<String>,
) -> Result<Json<Value>, AppError> {
let device = state
.db
.get_device(&id)?
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
let response_time_ms = state
.gree
.probe(&device)
.await
.map_err(|err| AppError::Device(err.to_string()))?;
Ok(Json(json!({ Ok(Json(json!({
"device_id": device.id, "device_id": device.id,
"response_time_ms": response_time_ms, "response_time_ms": response_time_ms,
@@ -226,23 +337,36 @@ struct ManualDeviceCommandRequest {
manual_override: bool, manual_override: bool,
} }
async fn command_device(State(state): State<AppState>, Path(id): Path<String>, Json(request): Json<ManualDeviceCommandRequest>) -> Result<Json<Device>, AppError> { async fn command_device(
Ok(Json(engine::send_manual_command( State(state): State<AppState>,
&state, Path(id): Path<String>,
&id, Json(request): Json<ManualDeviceCommandRequest>,
request.command, ) -> Result<Json<Device>, AppError> {
"device.manual_control", Ok(Json(
request.manual_override, engine::send_manual_command(
).await?)) &state,
&id,
request.command,
"device.manual_control",
request.manual_override,
)
.await?,
))
} }
async fn command_home_assistant_device(State(state): State<AppState>, Path(id): Path<String>, Json(command): Json<DeviceCommand>) -> Result<Json<Device>, AppError> { async fn command_home_assistant_device(
Ok(Json(engine::send_manual_command( State(state): State<AppState>,
&state, Path(id): Path<String>,
&id, Json(command): Json<DeviceCommand>,
command, ) -> Result<Json<Device>, AppError> {
"home_assistant.device_manual_control", Ok(Json(
false, engine::send_manual_command(
).await?)) &state,
&id,
command,
"home_assistant.device_manual_control",
false,
)
.await?,
))
} }
+10 -3
View File
@@ -1,5 +1,12 @@
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct EventsQuery { limit: Option<u32> } struct EventsQuery {
async fn events(State(state): State<AppState>, Query(query): Query<EventsQuery>) -> Result<Json<Value>, AppError> { limit: Option<u32>,
Ok(Json(json!({"events": state.db.list_events(query.limit.unwrap_or(100))?}))) }
async fn events(
State(state): State<AppState>,
Query(query): Query<EventsQuery>,
) -> Result<Json<Value>, AppError> {
Ok(Json(
json!({"events": state.db.list_events(query.limit.unwrap_or(100))?}),
))
} }
+1406 -328
View File
File diff suppressed because it is too large Load Diff
+168 -61
View File
@@ -8,7 +8,8 @@ struct GroupInput {
} }
fn normalize_group_zone_ids(zone_ids: Vec<String>) -> Vec<String> { fn normalize_group_zone_ids(zone_ids: Vec<String>) -> Vec<String> {
let mut values: Vec<String> = zone_ids.into_iter() let mut values: Vec<String> = zone_ids
.into_iter()
.map(|value| value.trim().to_string()) .map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.collect(); .collect();
@@ -23,11 +24,15 @@ fn validate_group_input(state: &AppState, input: &GroupInput) -> Result<Vec<Stri
} }
let zone_ids = normalize_group_zone_ids(input.zone_ids.clone()); let zone_ids = normalize_group_zone_ids(input.zone_ids.clone());
if zone_ids.is_empty() { if zone_ids.is_empty() {
return Err(AppError::BadRequest("group must contain at least one zone".into())); return Err(AppError::BadRequest(
"group must contain at least one zone".into(),
));
} }
for zone_id in &zone_ids { for zone_id in &zone_ids {
if state.db.get_zone(zone_id)?.is_none() { if state.db.get_zone(zone_id)?.is_none() {
return Err(AppError::BadRequest(format!("group references missing zone {zone_id}"))); return Err(AppError::BadRequest(format!(
"group references missing zone {zone_id}"
)));
} }
} }
Ok(zone_ids) Ok(zone_ids)
@@ -37,11 +42,21 @@ async fn list_groups(State(state): State<AppState>) -> Result<Json<Vec<ClimateGr
Ok(Json(state.db.list_groups()?)) Ok(Json(state.db.list_groups()?))
} }
async fn get_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<ClimateGroup>, AppError> { async fn get_group(
state.db.get_group(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("group {id}"))) 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> { async fn create_group(
State(state): State<AppState>,
Json(input): Json<GroupInput>,
) -> Result<(StatusCode, Json<ClimateGroup>), AppError> {
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
let _reference_guard = state.lock_automation_operation().await; let _reference_guard = state.lock_automation_operation().await;
let _house_guard = state.lock_house_operation().await; let _house_guard = state.lock_house_operation().await;
@@ -62,7 +77,11 @@ async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInpu
Ok((StatusCode::CREATED, Json(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> { async fn update_group(
State(state): State<AppState>,
Path(id): Path<String>,
Json(input): Json<GroupInput>,
) -> Result<Json<ClimateGroup>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
// Membership changes alter the target set of group automations, so serialize them with // Membership changes alter the target set of group automations, so serialize them with
// automation execution/reference validation before taking the group lock. // automation execution/reference validation before taking the group lock.
@@ -70,7 +89,10 @@ async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Jso
let _house_guard = state.lock_house_operation().await; let _house_guard = state.lock_house_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await; let _cycle_guard = state.lock_zone_control_cycle().await;
let _group_guard = state.lock_group_operation(&id).await; let _group_guard = state.lock_group_operation(&id).await;
let existing = state.db.get_group(&id)?.ok_or_else(|| AppError::NotFound(format!("group {id}")))?; let existing = state
.db
.get_group(&id)?
.ok_or_else(|| AppError::NotFound(format!("group {id}")))?;
let zone_ids = validate_group_input(&state, &input)?; let zone_ids = validate_group_input(&state, &input)?;
let group = ClimateGroup { let group = ClimateGroup {
id, id,
@@ -86,46 +108,90 @@ async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Jso
Ok(Json(group)) Ok(Json(group))
} }
async fn delete_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> { async fn delete_group(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, AppError> {
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
let _automation_guard = state.lock_automation_operation().await; let _automation_guard = state.lock_automation_operation().await;
let _house_guard = state.lock_house_operation().await; let _house_guard = state.lock_house_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await; let _cycle_guard = state.lock_zone_control_cycle().await;
let _group_guard = state.lock_group_operation(&id).await; let _group_guard = state.lock_group_operation(&id).await;
if state.db.list_automations()?.iter().any(|item| item.action_group_id.as_deref() == Some(id.as_str())) { if state
return Err(AppError::BadRequest("group is used by an automation; remove or retarget that automation first".into())); .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}")));
} }
if !state.db.delete_group(&id)? { return Err(AppError::NotFound(format!("group {id}"))); }
state.broadcast("group.deleted", json!({"id": id})); state.broadcast("group.deleted", json!({"id": id}));
state.wake_zone_control(); state.wake_zone_control();
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
fn ensure_zone_removal_safe(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> { fn ensure_zone_removal_safe(
if zone_ids.is_empty() { return Ok(()); } state: &AppState,
let automated_groups: std::collections::HashSet<String> = state.db.list_automations()?.into_iter() zone_ids: &std::collections::HashSet<String>,
) -> Result<(), AppError> {
if zone_ids.is_empty() {
return Ok(());
}
let automated_groups: std::collections::HashSet<String> = state
.db
.list_automations()?
.into_iter()
.filter_map(|item| item.action_group_id) .filter_map(|item| item.action_group_id)
.collect(); .collect();
for group in state.db.list_groups()? { for group in state.db.list_groups()? {
let remaining = group.zone_ids.iter().filter(|zone_id| !zone_ids.contains(*zone_id)).count(); let remaining = group
if remaining == 0 && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id)) && automated_groups.contains(&group.id) { .zone_ids
.iter()
.filter(|zone_id| !zone_ids.contains(*zone_id))
.count();
if remaining == 0
&& group
.zone_ids
.iter()
.any(|zone_id| zone_ids.contains(zone_id))
&& automated_groups.contains(&group.id)
{
return Err(AppError::BadRequest(format!("cannot remove the last zone from group '{}' while an automation targets that group", group.name))); return Err(AppError::BadRequest(format!("cannot remove the last zone from group '{}' while an automation targets that group", group.name)));
} }
} }
Ok(()) Ok(())
} }
async fn remove_zone_ids_from_groups_locked(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> { async fn remove_zone_ids_from_groups_locked(
if zone_ids.is_empty() { return Ok(()); } state: &AppState,
let mut group_ids: Vec<String> = state.db.list_groups()?.into_iter().map(|group| group.id).collect(); zone_ids: &std::collections::HashSet<String>,
) -> Result<(), AppError> {
if zone_ids.is_empty() {
return Ok(());
}
let mut group_ids: Vec<String> = state
.db
.list_groups()?
.into_iter()
.map(|group| group.id)
.collect();
group_ids.sort(); group_ids.sort();
group_ids.dedup(); group_ids.dedup();
for group_id in group_ids { for group_id in group_ids {
let _group_guard = state.lock_group_operation(&group_id).await; let _group_guard = state.lock_group_operation(&group_id).await;
let Some(mut group) = state.db.get_group(&group_id)? else { continue; }; let Some(mut group) = state.db.get_group(&group_id)? else {
continue;
};
let before = group.zone_ids.len(); let before = group.zone_ids.len();
group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id)); group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id));
if group.zone_ids.len() == before { continue; } if group.zone_ids.len() == before {
continue;
}
if group.zone_ids.is_empty() { if group.zone_ids.is_empty() {
state.db.delete_group(&group.id)?; state.db.delete_group(&group.id)?;
state.broadcast("group.deleted", json!({"id": group.id})); state.broadcast("group.deleted", json!({"id": group.id}));
@@ -138,19 +204,31 @@ async fn remove_zone_ids_from_groups_locked(state: &AppState, zone_ids: &std::co
Ok(()) Ok(())
} }
async fn update_group_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<GroupControlPatch>) -> Result<Json<Value>, AppError> { async fn update_group_control(
Ok(Json(engine::control_group(&state, &id, patch, "group.quick_control").await?)) 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?,
))
} }
fn home_assistant_group_mode(zones: &[&Zone]) -> String { fn home_assistant_group_mode(zones: &[&Zone]) -> String {
let mut value: Option<&str> = None; let mut value: Option<&str> = None;
for zone in zones { for zone in zones {
let current = if zone.inherit_house_mode { "house" } else { zone.mode.as_str() }; let current = if zone.inherit_house_mode {
"house"
} else {
zone.mode.as_str()
};
if !matches!(current, "house" | "cool" | "heat") { if !matches!(current, "house" | "cool" | "heat") {
return "mixed".into(); return "mixed".into();
} }
if let Some(previous) = value { if let Some(previous) = value {
if previous != current { return "mixed".into(); } if previous != current {
return "mixed".into();
}
} else { } else {
value = Some(current); value = Some(current);
} }
@@ -166,7 +244,9 @@ fn home_assistant_group_preset(zones: &[&Zone]) -> String {
return "mixed".into(); return "mixed".into();
} }
if let Some(previous) = value { if let Some(previous) = value {
if previous != current { return "mixed".into(); } if previous != current {
return "mixed".into();
}
} else { } else {
value = Some(current); value = Some(current);
} }
@@ -174,14 +254,17 @@ fn home_assistant_group_preset(zones: &[&Zone]) -> String {
value.unwrap_or("mixed").to_string() value.unwrap_or("mixed").to_string()
} }
fn home_assistant_group_custom_setpoint(zones: &[&Zone]) -> Option<f64> { fn home_assistant_group_custom_setpoint(zones: &[&Zone]) -> Option<f64> {
let mut value: Option<f64> = None; let mut value: Option<f64> = None;
for zone in zones { for zone in zones {
if zone.manual_preset.as_deref() != Some("custom") { return None; } if zone.manual_preset.as_deref() != Some("custom") {
return None;
}
let current = zone.manual_setpoint.or(zone.effective_setpoint)?; let current = zone.manual_setpoint.or(zone.effective_setpoint)?;
if let Some(previous) = value { if let Some(previous) = value {
if (previous - current).abs() > 0.05 { return None; } if (previous - current).abs() > 0.05 {
return None;
}
} else { } else {
value = Some(current); value = Some(current);
} }
@@ -189,7 +272,9 @@ fn home_assistant_group_custom_setpoint(zones: &[&Zone]) -> Option<f64> {
value value
} }
async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Json<Vec<Value>>, AppError> { async fn list_home_assistant_groups(
State(state): State<AppState>,
) -> Result<Json<Vec<Value>>, AppError> {
let groups = state.db.list_groups()?; let groups = state.db.list_groups()?;
let zones = state.db.list_zones()?; let zones = state.db.list_zones()?;
let devices = state.db.list_devices()?; let devices = state.db.list_devices()?;
@@ -198,19 +283,35 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
let mut output = Vec::with_capacity(groups.len()); let mut output = Vec::with_capacity(groups.len());
for group in groups { for group in groups {
let members = zones.iter() let members = zones
.iter()
.filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)) .filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.id))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let planned_members = plan.zones.iter() let planned_members = plan
.filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.zone_id)) .zones
.iter()
.filter(|zone| {
group
.zone_ids
.iter()
.any(|zone_id| zone_id == &zone.zone_id)
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let zone_names = members.iter().map(|zone| zone.name.clone()).collect::<Vec<_>>(); let zone_names = members
let member_device_ids = members.iter().map(|zone| zone.device_id.as_str()).collect::<std::collections::HashSet<_>>(); .iter()
let online_devices = devices.iter() .map(|zone| zone.name.clone())
.collect::<Vec<_>>();
let member_device_ids = members
.iter()
.map(|zone| zone.device_id.as_str())
.collect::<std::collections::HashSet<_>>();
let online_devices = devices
.iter()
.filter(|device| member_device_ids.contains(device.id.as_str()) && device.online) .filter(|device| member_device_ids.contains(device.id.as_str()) && device.online)
.count(); .count();
let current_temperatures = planned_members.iter() let current_temperatures = planned_members
.iter()
.filter_map(|zone| zone.current_temperature) .filter_map(|zone| zone.current_temperature)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let current_temperature = if current_temperatures.is_empty() { let current_temperature = if current_temperatures.is_empty() {
@@ -228,27 +329,32 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
} }
next_events.sort_by_key(|event| event.at); next_events.sort_by_key(|event| event.at);
next_events.truncate(8); next_events.truncate(8);
let member_states = planned_members.iter().map(|zone| json!({ let member_states = planned_members
"zone_id": zone.zone_id, .iter()
"zone_name": zone.zone_name, .map(|zone| {
"device_id": zone.device_id, json!({
"device_name": zone.device_name, "zone_id": zone.zone_id,
"enabled": zone.enabled, "zone_name": zone.zone_name,
"effective_enabled": zone.effective_enabled, "device_id": zone.device_id,
"mode": zone.mode, "device_name": zone.device_name,
"configured_mode": zone.configured_mode, "enabled": zone.enabled,
"inherit_house_mode": zone.inherit_house_mode, "effective_enabled": zone.effective_enabled,
"preset": zone.preset, "mode": zone.mode,
"current_temperature": zone.current_temperature, "configured_mode": zone.configured_mode,
"target_temperature": zone.target_temperature, "inherit_house_mode": zone.inherit_house_mode,
"demand": zone.demand, "preset": zone.preset,
"control_source": zone.control_source, "current_temperature": zone.current_temperature,
"current_schedule": zone.current_schedule_name, "target_temperature": zone.target_temperature,
"local_thermostat_power": zone.local_thermostat_power, "demand": zone.demand,
"local_thermostat_resume_at": zone.local_thermostat_resume_at, "control_source": zone.control_source,
"device_manual_override": zone.device_manual_override, "current_schedule": zone.current_schedule_name,
"device_manual_override_until": zone.device_manual_override_until, "local_thermostat_power": zone.local_thermostat_power,
})).collect::<Vec<_>>(); "local_thermostat_resume_at": zone.local_thermostat_resume_at,
"device_manual_override": zone.device_manual_override,
"device_manual_override_until": zone.device_manual_override_until,
})
})
.collect::<Vec<_>>();
output.push(json!({ output.push(json!({
"id": group.id, "id": group.id,
@@ -281,6 +387,7 @@ async fn update_home_assistant_group_control(
Path(id): Path<String>, Path(id): Path<String>,
Json(patch): Json<GroupControlPatch>, Json(patch): Json<GroupControlPatch>,
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
Ok(Json(engine::control_group(&state, &id, patch, "home_assistant.group_control").await?)) Ok(Json(
engine::control_group(&state, &id, patch, "home_assistant.group_control").await?,
))
} }
+262 -68
View File
@@ -1,8 +1,19 @@
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ReadingsQuery { device_id: Option<String>, hours: Option<i64>, limit: Option<u32> } struct ReadingsQuery {
async fn readings(State(state): State<AppState>, Query(query): Query<ReadingsQuery>) -> Result<Json<Value>, AppError> { device_id: Option<String>,
hours: Option<i64>,
limit: Option<u32>,
}
async fn readings(
State(state): State<AppState>,
Query(query): Query<ReadingsQuery>,
) -> Result<Json<Value>, AppError> {
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650); let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650);
let values = state.db.list_readings(query.device_id.as_deref(), Utc::now() - ChronoDuration::hours(hours), query.limit.unwrap_or(1500))?; let values = state.db.list_readings(
query.device_id.as_deref(),
Utc::now() - ChronoDuration::hours(hours),
query.limit.unwrap_or(1500),
)?;
Ok(Json(json!({"readings": values}))) Ok(Json(json!({"readings": values})))
} }
@@ -29,24 +40,27 @@ fn history_bucket_seconds(hours: i64) -> i64 {
} }
fn fallback_zone_rows(zone: &Zone, device: &Device, readings: Vec<Reading>) -> Vec<ZoneReading> { fn fallback_zone_rows(zone: &Zone, device: &Device, readings: Vec<Reading>) -> Vec<ZoneReading> {
readings.into_iter().map(|reading| ZoneReading { readings
id: reading.id, .into_iter()
zone_id: zone.id.clone(), .map(|reading| ZoneReading {
device_id: zone.device_id.clone(), id: reading.id,
timestamp: reading.timestamp, zone_id: zone.id.clone(),
gree_temperature: reading.indoor_temperature, device_id: zone.device_id.clone(),
external_temperature: None, timestamp: reading.timestamp,
control_temperature: reading.indoor_temperature, gree_temperature: reading.indoor_temperature,
target_temperature: Some(reading.target_temperature), external_temperature: None,
device_setpoint: Some(reading.target_temperature), control_temperature: reading.indoor_temperature,
outdoor_temperature: reading.outdoor_temperature, target_temperature: Some(reading.target_temperature),
power: reading.power, device_setpoint: Some(reading.target_temperature),
mode: device.mode.clone(), outdoor_temperature: reading.outdoor_temperature,
fan_speed: device.fan_speed, power: reading.power,
demand: false, mode: device.mode.clone(),
control_source: "gree_history_fallback".into(), fan_speed: device.fan_speed,
active_preset: "history".into(), demand: false,
}).collect() control_source: "gree_history_fallback".into(),
active_preset: "history".into(),
})
.collect()
} }
fn zone_history_with_fallback( fn zone_history_with_fallback(
@@ -56,23 +70,43 @@ fn zone_history_with_fallback(
bucket_seconds: i64, bucket_seconds: i64,
limit: u32, limit: u32,
) -> Result<Vec<ZoneReading>, AppError> { ) -> Result<Vec<ZoneReading>, AppError> {
let mut values = state.db.list_zone_history(zone_id, since.clone(), bucket_seconds, limit)?; let mut values = state
.db
.list_zone_history(zone_id, since.clone(), bucket_seconds, limit)?;
if let Some(zone_id) = zone_id { if let Some(zone_id) = zone_id {
if values.is_empty() { if values.is_empty() {
let zone = state.db.get_zone(zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?; let zone = state
.db
.get_zone(zone_id)?
.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?;
if let Some(device) = state.db.get_device(&zone.device_id)? { if let Some(device) = state.db.get_device(&zone.device_id)? {
let rows = state.db.list_device_history(Some(&zone.device_id), since.clone(), bucket_seconds, limit)?; let rows = state.db.list_device_history(
Some(&zone.device_id),
since.clone(),
bucket_seconds,
limit,
)?;
values = fallback_zone_rows(&zone, &device, rows); values = fallback_zone_rows(&zone, &device, rows);
} }
} }
return Ok(values); return Ok(values);
} }
let existing: std::collections::HashSet<String> = values.iter().map(|row| row.zone_id.clone()).collect(); let existing: std::collections::HashSet<String> =
values.iter().map(|row| row.zone_id.clone()).collect();
for zone in state.db.list_zones()? { for zone in state.db.list_zones()? {
if existing.contains(&zone.id) { continue; } if existing.contains(&zone.id) {
let Some(device) = state.db.get_device(&zone.device_id)? else { continue; }; continue;
let rows = state.db.list_device_history(Some(&zone.device_id), since.clone(), bucket_seconds, limit)?; }
let Some(device) = state.db.get_device(&zone.device_id)? else {
continue;
};
let rows = state.db.list_device_history(
Some(&zone.device_id),
since.clone(),
bucket_seconds,
limit,
)?;
values.extend(fallback_zone_rows(&zone, &device, rows)); values.extend(fallback_zone_rows(&zone, &device, rows));
} }
values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp)); values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp));
@@ -90,33 +124,68 @@ fn sensor_history_with_fallback(
limit: u32, limit: u32,
outdoor_entity: &str, outdoor_entity: &str,
) -> Result<Vec<HaReading>, AppError> { ) -> Result<Vec<HaReading>, AppError> {
let mut values = state.db.list_ha_history(None, since.clone(), bucket_seconds, limit)?; let mut values = state
let mut existing: std::collections::HashSet<String> = values.iter().map(|row| row.entity_id.clone()).collect(); .db
.list_ha_history(None, since.clone(), bucket_seconds, limit)?;
let mut existing: std::collections::HashSet<String> =
values.iter().map(|row| row.entity_id.clone()).collect();
for zone in state.db.list_zones()? { for zone in state.db.list_zones()? {
let Some(entity_id) = zone.ha_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { continue; }; let Some(entity_id) = zone
if existing.contains(entity_id) { continue; } .ha_entity_id
let rows = state.db.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?; .as_deref()
.filter(|value| !value.trim().is_empty())
else {
continue;
};
if existing.contains(entity_id) {
continue;
}
let rows =
state
.db
.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?;
let mut added = false; let mut added = false;
for row in rows { for row in rows {
if let Some(temperature) = row.external_temperature { if let Some(temperature) = row.external_temperature {
values.push(HaReading { id: row.id, entity_id: entity_id.to_string(), zone_id: Some(zone.id.clone()), kind: "room".into(), timestamp: row.timestamp, temperature }); values.push(HaReading {
id: row.id,
entity_id: entity_id.to_string(),
zone_id: Some(zone.id.clone()),
kind: "room".into(),
timestamp: row.timestamp,
temperature,
});
added = true; added = true;
} }
} }
if added { existing.insert(entity_id.to_string()); } if added {
existing.insert(entity_id.to_string());
}
} }
let outdoor_entity = outdoor_entity.trim(); let outdoor_entity = outdoor_entity.trim();
if !outdoor_entity.is_empty() && !existing.contains(outdoor_entity) { if !outdoor_entity.is_empty() && !existing.contains(outdoor_entity) {
for zone in state.db.list_zones()? { for zone in state.db.list_zones()? {
let rows = state.db.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?; let rows =
state
.db
.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?;
let mut added = false; let mut added = false;
for row in rows { for row in rows {
if let Some(temperature) = row.outdoor_temperature { if let Some(temperature) = row.outdoor_temperature {
values.push(HaReading { id: row.id, entity_id: outdoor_entity.to_string(), zone_id: None, kind: "outdoor".into(), timestamp: row.timestamp, temperature }); values.push(HaReading {
id: row.id,
entity_id: outdoor_entity.to_string(),
zone_id: None,
kind: "outdoor".into(),
timestamp: row.timestamp,
temperature,
});
added = true; added = true;
} }
} }
if added { break; } if added {
break;
}
} }
} }
values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp)); values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp));
@@ -137,23 +206,54 @@ async fn combined_device_history(
let influx = state.settings.read().await.influxdb.clone(); let influx = state.settings.read().await.influxdb.clone();
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64); let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
if !influx.enabled || since >= cutoff { if !influx.enabled || since >= cutoff {
return Ok((state.db.list_device_history(device_id, since, bucket_seconds, limit)?, "sqlite".into(), None)); return Ok((
state
.db
.list_device_history(device_id, since, bucket_seconds, limit)?,
"sqlite".into(),
None,
));
} }
let mut warning = None; let mut warning = None;
let mut values = match influxdb::query_devices(&state.http, &influx, device_id, since, cutoff, bucket_seconds, limit).await { let mut values = match influxdb::query_devices(
&state.http,
&influx,
device_id,
since,
cutoff,
bucket_seconds,
limit,
)
.await
{
Ok(rows) => rows, Ok(rows) => rows,
Err(err) => { Err(err) => {
warning = Some(err.to_string()); warning = Some(err.to_string());
state.log("warn", "influx.query_error", "InfluxDB device history query failed", json!({"error": err.to_string()})); state.log(
state.db.list_device_history(device_id, since, bucket_seconds, limit)? "warn",
"influx.query_error",
"InfluxDB device history query failed",
json!({"error": err.to_string()}),
);
state
.db
.list_device_history(device_id, since, bucket_seconds, limit)?
} }
}; };
if warning.is_none() { if warning.is_none() {
values.extend(state.db.list_device_history(device_id, cutoff, bucket_seconds, limit)?); values.extend(
state
.db
.list_device_history(device_id, cutoff, bucket_seconds, limit)?,
);
} }
values.sort_by_key(|row| row.timestamp); values.sort_by_key(|row| row.timestamp);
trim_history(&mut values, limit); trim_history(&mut values, limit);
let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" }; let source = if warning.is_some() {
"sqlite_fallback"
} else {
"influx+sqlite"
};
Ok((values, source.into(), warning)) Ok((values, source.into(), warning))
} }
@@ -167,23 +267,52 @@ async fn combined_zone_history(
let influx = state.settings.read().await.influxdb.clone(); let influx = state.settings.read().await.influxdb.clone();
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64); let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
if !influx.enabled || since >= cutoff { if !influx.enabled || since >= cutoff {
return Ok((zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)?, "sqlite".into(), None)); return Ok((
zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)?,
"sqlite".into(),
None,
));
} }
let mut warning = None; let mut warning = None;
let mut values = match influxdb::query_zones(&state.http, &influx, zone_id, since, cutoff, bucket_seconds, limit).await { let mut values = match influxdb::query_zones(
&state.http,
&influx,
zone_id,
since,
cutoff,
bucket_seconds,
limit,
)
.await
{
Ok(rows) => rows, Ok(rows) => rows,
Err(err) => { Err(err) => {
warning = Some(err.to_string()); warning = Some(err.to_string());
state.log("warn", "influx.query_error", "InfluxDB zone history query failed", json!({"error": err.to_string()})); state.log(
"warn",
"influx.query_error",
"InfluxDB zone history query failed",
json!({"error": err.to_string()}),
);
zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)? zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)?
} }
}; };
if warning.is_none() { if warning.is_none() {
values.extend(zone_history_with_fallback(state, zone_id, cutoff, bucket_seconds, limit)?); values.extend(zone_history_with_fallback(
state,
zone_id,
cutoff,
bucket_seconds,
limit,
)?);
} }
values.sort_by_key(|row| row.timestamp); values.sort_by_key(|row| row.timestamp);
trim_history(&mut values, limit); trim_history(&mut values, limit);
let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" }; let source = if warning.is_some() {
"sqlite_fallback"
} else {
"influx+sqlite"
};
Ok((values, source.into(), warning)) Ok((values, source.into(), warning))
} }
@@ -198,18 +327,38 @@ async fn combined_sensor_history(
let influx = state.settings.read().await.influxdb.clone(); let influx = state.settings.read().await.influxdb.clone();
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64); let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
let local = |start| -> Result<Vec<HaReading>, AppError> { let local = |start| -> Result<Vec<HaReading>, AppError> {
if entity_id.is_some() { Ok(state.db.list_ha_history(entity_id, start, bucket_seconds, limit)?) } if entity_id.is_some() {
else { sensor_history_with_fallback(state, start, bucket_seconds, limit, outdoor_entity) } Ok(state
.db
.list_ha_history(entity_id, start, bucket_seconds, limit)?)
} else {
sensor_history_with_fallback(state, start, bucket_seconds, limit, outdoor_entity)
}
}; };
if !influx.enabled || since >= cutoff { if !influx.enabled || since >= cutoff {
return Ok((local(since)?, "sqlite".into(), None)); return Ok((local(since)?, "sqlite".into(), None));
} }
let mut warning = None; let mut warning = None;
let mut values = match influxdb::query_ha(&state.http, &influx, entity_id, since, cutoff, bucket_seconds, limit).await { let mut values = match influxdb::query_ha(
&state.http,
&influx,
entity_id,
since,
cutoff,
bucket_seconds,
limit,
)
.await
{
Ok(rows) => rows, Ok(rows) => rows,
Err(err) => { Err(err) => {
warning = Some(err.to_string()); warning = Some(err.to_string());
state.log("warn", "influx.query_error", "InfluxDB HA history query failed", json!({"error": err.to_string()})); state.log(
"warn",
"influx.query_error",
"InfluxDB HA history query failed",
json!({"error": err.to_string()}),
);
local(since)? local(since)?
} }
}; };
@@ -218,7 +367,11 @@ async fn combined_sensor_history(
} }
values.sort_by_key(|row| row.timestamp); values.sort_by_key(|row| row.timestamp);
trim_history(&mut values, limit); trim_history(&mut values, limit);
let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" }; let source = if warning.is_some() {
"sqlite_fallback"
} else {
"influx+sqlite"
};
Ok((values, source.into(), warning)) Ok((values, source.into(), warning))
} }
@@ -229,19 +382,32 @@ fn trim_history<T>(values: &mut Vec<T>, limit: u32) {
} }
} }
async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery>) -> Result<Json<Value>, AppError> { async fn history(
State(state): State<AppState>,
Query(query): Query<HistoryQuery>,
) -> Result<Json<Value>, AppError> {
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650); let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650);
let since = Utc::now() - ChronoDuration::hours(hours); let since = Utc::now() - ChronoDuration::hours(hours);
let bucket_seconds = history_bucket_seconds(hours); let bucket_seconds = history_bucket_seconds(hours);
let limit = query.limit.unwrap_or(12_000).clamp(1, 20_000); let limit = query.limit.unwrap_or(12_000).clamp(1, 20_000);
let scope = query.scope.as_deref().unwrap_or("zones"); let scope = query.scope.as_deref().unwrap_or("zones");
let outdoor_entity = state.settings.read().await.home_assistant.outdoor_entity_id.clone(); let outdoor_entity = state
.settings
.read()
.await
.home_assistant
.outdoor_entity_id
.clone();
let (device_count, zone_count, ha_count) = state.db.history_counts()?; let (device_count, zone_count, ha_count) = state.db.history_counts()?;
match scope { match scope {
"devices" => { "devices" => {
let device_id = query.device_id.as_deref().filter(|value| !value.is_empty() && *value != "all"); let device_id = query
let (readings, storage, warning) = combined_device_history(&state, device_id, since, bucket_seconds, limit).await?; .device_id
.as_deref()
.filter(|value| !value.is_empty() && *value != "all");
let (readings, storage, warning) =
combined_device_history(&state, device_id, since, bucket_seconds, limit).await?;
Ok(Json(json!({ Ok(Json(json!({
"scope": "devices", "readings": readings, "bucket_seconds": bucket_seconds, "scope": "devices", "readings": readings, "bucket_seconds": bucket_seconds,
"storage": storage, "storage_warning": warning, "storage": storage, "storage_warning": warning,
@@ -249,8 +415,19 @@ async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery
}))) })))
} }
"sensors" => { "sensors" => {
let entity_id = query.entity_id.as_deref().filter(|value| !value.is_empty() && *value != "all"); let entity_id = query
let (readings, storage, warning) = combined_sensor_history(&state, entity_id, since, bucket_seconds, limit, &outdoor_entity).await?; .entity_id
.as_deref()
.filter(|value| !value.is_empty() && *value != "all");
let (readings, storage, warning) = combined_sensor_history(
&state,
entity_id,
since,
bucket_seconds,
limit,
&outdoor_entity,
)
.await?;
Ok(Json(json!({ Ok(Json(json!({
"scope": "sensors", "readings": readings, "bucket_seconds": bucket_seconds, "scope": "sensors", "readings": readings, "bucket_seconds": bucket_seconds,
"storage": storage, "storage_warning": warning, "storage": storage, "storage_warning": warning,
@@ -258,9 +435,19 @@ async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery
}))) })))
} }
"overview" => { "overview" => {
let (zones, zone_storage, zone_warning) = combined_zone_history(&state, None, since, bucket_seconds, limit).await?; let (zones, zone_storage, zone_warning) =
let (devices, device_storage, device_warning) = combined_device_history(&state, None, since, bucket_seconds, limit).await?; combined_zone_history(&state, None, since, bucket_seconds, limit).await?;
let (sensors, sensor_storage, sensor_warning) = combined_sensor_history(&state, None, since, bucket_seconds, limit, &outdoor_entity).await?; let (devices, device_storage, device_warning) =
combined_device_history(&state, None, since, bucket_seconds, limit).await?;
let (sensors, sensor_storage, sensor_warning) = combined_sensor_history(
&state,
None,
since,
bucket_seconds,
limit,
&outdoor_entity,
)
.await?;
let storage_warning = [zone_warning, device_warning, sensor_warning] let storage_warning = [zone_warning, device_warning, sensor_warning]
.into_iter() .into_iter()
.flatten() .flatten()
@@ -274,24 +461,31 @@ async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery
}))) })))
} }
"zones" | "zone" => { "zones" | "zone" => {
let zone_id = query.zone_id.as_deref().filter(|value| !value.is_empty() && *value != "all"); let zone_id = query
.zone_id
.as_deref()
.filter(|value| !value.is_empty() && *value != "all");
if let Some(zone_id) = zone_id { if let Some(zone_id) = zone_id {
if state.db.get_zone(zone_id)?.is_none() { if state.db.get_zone(zone_id)?.is_none() {
return Err(AppError::NotFound(format!("zone {zone_id}"))); return Err(AppError::NotFound(format!("zone {zone_id}")));
} }
} }
let (readings, storage, warning) = combined_zone_history(&state, zone_id, since, bucket_seconds, limit).await?; let (readings, storage, warning) =
combined_zone_history(&state, zone_id, since, bucket_seconds, limit).await?;
Ok(Json(json!({ Ok(Json(json!({
"scope": "zones", "readings": readings, "bucket_seconds": bucket_seconds, "scope": "zones", "readings": readings, "bucket_seconds": bucket_seconds,
"storage": storage, "storage_warning": warning, "storage": storage, "storage_warning": warning,
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
}))) })))
} }
_ => Err(AppError::BadRequest("history scope must be overview, zones, devices or sensors".into())), _ => Err(AppError::BadRequest(
"history scope must be overview, zones, devices or sensors".into(),
)),
} }
} }
async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppError> { async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
Ok(Json(serde_json::to_value(engine::build_control_plan(&state).await?)?)) Ok(Json(serde_json::to_value(
engine::build_control_plan(&state).await?,
)?))
} }
+179 -62
View File
@@ -1,21 +1,36 @@
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct HouseControlPatch { mode: String } struct HouseControlPatch {
mode: String,
}
async fn rearm_house_automation_compressor_queues(state: &AppState) -> Result<(), AppError> { async fn rearm_house_automation_compressor_queues(state: &AppState) -> Result<(), AppError> {
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); let mut zone_ids: Vec<String> = state
.db
.list_zones()?
.into_iter()
.map(|zone| zone.id)
.collect();
zone_ids.sort(); zone_ids.sort();
zone_ids.dedup(); zone_ids.dedup();
for zone_id in zone_ids { for zone_id in zone_ids {
let _zone_guard = state.lock_zone_operation(&zone_id).await; let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; let Some(mut zone) = state.db.get_zone(&zone_id)? else {
continue;
};
let scoped_manual = zone.device_manual_override let scoped_manual = zone.device_manual_override
|| zone.local_thermostat_power.is_some() || zone.local_thermostat_power.is_some()
|| zone.control_source.starts_with("group:") || zone.control_source.starts_with("group:")
|| engine::temporary_quick_thermostat_is_active(&zone, Utc::now()); || engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
if scoped_manual { continue; } if scoped_manual {
if zone.compressor_pending_action.is_none() && zone.compressor_cancelled_action.is_none() continue;
&& zone.lockout_until.is_none() && zone.lockout_reason.is_none() { continue; } }
if zone.compressor_pending_action.is_none()
&& zone.compressor_cancelled_action.is_none()
&& zone.lockout_until.is_none()
&& zone.lockout_reason.is_none()
{
continue;
}
engine::rearm_compressor_queue(&mut zone); engine::rearm_compressor_queue(&mut zone);
zone.revision = zone.revision.saturating_add(1); zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now(); zone.updated_at = Utc::now();
@@ -25,15 +40,24 @@ async fn rearm_house_automation_compressor_queues(state: &AppState) -> Result<()
Ok(()) Ok(())
} }
async fn set_all_thermostat_power_state(state: &AppState, power: bool) -> Result<usize, AppError> { async fn set_all_thermostat_power_state(state: &AppState, power: bool) -> Result<usize, AppError> {
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); let mut zone_ids: Vec<String> = state
.db
.list_zones()?
.into_iter()
.map(|zone| zone.id)
.collect();
zone_ids.sort(); zone_ids.sort();
zone_ids.dedup(); zone_ids.dedup();
let mut changed = 0usize; let mut changed = 0usize;
for zone_id in zone_ids { for zone_id in zone_ids {
let _zone_guard = state.lock_zone_operation(&zone_id).await; let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; let Some(mut zone) = state.db.get_zone(&zone_id)? else {
continue;
};
engine::rearm_compressor_queue(&mut zone); engine::rearm_compressor_queue(&mut zone);
if engine::set_house_bulk_thermostat_power(&mut zone, power) { changed += 1; } if engine::set_house_bulk_thermostat_power(&mut zone, power) {
changed += 1;
}
engine::refresh_control_ownership(&mut zone); engine::refresh_control_ownership(&mut zone);
zone.revision = zone.revision.saturating_add(1); zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now(); zone.updated_at = Utc::now();
@@ -43,10 +67,16 @@ async fn set_all_thermostat_power_state(state: &AppState, power: bool) -> Result
Ok(changed) Ok(changed)
} }
async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result<Vec<Value>, AppError> { async fn command_all_enabled_devices_power(
state: &AppState,
power: bool,
source: &str,
) -> Result<Vec<Value>, AppError> {
let mut failed = Vec::new(); let mut failed = Vec::new();
for device in state.db.list_devices()? { for device in state.db.list_devices()? {
if !device.enabled { continue; } if !device.enabled {
continue;
}
// The per-zone thermostat power state is persisted before these physical commands. // The per-zone thermostat power state is persisted before these physical commands.
// OFF is immediate; ON still respects compressor protection. // OFF is immediate; ON still respects compressor protection.
let result = if power { let result = if power {
@@ -55,12 +85,17 @@ async fn command_all_enabled_devices_power(state: &AppState, power: bool, source
engine::force_house_power_off_device(state, &device.id, source).await engine::force_house_power_off_device(state, &device.id, source).await
}; };
if let Err(err) = result { if let Err(err) = result {
state.log("error", "house.power_all_error", &err.to_string(), json!({ state.log(
"device_id": device.id, "error",
"device_name": device.name, "house.power_all_error",
"power": power, &err.to_string(),
"source": source, json!({
})); "device_id": device.id,
"device_name": device.name,
"power": power,
"source": source,
}),
);
failed.push(json!({ failed.push(json!({
"device_id": device.id, "device_id": device.id,
"device_name": device.name, "device_name": device.name,
@@ -71,14 +106,19 @@ async fn command_all_enabled_devices_power(state: &AppState, power: bool, source
Ok(failed) Ok(failed)
} }
async fn update_house_control(State(state): State<AppState>, Json(input): Json<HouseControlPatch>) -> Result<Json<Value>, AppError> { async fn update_house_control(
State(state): State<AppState>,
Json(input): Json<HouseControlPatch>,
) -> Result<Json<Value>, AppError> {
let _house_guard = state.lock_house_operation().await; let _house_guard = state.lock_house_operation().await;
// Serialize the ownership/configuration transition against an already-running thermostat // Serialize the ownership/configuration transition against an already-running thermostat
// cycle. Otherwise a cycle that captured the previous house mode could send one stale // cycle. Otherwise a cycle that captured the previous house mode could send one stale
// climate command after this interactive change. // climate command after this interactive change.
let cycle_guard = state.lock_zone_control_cycle().await; let cycle_guard = state.lock_zone_control_cycle().await;
if !matches!(input.mode.as_str(), "cool" | "heat" | "off") { if !matches!(input.mode.as_str(), "cool" | "heat" | "off") {
return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); return Err(AppError::BadRequest(
"house mode must be cool, heat or off".into(),
));
} }
let mode = input.mode; let mode = input.mode;
let activate_all = mode != "off"; let activate_all = mode != "off";
@@ -101,14 +141,24 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
} else { } else {
state.wake_zone_control(); state.wake_zone_control();
} }
state.log("info", "house.mode", &format!("House mode set to {}", mode), json!({"mode": mode})); state.log(
"info",
"house.mode",
&format!("House mode set to {}", mode),
json!({"mode": mode}),
);
Ok(Json(payload)) Ok(Json(payload))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct HousePowerPatch { power: bool } struct HousePowerPatch {
power: bool,
}
async fn update_house_power(State(state): State<AppState>, Json(input): Json<HousePowerPatch>) -> Result<Json<Value>, AppError> { async fn update_house_power(
State(state): State<AppState>,
Json(input): Json<HousePowerPatch>,
) -> Result<Json<Value>, AppError> {
let _house_guard = state.lock_house_operation().await; let _house_guard = state.lock_house_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await; let _cycle_guard = state.lock_zone_control_cycle().await;
@@ -125,17 +175,22 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
let devices = state.db.list_devices()?; let devices = state.db.list_devices()?;
let groups = state.db.list_groups()?; let groups = state.db.list_groups()?;
state.log("info", "house.power_all", if input.power { state.log(
"Whole-house ON sent; local OFF state released and house thermostat intent armed" "info",
} else { "house.power_all",
"Whole-house OFF sent; all thermostats left locally OFF until explicitly re-enabled" if input.power {
}, json!({ "Whole-house ON sent; local OFF state released and house thermostat intent armed"
"power": input.power, } else {
"failed": failed.len(), "Whole-house OFF sent; all thermostats left locally OFF until explicitly re-enabled"
"changed_zones": changed_zones, },
"one_shot": true, json!({
"persistent_global_gate": false, "power": input.power,
})); "failed": failed.len(),
"changed_zones": changed_zones,
"one_shot": true,
"persistent_global_gate": false,
}),
);
Ok(Json(json!({ Ok(Json(json!({
"power": input.power, "power": input.power,
"one_shot": true, "one_shot": true,
@@ -146,13 +201,20 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct HousePresetPatch { preset: String } struct HousePresetPatch {
preset: String,
}
async fn update_house_preset(State(state): State<AppState>, Json(input): Json<HousePresetPatch>) -> Result<Json<Value>, AppError> { async fn update_house_preset(
State(state): State<AppState>,
Json(input): Json<HousePresetPatch>,
) -> Result<Json<Value>, AppError> {
let _house_guard = state.lock_house_operation().await; let _house_guard = state.lock_house_operation().await;
let cycle_guard = state.lock_zone_control_cycle().await; let cycle_guard = state.lock_zone_control_cycle().await;
if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") { if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") {
return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into())); return Err(AppError::BadRequest(
"house preset must be auto, comfort, sleep or away".into(),
));
} }
// A house profile applies to free house-controlled zones. Explicit local/group/direct // A house profile applies to free house-controlled zones. Explicit local/group/direct
@@ -160,7 +222,12 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
rearm_house_automation_compressor_queues(&state).await?; rearm_house_automation_compressor_queues(&state).await?;
let schedules = state.db.list_schedules()?; let schedules = state.db.list_schedules()?;
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); let mut zone_ids: Vec<String> = state
.db
.list_zones()?
.into_iter()
.map(|zone| zone.id)
.collect();
zone_ids.sort(); zone_ids.sort();
zone_ids.dedup(); zone_ids.dedup();
let mut _zone_guards = Vec::with_capacity(zone_ids.len()); let mut _zone_guards = Vec::with_capacity(zone_ids.len());
@@ -169,9 +236,13 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
} }
let mut zones = Vec::with_capacity(zone_ids.len()); let mut zones = Vec::with_capacity(zone_ids.len());
for zone_id in &zone_ids { for zone_id in &zone_ids {
let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; }; let Some(zone_snapshot) = state.db.get_zone(zone_id)? else {
continue;
};
let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await; let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await;
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; }; let Some(mut zone) = state.db.get_zone(zone_id)? else {
continue;
};
let scoped_manual = zone.device_manual_override let scoped_manual = zone.device_manual_override
|| zone.local_thermostat_power.is_some() || zone.local_thermostat_power.is_some()
|| zone.control_source.starts_with("group:") || zone.control_source.starts_with("group:")
@@ -188,7 +259,8 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
} else { } else {
zone.manual_preset = Some(input.preset.clone()); zone.manual_preset = Some(input.preset.clone());
zone.manual_setpoint = None; zone.manual_setpoint = None;
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()); zone.manual_override_until =
engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
} }
zone.updated_at = Utc::now(); zone.updated_at = Utc::now();
state.db.save_zone(&zone)?; state.db.save_zone(&zone)?;
@@ -205,14 +277,24 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
// same arbitration cycle and no member is left waiting behind the periodic interval. // same arbitration cycle and no member is left waiting behind the periodic interval.
let mut failed: Vec<Value> = Vec::new(); let mut failed: Vec<Value> = Vec::new();
if let Err(err) = engine::run_zone_control_now(&state).await { if let Err(err) = engine::run_zone_control_now(&state).await {
state.log("error", "house.immediate_control_error", &err.to_string(), json!({"source":"house_preset"})); state.log(
"error",
"house.immediate_control_error",
&err.to_string(),
json!({"source":"house_preset"}),
);
failed.push(json!({"scope":"thermostat_cycle","error":err.to_string()})); failed.push(json!({"scope":"thermostat_cycle","error":err.to_string()}));
} }
let devices = state.db.list_devices()?; let devices = state.db.list_devices()?;
state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({ state.log(
"preset": input.preset, "info",
"failed": failed.len(), "house.preset",
})); &format!("House preset set to {}", input.preset),
json!({
"preset": input.preset,
"failed": failed.len(),
}),
);
Ok(Json(json!({ Ok(Json(json!({
"preset": input.preset, "preset": input.preset,
"zones": zones, "zones": zones,
@@ -222,22 +304,41 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ScheduleTemplateRequest { template: String } struct ScheduleTemplateRequest {
template: String,
}
async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleTemplateRequest>) -> Result<Json<Value>, AppError> { async fn apply_schedule_template(
State(state): State<AppState>,
Path(id): Path<String>,
Json(input): Json<ScheduleTemplateRequest>,
) -> Result<Json<Value>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
let _schedule_guard = state.lock_schedule_operation().await; let _schedule_guard = state.lock_schedule_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await; let _cycle_guard = state.lock_zone_control_cycle().await;
let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let zone = state
.db
.get_zone(&id)?
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let mut items: Vec<Schedule> = Vec::new(); let mut items: Vec<Schedule> = Vec::new();
let mut add = |name: &str, days: Vec<u32>, start: &str, end: &str, preset: &str| { let mut add = |name: &str, days: Vec<u32>, start: &str, end: &str, preset: &str| {
items.push(Schedule { items.push(Schedule {
id: Uuid::new_v4().to_string(), zone_id: id.clone(), name: name.into(), enabled: true, id: Uuid::new_v4().to_string(),
weekdays: days, start_time: start.into(), end_time: end.into(), preset: preset.into(), zone_id: id.clone(),
setpoint: zone.setpoint, created_at: Utc::now(), updated_at: Utc::now(), flow_id: None, flow_node_id: None, name: name.into(),
enabled: true,
weekdays: days,
start_time: start.into(),
end_time: end.into(),
preset: preset.into(),
setpoint: zone.setpoint,
created_at: Utc::now(),
updated_at: Utc::now(),
flow_id: None,
flow_node_id: None,
}); });
}; };
let all = vec![1,2,3,4,5,6,7]; let all = vec![1, 2, 3, 4, 5, 6, 7];
match input.template.as_str() { match input.template.as_str() {
"family" => { "family" => {
add("Comfort", all.clone(), "06:30", "22:30", "comfort"); add("Comfort", all.clone(), "06:30", "22:30", "comfort");
@@ -252,8 +353,8 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
add("Sleep", all, "22:00", "06:30", "sleep"); add("Sleep", all, "22:00", "06:30", "sleep");
} }
"workday" => { "workday" => {
let weekdays = vec![1,2,3,4,5]; let weekdays = vec![1, 2, 3, 4, 5];
let weekend = vec![6,7]; let weekend = vec![6, 7];
add("Morning", weekdays.clone(), "06:30", "08:00", "comfort"); add("Morning", weekdays.clone(), "06:30", "08:00", "comfort");
add("Away", weekdays.clone(), "08:00", "16:00", "away"); add("Away", weekdays.clone(), "08:00", "16:00", "away");
add("Evening", weekdays.clone(), "16:00", "22:30", "comfort"); add("Evening", weekdays.clone(), "16:00", "22:30", "comfort");
@@ -270,28 +371,45 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
validate_schedule_set(&items)?; validate_schedule_set(&items)?;
state.db.replace_schedules_for_zone(&id, &items)?; state.db.replace_schedules_for_zone(&id, &items)?;
refresh_zone_override_boundary(&state, &id).await?; refresh_zone_override_boundary(&state, &id).await?;
state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()})); state.broadcast(
"schedule.template_applied",
json!({"zone_id": id, "template": input.template, "count": items.len()}),
);
state.wake_zone_control(); state.wake_zone_control();
Ok(Json(json!({"zone": zone, "schedules": items}))) Ok(Json(json!({"zone": zone, "schedules": items})))
} }
async fn update_home_assistant_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, AppError> { async fn update_home_assistant_zone_control(
Ok(Json(apply_zone_control_patch(&state, &id, patch, "home_assistant.zone_thermostat").await?)) 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, "home_assistant.zone_thermostat").await?,
))
} }
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> { async fn delete_zone(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, AppError> {
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
let _automation_guard = state.lock_automation_operation().await; let _automation_guard = state.lock_automation_operation().await;
let _house_guard = state.lock_house_operation().await; let _house_guard = state.lock_house_operation().await;
let _schedule_guard = state.lock_schedule_operation().await; let _schedule_guard = state.lock_schedule_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await; let _cycle_guard = state.lock_zone_control_cycle().await;
let zone_guard = state.lock_zone_operation(&id).await; let zone_guard = state.lock_zone_operation(&id).await;
let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let zone = state
.db
.get_zone(&id)?
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let mut removed = std::collections::HashSet::new(); let mut removed = std::collections::HashSet::new();
removed.insert(id.clone()); removed.insert(id.clone());
ensure_zone_removal_safe(&state, &removed)?; ensure_zone_removal_safe(&state, &removed)?;
ensure_device_stopped_for_detach(&state, &zone.device_id, "zone.deleted").await?; ensure_device_stopped_for_detach(&state, &zone.device_id, "zone.deleted").await?;
if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); } if !state.db.delete_zone(&id)? {
return Err(AppError::NotFound(format!("zone {id}")));
}
// Group control locks group first and zone second. Release the zone lock before taking // Group control locks group first and zone second. Release the zone lock before taking
// group locks so deletion cannot form the inverse zone -> group lock order. // group locks so deletion cannot form the inverse zone -> group lock order.
drop(zone_guard); drop(zone_guard);
@@ -299,4 +417,3 @@ async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> R
state.broadcast("zone.deleted", json!({"id": id})); state.broadcast("zone.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
+62 -22
View File
@@ -1,25 +1,53 @@
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct HaTestRequest { entity_id: Option<String> } struct HaTestRequest {
async fn test_home_assistant(State(state): State<AppState>, Json(input): Json<HaTestRequest>) -> Result<Json<Value>, AppError> { entity_id: Option<String>,
}
async fn test_home_assistant(
State(state): State<AppState>,
Json(input): Json<HaTestRequest>,
) -> Result<Json<Value>, AppError> {
let settings = state.settings.read().await.clone(); let settings = state.settings.read().await.clone();
let resolved_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, input.entity_id.as_deref()); let resolved_entity_id =
let temperature = home_assistant::read_temperature(&state.http, &settings.home_assistant, resolved_entity_id.as_deref(), Some(settings.home_assistant.sensor_stale_after_seconds)) home_assistant::resolve_entity_id(&settings.home_assistant, input.entity_id.as_deref());
.await.map_err(|e| AppError::Device(e.to_string()))?; let temperature = home_assistant::read_temperature(
Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id}))) &state.http,
&settings.home_assistant,
resolved_entity_id.as_deref(),
Some(settings.home_assistant.sensor_stale_after_seconds),
)
.await
.map_err(|e| AppError::Device(e.to_string()))?;
Ok(Json(
json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id}),
))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct HaEntityRequest { entity_id: String } struct HaEntityRequest {
entity_id: String,
}
async fn inspect_home_assistant_entity(State(state): State<AppState>, Json(input): Json<HaEntityRequest>) -> Result<Json<Value>, AppError> { async fn inspect_home_assistant_entity(
State(state): State<AppState>,
Json(input): Json<HaEntityRequest>,
) -> Result<Json<Value>, AppError> {
let settings = state.settings.read().await.clone(); let settings = state.settings.read().await.clone();
let entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(input.entity_id.as_str())) let entity_id =
.filter(|value| !value.trim().is_empty()) home_assistant::resolve_entity_id(&settings.home_assistant, Some(input.entity_id.as_str()))
.ok_or_else(|| AppError::BadRequest("Home Assistant entity_id is required".into()))?; .filter(|value| !value.trim().is_empty())
let payload = home_assistant::read_entity(&state.http, &settings.home_assistant, Some(entity_id.as_str())) .ok_or_else(|| AppError::BadRequest("Home Assistant entity_id is required".into()))?;
.await.map_err(|e| AppError::Device(e.to_string()))?; let payload = home_assistant::read_entity(
let raw_state = payload.get("state").and_then(Value::as_str).unwrap_or_default().to_string(); &state.http,
&settings.home_assistant,
Some(entity_id.as_str()),
)
.await
.map_err(|e| AppError::Device(e.to_string()))?;
let raw_state = payload
.get("state")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let available = !matches!(raw_state.as_str(), "unknown" | "unavailable" | ""); let available = !matches!(raw_state.as_str(), "unknown" | "unavailable" | "");
Ok(Json(json!({ Ok(Json(json!({
"ok": true, "ok": true,
@@ -32,13 +60,25 @@ async fn inspect_home_assistant_entity(State(state): State<AppState>, Json(input
}))) })))
} }
async fn test_notifications(State(state): State<AppState>, Json(mut input): Json<NotificationSettings>) -> Result<Json<Value>, AppError> { async fn test_notifications(
State(state): State<AppState>,
Json(mut input): Json<NotificationSettings>,
) -> Result<Json<Value>, AppError> {
let old = state.settings.read().await.notifications.clone(); let old = state.settings.read().await.notifications.clone();
if input.pushover_app_token.trim().is_empty() { input.pushover_app_token = old.pushover_app_token; } if input.pushover_app_token.trim().is_empty() {
if input.pushover_user_key.trim().is_empty() { input.pushover_user_key = old.pushover_user_key; } input.pushover_app_token = old.pushover_app_token;
if input.slack_webhook_url.trim().is_empty() { input.slack_webhook_url = old.slack_webhook_url; } }
if input.discord_webhook_url.trim().is_empty() { input.discord_webhook_url = old.discord_webhook_url; } if input.pushover_user_key.trim().is_empty() {
notifications::test(&state, input).await.map_err(AppError::Device)?; input.pushover_user_key = old.pushover_user_key;
}
if input.slack_webhook_url.trim().is_empty() {
input.slack_webhook_url = old.slack_webhook_url;
}
if input.discord_webhook_url.trim().is_empty() {
input.discord_webhook_url = old.discord_webhook_url;
}
notifications::test(&state, input)
.await
.map_err(AppError::Device)?;
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }
+22 -5
View File
@@ -5,16 +5,33 @@ async fn security_headers(request: Request, next: Next) -> Response {
.headers() .headers()
.get(header::CONTENT_TYPE) .get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.is_some_and(|value| value.split(';').next().is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/html"))); .is_some_and(|value| {
value
.split(';')
.next()
.is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/html"))
});
let headers = response.headers_mut(); let headers = response.headers_mut();
headers.insert(header::HeaderName::from_static("x-content-type-options"), HeaderValue::from_static("nosniff")); headers.insert(
headers.insert(header::HeaderName::from_static("referrer-policy"), HeaderValue::from_static("same-origin")); header::HeaderName::from_static("x-content-type-options"),
HeaderValue::from_static("nosniff"),
);
headers.insert(
header::HeaderName::from_static("referrer-policy"),
HeaderValue::from_static("same-origin"),
);
if is_html { if is_html {
headers.insert(header::HeaderName::from_static("x-frame-options"), HeaderValue::from_static("SAMEORIGIN")); headers.insert(
header::HeaderName::from_static("x-frame-options"),
HeaderValue::from_static("SAMEORIGIN"),
);
headers.insert(header::HeaderName::from_static("content-security-policy"), HeaderValue::from_static("default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'")); headers.insert(header::HeaderName::from_static("content-security-policy"), HeaderValue::from_static("default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'"));
headers.insert(header::HeaderName::from_static("permissions-policy"), HeaderValue::from_static("camera=(), microphone=(), geolocation=()")); headers.insert(
header::HeaderName::from_static("permissions-policy"),
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
);
} }
if is_api { if is_api {
-1
View File
@@ -61,4 +61,3 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
} }
}) })
} }
+130 -34
View File
@@ -11,39 +11,82 @@ struct ScheduleInput {
preset: String, preset: String,
setpoint: f64, setpoint: f64,
} }
fn schedule_preset() -> String { "custom".into() } fn schedule_preset() -> String {
"custom".into()
}
impl ScheduleInput { impl ScheduleInput {
fn validate(&self) -> Result<(), AppError> { fn validate(&self) -> Result<(), AppError> {
if self.name.trim().is_empty() { return Err(AppError::BadRequest("schedule name is required".into())); } if self.name.trim().is_empty() {
if self.weekdays.is_empty() || self.weekdays.iter().any(|v| !(1..=7).contains(v)) { return Err(AppError::BadRequest("weekdays must contain numbers 1..7".into())); } return Err(AppError::BadRequest("schedule name is required".into()));
chrono::NaiveTime::parse_from_str(&self.start_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid start time".into()))?; }
chrono::NaiveTime::parse_from_str(&self.end_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid end time".into()))?; if self.weekdays.is_empty() || self.weekdays.iter().any(|v| !(1..=7).contains(v)) {
if !matches!(self.preset.as_str(), "comfort" | "sleep" | "away" | "custom") { return Err(AppError::BadRequest("unsupported schedule preset".into())); } return Err(AppError::BadRequest(
if self.preset == "custom" && !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("schedule setpoint must be between 8 and 30 C".into())); } "weekdays must contain numbers 1..7".into(),
));
}
chrono::NaiveTime::parse_from_str(&self.start_time, "%H:%M")
.map_err(|_| AppError::BadRequest("invalid start time".into()))?;
chrono::NaiveTime::parse_from_str(&self.end_time, "%H:%M")
.map_err(|_| AppError::BadRequest("invalid end time".into()))?;
if !matches!(
self.preset.as_str(),
"comfort" | "sleep" | "away" | "custom"
) {
return Err(AppError::BadRequest("unsupported schedule preset".into()));
}
if self.preset == "custom" && !(8.0..=30.0).contains(&self.setpoint) {
return Err(AppError::BadRequest(
"schedule setpoint must be between 8 and 30 C".into(),
));
}
Ok(()) Ok(())
} }
fn into_schedule(self, id: String, created_at: chrono::DateTime<Utc>) -> Schedule { fn into_schedule(self, id: String, created_at: chrono::DateTime<Utc>) -> Schedule {
Schedule { id, zone_id: self.zone_id, name: self.name.trim().into(), enabled: self.enabled, Schedule {
weekdays: self.weekdays, start_time: self.start_time, end_time: self.end_time, id,
preset: self.preset, setpoint: self.setpoint, created_at, updated_at: Utc::now(), flow_id: None, flow_node_id: None } zone_id: self.zone_id,
name: self.name.trim().into(),
enabled: self.enabled,
weekdays: self.weekdays,
start_time: self.start_time,
end_time: self.end_time,
preset: self.preset,
setpoint: self.setpoint,
created_at,
updated_at: Utc::now(),
flow_id: None,
flow_node_id: None,
}
} }
} }
fn validate_schedule_set(items: &[Schedule]) -> Result<(), AppError> { fn validate_schedule_set(items: &[Schedule]) -> Result<(), AppError> {
for (index, item) in items.iter().enumerate() { for (index, item) in items.iter().enumerate() {
for other in items.iter().skip(index + 1) { for other in items.iter().skip(index + 1) {
if engine::schedules_overlap(item, other) { if engine::schedules_overlap(item, other) {
return Err(AppError::BadRequest(format!("schedule '{}' overlaps with '{}' for the same zone", item.name, other.name))); return Err(AppError::BadRequest(format!(
"schedule '{}' overlaps with '{}' for the same zone",
item.name, other.name
)));
} }
} }
} }
Ok(()) Ok(())
} }
fn validate_schedule_conflicts(state: &AppState, item: &Schedule, exclude_id: Option<&str>) -> Result<(), AppError> { fn validate_schedule_conflicts(
state: &AppState,
item: &Schedule,
exclude_id: Option<&str>,
) -> Result<(), AppError> {
for existing in state.db.list_schedules()? { for existing in state.db.list_schedules()? {
if exclude_id == Some(existing.id.as_str()) { continue; } if exclude_id == Some(existing.id.as_str()) {
continue;
}
if engine::schedules_overlap(item, &existing) { if engine::schedules_overlap(item, &existing) {
return Err(AppError::BadRequest(format!("schedule overlaps with '{}'", existing.name))); return Err(AppError::BadRequest(format!(
"schedule overlaps with '{}'",
existing.name
)));
} }
} }
Ok(()) Ok(())
@@ -57,11 +100,21 @@ async fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Resu
} else { } else {
None None
}; };
let Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); }; let Some(mut zone) = state.db.get_zone(zone_id)? else {
let has_temporary_schedule_boundary = zone.temporary_quick_thermostat.as_ref() return Ok(());
};
let has_temporary_schedule_boundary = zone
.temporary_quick_thermostat
.as_ref()
.map(|session| session.finish_kind == "schedule_boundary") .map(|session| session.finish_kind == "schedule_boundary")
.unwrap_or(false); .unwrap_or(false);
if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() && !zone.device_manual_override && !has_temporary_schedule_boundary { return Ok(()); } if zone.manual_preset.is_none()
&& zone.manual_setpoint.is_none()
&& !zone.device_manual_override
&& !has_temporary_schedule_boundary
{
return Ok(());
}
let schedules = state.db.list_schedules()?; let schedules = state.db.list_schedules()?;
let boundary = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()); let boundary = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
// An active Temporary Quick Thermostat explicitly owns its target until its own finish // An active Temporary Quick Thermostat explicitly owns its target until its own finish
@@ -79,11 +132,14 @@ async fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Resu
zone.control_resume_at = None; zone.control_resume_at = None;
} }
if has_temporary_schedule_boundary { if has_temporary_schedule_boundary {
let reference = zone.temporary_quick_thermostat.as_ref() let reference = zone
.temporary_quick_thermostat
.as_ref()
.filter(|session| session.activated_at.is_none()) .filter(|session| session.activated_at.is_none())
.map(|session| session.started_at.with_timezone(&chrono::Local)) .map(|session| session.started_at.with_timezone(&chrono::Local))
.unwrap_or_else(chrono::Local::now); .unwrap_or_else(chrono::Local::now);
let refreshed = engine::next_schedule_boundary_utc(&zone.id, &schedules, reference).or(Some(Utc::now())); let refreshed = engine::next_schedule_boundary_utc(&zone.id, &schedules, reference)
.or(Some(Utc::now()));
if let Some(session) = zone.temporary_quick_thermostat.as_mut() { if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
session.expires_at = refreshed; session.expires_at = refreshed;
} }
@@ -95,16 +151,30 @@ async fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Resu
Ok(()) Ok(())
} }
async fn list_schedules(State(state): State<AppState>) -> Result<Json<Vec<Schedule>>, AppError> { Ok(Json(state.db.list_schedules()?)) } async fn list_schedules(State(state): State<AppState>) -> Result<Json<Vec<Schedule>>, AppError> {
async fn get_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Schedule>, AppError> { Ok(Json(state.db.list_schedules()?))
state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}")))
} }
async fn create_schedule(State(state): State<AppState>, Json(input): Json<ScheduleInput>) -> Result<(StatusCode, Json<Schedule>), AppError> { async fn get_schedule(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Schedule>, AppError> {
state
.db
.get_schedule(&id)?
.map(Json)
.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))
}
async fn create_schedule(
State(state): State<AppState>,
Json(input): Json<ScheduleInput>,
) -> Result<(StatusCode, Json<Schedule>), AppError> {
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
let _schedule_guard = state.lock_schedule_operation().await; let _schedule_guard = state.lock_schedule_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await; let _cycle_guard = state.lock_zone_control_cycle().await;
input.validate()?; input.validate()?;
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); } if state.db.get_zone(&input.zone_id)?.is_none() {
return Err(AppError::BadRequest("schedule zone does not exist".into()));
}
let item = input.into_schedule(Uuid::new_v4().to_string(), Utc::now()); let item = input.into_schedule(Uuid::new_v4().to_string(), Utc::now());
validate_schedule_conflicts(&state, &item, None)?; validate_schedule_conflicts(&state, &item, None)?;
state.db.save_schedule(&item)?; state.db.save_schedule(&item)?;
@@ -113,34 +183,60 @@ async fn create_schedule(State(state): State<AppState>, Json(input): Json<Schedu
state.wake_zone_control(); state.wake_zone_control();
Ok((StatusCode::CREATED, Json(item))) Ok((StatusCode::CREATED, Json(item)))
} }
async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleInput>) -> Result<Json<Schedule>, AppError> { async fn update_schedule(
State(state): State<AppState>,
Path(id): Path<String>,
Json(input): Json<ScheduleInput>,
) -> Result<Json<Schedule>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
let _schedule_guard = state.lock_schedule_operation().await; let _schedule_guard = state.lock_schedule_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await; let _cycle_guard = state.lock_zone_control_cycle().await;
input.validate()?; input.validate()?;
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; let existing = state
if existing.flow_id.is_some() { return Err(AppError::BadRequest("this schedule is generated by Flow; edit it in the Flow editor".into())); } .db
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); } .get_schedule(&id)?
.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
if existing.flow_id.is_some() {
return Err(AppError::BadRequest(
"this schedule is generated by Flow; edit it in the Flow editor".into(),
));
}
if state.db.get_zone(&input.zone_id)?.is_none() {
return Err(AppError::BadRequest("schedule zone does not exist".into()));
}
let old_zone_id = existing.zone_id.clone(); let old_zone_id = existing.zone_id.clone();
let item = input.into_schedule(id.clone(), existing.created_at); let item = input.into_schedule(id.clone(), existing.created_at);
validate_schedule_conflicts(&state, &item, Some(&id))?; validate_schedule_conflicts(&state, &item, Some(&id))?;
state.db.save_schedule(&item)?; state.db.save_schedule(&item)?;
refresh_zone_override_boundary(&state, &old_zone_id).await?; refresh_zone_override_boundary(&state, &old_zone_id).await?;
if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id).await?; } if item.zone_id != old_zone_id {
refresh_zone_override_boundary(&state, &item.zone_id).await?;
}
state.broadcast("schedule.updated", serde_json::to_value(&item)?); state.broadcast("schedule.updated", serde_json::to_value(&item)?);
state.wake_zone_control(); state.wake_zone_control();
Ok(Json(item)) Ok(Json(item))
} }
async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> { async fn delete_schedule(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, AppError> {
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
let _schedule_guard = state.lock_schedule_operation().await; let _schedule_guard = state.lock_schedule_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await; let _cycle_guard = state.lock_zone_control_cycle().await;
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; let existing = state
if existing.flow_id.is_some() { return Err(AppError::BadRequest("this schedule is generated by Flow; delete it from the Flow editor".into())); } .db
if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); } .get_schedule(&id)?
.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
if existing.flow_id.is_some() {
return Err(AppError::BadRequest(
"this schedule is generated by Flow; delete it from the Flow editor".into(),
));
}
if !state.db.delete_schedule(&id)? {
return Err(AppError::NotFound(format!("schedule {id}")));
}
refresh_zone_override_boundary(&state, &existing.zone_id).await?; refresh_zone_override_boundary(&state, &existing.zone_id).await?;
state.broadcast("schedule.deleted", json!({"id": id})); state.broadcast("schedule.deleted", json!({"id": id}));
state.wake_zone_control(); state.wake_zone_control();
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
+208 -64
View File
@@ -1,5 +1,7 @@
fn application_settings(settings: &RuntimeSettings) -> ApplicationSettings { fn application_settings(settings: &RuntimeSettings) -> ApplicationSettings {
ApplicationSettings { simulator_enabled: settings.simulator_enabled } ApplicationSettings {
simulator_enabled: settings.simulator_enabled,
}
} }
fn gree_settings(settings: &RuntimeSettings) -> GreeSettings { fn gree_settings(settings: &RuntimeSettings) -> GreeSettings {
@@ -83,8 +85,16 @@ async fn update_application_settings(
state.db.save_runtime_settings(&settings)?; state.db.save_runtime_settings(&settings)?;
application_settings(&settings) application_settings(&settings)
}; };
state.log("info", "settings.application.updated", "Application settings updated", json!({"simulator_enabled": payload.simulator_enabled})); state.log(
state.broadcast("settings.application.updated", serde_json::to_value(&payload)?); "info",
"settings.application.updated",
"Application settings updated",
json!({"simulator_enabled": payload.simulator_enabled}),
);
state.broadcast(
"settings.application.updated",
serde_json::to_value(&payload)?,
);
Ok(Json(payload)) Ok(Json(payload))
} }
@@ -102,21 +112,33 @@ fn normalize_gree_settings(mut input: GreeSettings) -> Result<GreeSettings, AppE
input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000); input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000);
input.compressor_protection_seconds = input.compressor_protection_seconds.clamp(30, 1800); input.compressor_protection_seconds = input.compressor_protection_seconds.clamp(30, 1800);
if !(input.discovery_broadcast.eq_ignore_ascii_case("auto") if !(input.discovery_broadcast.eq_ignore_ascii_case("auto")
|| input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:")) || input
.discovery_broadcast
.to_ascii_lowercase()
.starts_with("auto:"))
{ {
input.discovery_broadcast.parse::<std::net::SocketAddr>() input
.discovery_broadcast
.parse::<std::net::SocketAddr>()
.map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?; .map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?;
} }
Ok(input) Ok(input)
} }
async fn clear_compressor_runtime_after_settings_change(state: &AppState) -> Result<(), AppError> { async fn clear_compressor_runtime_after_settings_change(state: &AppState) -> Result<(), AppError> {
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); let mut zone_ids: Vec<String> = state
.db
.list_zones()?
.into_iter()
.map(|zone| zone.id)
.collect();
zone_ids.sort(); zone_ids.sort();
zone_ids.dedup(); zone_ids.dedup();
for zone_id in zone_ids { for zone_id in zone_ids {
let _zone_guard = state.lock_zone_operation(&zone_id).await; let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; let Some(mut zone) = state.db.get_zone(&zone_id)? else {
continue;
};
if zone.compressor_pending_action.is_none() if zone.compressor_pending_action.is_none()
&& zone.compressor_cancelled_action.is_none() && zone.compressor_cancelled_action.is_none()
&& zone.lockout_until.is_none() && zone.lockout_until.is_none()
@@ -143,7 +165,8 @@ async fn update_gree_settings(
let _cycle_guard = state.lock_zone_control_cycle().await; let _cycle_guard = state.lock_zone_control_cycle().await;
let (payload, compressor_changed) = { let (payload, compressor_changed) = {
let mut settings = state.settings.write().await; let mut settings = state.settings.write().await;
let compressor_changed = settings.compressor_protection_enabled != input.compressor_protection_enabled let compressor_changed = settings.compressor_protection_enabled
!= input.compressor_protection_enabled
|| settings.compressor_protection_seconds != input.compressor_protection_seconds; || settings.compressor_protection_seconds != input.compressor_protection_seconds;
settings.controller_id = input.controller_id; settings.controller_id = input.controller_id;
settings.poll_interval_seconds = input.poll_interval_seconds; settings.poll_interval_seconds = input.poll_interval_seconds;
@@ -159,10 +182,15 @@ async fn update_gree_settings(
if compressor_changed { if compressor_changed {
clear_compressor_runtime_after_settings_change(&state).await?; clear_compressor_runtime_after_settings_change(&state).await?;
} }
state.log("info", "settings.gree.updated", "GREE settings updated", json!({ state.log(
"compressor_protection_enabled": payload.compressor_protection_enabled, "info",
"compressor_protection_seconds": payload.compressor_protection_seconds "settings.gree.updated",
})); "GREE settings updated",
json!({
"compressor_protection_enabled": payload.compressor_protection_enabled,
"compressor_protection_seconds": payload.compressor_protection_seconds
}),
);
state.broadcast("settings.gree.updated", serde_json::to_value(&payload)?); state.broadcast("settings.gree.updated", serde_json::to_value(&payload)?);
state.wake_zone_control(); state.wake_zone_control();
Ok(Json(payload)) Ok(Json(payload))
@@ -188,11 +216,16 @@ async fn update_history_settings(
history_settings(&settings) history_settings(&settings)
}; };
let removed = state.db.prune_events(payload.event_retention_days as i64)?; let removed = state.db.prune_events(payload.event_retention_days as i64)?;
state.log("info", "settings.history.updated", "History settings updated", json!({ state.log(
"retention_days": payload.retention_days, "info",
"event_retention_days": payload.event_retention_days, "settings.history.updated",
"event_rows_removed": removed "History settings updated",
})); json!({
"retention_days": payload.retention_days,
"event_retention_days": payload.event_retention_days,
"event_rows_removed": removed
}),
);
state.broadcast("settings.history.updated", serde_json::to_value(&payload)?); state.broadcast("settings.history.updated", serde_json::to_value(&payload)?);
Ok(Json(payload)) Ok(Json(payload))
} }
@@ -201,7 +234,10 @@ async fn get_influxdb_settings(State(state): State<AppState>) -> Json<InfluxDbSe
Json(influxdb_settings(&*state.settings.read().await)) Json(influxdb_settings(&*state.settings.read().await))
} }
fn apply_influxdb_update(current: &InfluxDbSettings, input: InfluxDbSettingsUpdate) -> Result<InfluxDbSettings, AppError> { fn apply_influxdb_update(
current: &InfluxDbSettings,
input: InfluxDbSettingsUpdate,
) -> Result<InfluxDbSettings, AppError> {
let mut next = InfluxDbSettings { let mut next = InfluxDbSettings {
enabled: input.enabled, enabled: input.enabled,
version: input.version, version: input.version,
@@ -214,8 +250,12 @@ fn apply_influxdb_update(current: &InfluxDbSettings, input: InfluxDbSettingsUpda
token: current.token.clone(), token: current.token.clone(),
history_threshold_days: input.history_threshold_days.clamp(1, 3650), history_threshold_days: input.history_threshold_days.clamp(1, 3650),
}; };
if let Some(password) = input.password { next.password = password; } if let Some(password) = input.password {
if let Some(token) = input.token { next.token = token; } next.password = password;
}
if let Some(token) = input.token {
next.token = token;
}
influxdb::validate(&next).map_err(|err| AppError::BadRequest(err.to_string()))?; influxdb::validate(&next).map_err(|err| AppError::BadRequest(err.to_string()))?;
Ok(next) Ok(next)
} }
@@ -232,24 +272,38 @@ async fn update_influxdb_settings(
state.db.save_runtime_settings(&settings)?; state.db.save_runtime_settings(&settings)?;
influxdb_settings(&settings) influxdb_settings(&settings)
}; };
state.log("info", "settings.influxdb.updated", "InfluxDB settings updated", json!({"enabled": payload.enabled, "version": payload.version})); state.log(
"info",
"settings.influxdb.updated",
"InfluxDB settings updated",
json!({"enabled": payload.enabled, "version": payload.version}),
);
state.broadcast("settings.influxdb.updated", serde_json::to_value(&payload)?); state.broadcast("settings.influxdb.updated", serde_json::to_value(&payload)?);
Ok(Json(payload)) Ok(Json(payload))
} }
async fn get_notification_settings(State(state): State<AppState>) -> Json<NotificationSettingsView> { async fn get_notification_settings(
State(state): State<AppState>,
) -> Json<NotificationSettingsView> {
Json(notification_settings(&*state.settings.read().await)) Json(notification_settings(&*state.settings.read().await))
} }
fn apply_notification_update(current: &NotificationSettings, mut input: NotificationSettingsUpdate) -> Result<NotificationSettings, AppError> { fn apply_notification_update(
current: &NotificationSettings,
mut input: NotificationSettingsUpdate,
) -> Result<NotificationSettings, AppError> {
input.cooldown_seconds = input.cooldown_seconds.clamp(30, 86_400); input.cooldown_seconds = input.cooldown_seconds.clamp(30, 86_400);
input.communication_failure_threshold = input.communication_failure_threshold.clamp(2, 100); input.communication_failure_threshold = input.communication_failure_threshold.clamp(2, 100);
input.target_timeout_minutes = input.target_timeout_minutes.clamp(5, 24 * 60); input.target_timeout_minutes = input.target_timeout_minutes.clamp(5, 24 * 60);
if !matches!(input.mode.as_str(), "problems" | "important") { if !matches!(input.mode.as_str(), "problems" | "important") {
return Err(AppError::BadRequest("notification mode must be problems or important".into())); return Err(AppError::BadRequest(
"notification mode must be problems or important".into(),
));
} }
if !matches!(input.provider.as_str(), "pushover" | "slack" | "discord") { if !matches!(input.provider.as_str(), "pushover" | "slack" | "discord") {
return Err(AppError::BadRequest("unsupported notification provider".into())); return Err(AppError::BadRequest(
"unsupported notification provider".into(),
));
} }
let mut next = NotificationSettings { let mut next = NotificationSettings {
enabled: input.enabled, enabled: input.enabled,
@@ -264,10 +318,18 @@ fn apply_notification_update(current: &NotificationSettings, mut input: Notifica
target_timeout_minutes: input.target_timeout_minutes, target_timeout_minutes: input.target_timeout_minutes,
alert_types: input.alert_types, alert_types: input.alert_types,
}; };
if let Some(value) = input.pushover_app_token { next.pushover_app_token = value; } if let Some(value) = input.pushover_app_token {
if let Some(value) = input.pushover_user_key { next.pushover_user_key = value; } next.pushover_app_token = value;
if let Some(value) = input.slack_webhook_url { next.slack_webhook_url = value; } }
if let Some(value) = input.discord_webhook_url { next.discord_webhook_url = value; } if let Some(value) = input.pushover_user_key {
next.pushover_user_key = value;
}
if let Some(value) = input.slack_webhook_url {
next.slack_webhook_url = value;
}
if let Some(value) = input.discord_webhook_url {
next.discord_webhook_url = value;
}
Ok(next) Ok(next)
} }
@@ -283,8 +345,16 @@ async fn update_notification_settings(
state.db.save_runtime_settings(&settings)?; state.db.save_runtime_settings(&settings)?;
notification_settings(&settings) notification_settings(&settings)
}; };
state.log("info", "settings.notifications.updated", "Notification settings updated", json!({"enabled": payload.enabled, "provider": payload.provider})); state.log(
state.broadcast("settings.notifications.updated", serde_json::to_value(&payload)?); "info",
"settings.notifications.updated",
"Notification settings updated",
json!({"enabled": payload.enabled, "provider": payload.provider}),
);
state.broadcast(
"settings.notifications.updated",
serde_json::to_value(&payload)?,
);
Ok(Json(payload)) Ok(Json(payload))
} }
@@ -313,24 +383,37 @@ async fn update_night_settings(
settings.night_mode = input.clone(); settings.night_mode = input.clone();
state.db.save_runtime_settings(&settings)?; state.db.save_runtime_settings(&settings)?;
} }
state.log("info", "settings.night.updated", "Night mode settings updated", json!({"enabled": input.enabled})); state.log(
"info",
"settings.night.updated",
"Night mode settings updated",
json!({"enabled": input.enabled}),
);
state.broadcast("settings.night.updated", serde_json::to_value(&input)?); state.broadcast("settings.night.updated", serde_json::to_value(&input)?);
state.wake_zone_control(); state.wake_zone_control();
Ok(Json(input)) Ok(Json(input))
} }
async fn get_home_assistant_settings(State(state): State<AppState>) -> Json<HomeAssistantSettingsView> { async fn get_home_assistant_settings(
State(state): State<AppState>,
) -> Json<HomeAssistantSettingsView> {
Json(home_assistant_settings(&*state.settings.read().await)) Json(home_assistant_settings(&*state.settings.read().await))
} }
fn normalize_sensor_aliases(settings: &mut HomeAssistantSettings) { fn normalize_sensor_aliases(settings: &mut HomeAssistantSettings) {
settings.sensor_aliases = settings.sensor_aliases settings.sensor_aliases = settings
.sensor_aliases
.iter() .iter()
.filter_map(|(entity, alias)| { .filter_map(|(entity, alias)| {
let entity = entity.trim(); let entity = entity.trim();
let alias = alias.trim(); let alias = alias.trim();
if entity.is_empty() || alias.is_empty() { return None; } if entity.is_empty() || alias.is_empty() {
Some((entity.chars().take(160).collect::<String>(), alias.chars().take(80).collect::<String>())) return None;
}
Some((
entity.chars().take(160).collect::<String>(),
alias.chars().take(80).collect::<String>(),
))
}) })
.collect(); .collect();
} }
@@ -338,39 +421,69 @@ fn normalize_sensor_aliases(settings: &mut HomeAssistantSettings) {
fn normalize_flow_shared_inputs(settings: &mut HomeAssistantSettings) -> Result<(), AppError> { fn normalize_flow_shared_inputs(settings: &mut HomeAssistantSettings) -> Result<(), AppError> {
let mut ids = std::collections::HashSet::new(); let mut ids = std::collections::HashSet::new();
if settings.flow_inputs.len() > 128 { if settings.flow_inputs.len() > 128 {
return Err(AppError::BadRequest("too many shared Flow inputs (max 128)".into())); return Err(AppError::BadRequest(
"too many shared Flow inputs (max 128)".into(),
));
} }
for item in &mut settings.flow_inputs { for item in &mut settings.flow_inputs {
item.id = item.id.trim().chars().take(120).collect(); item.id = item.id.trim().chars().take(120).collect();
item.name = item.name.trim().chars().take(100).collect(); item.name = item.name.trim().chars().take(100).collect();
item.kind = item.kind.trim().to_string(); item.kind = item.kind.trim().to_string();
if item.id.is_empty() || item.name.is_empty() { if item.id.is_empty() || item.name.is_empty() {
return Err(AppError::BadRequest("shared Flow input requires id and name".into())); return Err(AppError::BadRequest(
"shared Flow input requires id and name".into(),
));
} }
if !ids.insert(item.id.clone()) { if !ids.insert(item.id.clone()) {
return Err(AppError::BadRequest("shared Flow input IDs must be unique".into())); return Err(AppError::BadRequest(
"shared Flow input IDs must be unique".into(),
));
} }
if !matches!(item.kind.as_str(), if !matches!(
"outdoor_temperature" | "device_temperature" | "zone_temperature" | item.kind.as_str(),
"ha_state" | "ha_numeric" | "ha_attribute" | "ha_available" | "outdoor_temperature"
"house_mode" | "device_state" | "zone_state" | "group_state" | | "device_temperature"
"night_mode" | "constant") { | "zone_temperature"
return Err(AppError::BadRequest(format!("unsupported shared Flow input kind: {}", item.kind))); | "ha_state"
| "ha_numeric"
| "ha_attribute"
| "ha_available"
| "house_mode"
| "device_state"
| "zone_state"
| "group_state"
| "night_mode"
| "constant"
) {
return Err(AppError::BadRequest(format!(
"unsupported shared Flow input kind: {}",
item.kind
)));
} }
if !item.config.is_object() { if !item.config.is_object() {
return Err(AppError::BadRequest("shared Flow input config must be an object".into())); return Err(AppError::BadRequest(
"shared Flow input config must be an object".into(),
));
} }
if item.config.get("operator").is_some() { if item.config.get("operator").is_some() {
return Err(AppError::BadRequest("shared Flow inputs are value sources; operator belongs to the Flow block".into())); return Err(AppError::BadRequest(
"shared Flow inputs are value sources; operator belongs to the Flow block".into(),
));
} }
if shared_input_comparison_kind(&item.kind) && item.config.get("value").is_some() { if shared_input_comparison_kind(&item.kind) && item.config.get("value").is_some() {
return Err(AppError::BadRequest("shared Flow inputs are value sources; comparison value belongs to the Flow block".into())); return Err(AppError::BadRequest(
"shared Flow inputs are value sources; comparison value belongs to the Flow block"
.into(),
));
} }
} }
Ok(()) Ok(())
} }
fn validate_flow_shared_inputs(settings: &mut HomeAssistantSettings, state: &AppState) -> Result<(), AppError> { fn validate_flow_shared_inputs(
settings: &mut HomeAssistantSettings,
state: &AppState,
) -> Result<(), AppError> {
normalize_flow_shared_inputs(settings)?; normalize_flow_shared_inputs(settings)?;
for item in &settings.flow_inputs { for item in &settings.flow_inputs {
validate_shared_input_source(&item.kind, &item.config, state)?; validate_shared_input_source(&item.kind, &item.config, state)?;
@@ -385,36 +498,55 @@ fn canonicalize_home_assistant_entities(settings: &mut HomeAssistantSettings) {
} }
let outdoor_entity = settings.outdoor_entity_id.clone(); let outdoor_entity = settings.outdoor_entity_id.clone();
if !outdoor_entity.trim().is_empty() { if !outdoor_entity.trim().is_empty() {
if let Some(entity_id) = home_assistant::resolve_entity_id(settings, Some(&outdoor_entity)) { if let Some(entity_id) = home_assistant::resolve_entity_id(settings, Some(&outdoor_entity))
{
settings.outdoor_entity_id = entity_id; settings.outdoor_entity_id = entity_id;
} }
} }
} }
fn validate_home_assistant_url(settings: &HomeAssistantSettings) -> Result<(), AppError> { fn validate_home_assistant_url(settings: &HomeAssistantSettings) -> Result<(), AppError> {
if settings.url.trim().is_empty() { return Ok(()); } if settings.url.trim().is_empty() {
return Ok(());
}
let parsed = url::Url::parse(&settings.url) let parsed = url::Url::parse(&settings.url)
.map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?; .map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?;
if !matches!(parsed.scheme(), "http" | "https") { if !matches!(parsed.scheme(), "http" | "https") {
return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); return Err(AppError::BadRequest(
"Home Assistant URL must use http or https".into(),
));
} }
Ok(()) Ok(())
} }
fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &HomeAssistantSettings) { fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &HomeAssistantSettings) {
let Some(configured) = zone.ha_entity_id.clone() else { return; }; let Some(configured) = zone.ha_entity_id.clone() else {
return;
};
zone.ha_entity_id = home_assistant::resolve_entity_id(settings, Some(&configured)); zone.ha_entity_id = home_assistant::resolve_entity_id(settings, Some(&configured));
} }
async fn canonicalize_saved_zone_entities(state: &AppState, settings: &HomeAssistantSettings) -> Result<(), AppError> { async fn canonicalize_saved_zone_entities(
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); state: &AppState,
settings: &HomeAssistantSettings,
) -> Result<(), AppError> {
let mut zone_ids: Vec<String> = state
.db
.list_zones()?
.into_iter()
.map(|zone| zone.id)
.collect();
zone_ids.sort(); zone_ids.sort();
zone_ids.dedup(); zone_ids.dedup();
for zone_id in zone_ids { for zone_id in zone_ids {
let _zone_guard = state.lock_zone_operation(&zone_id).await; let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; }; let Some(snapshot) = state.db.get_zone(&zone_id)? else {
continue;
};
let _device_guard = state.lock_device_operation(&snapshot.device_id).await; let _device_guard = state.lock_device_operation(&snapshot.device_id).await;
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; let Some(mut zone) = state.db.get_zone(&zone_id)? else {
continue;
};
let previous = zone.ha_entity_id.clone(); let previous = zone.ha_entity_id.clone();
canonicalize_zone_ha_entity(&mut zone, settings); canonicalize_zone_ha_entity(&mut zone, settings);
if zone.ha_entity_id != previous { if zone.ha_entity_id != previous {
@@ -440,7 +572,9 @@ fn apply_home_assistant_update(
sensor_aliases: input.sensor_aliases, sensor_aliases: input.sensor_aliases,
flow_inputs: input.flow_inputs, flow_inputs: input.flow_inputs,
}; };
if let Some(token) = input.token { next.token = token; } if let Some(token) = input.token {
next.token = token;
}
next next
} }
@@ -464,11 +598,19 @@ async fn update_home_assistant_settings(
}; };
let saved = state.settings.read().await.home_assistant.clone(); let saved = state.settings.read().await.home_assistant.clone();
canonicalize_saved_zone_entities(&state, &saved).await?; canonicalize_saved_zone_entities(&state, &saved).await?;
state.log("info", "settings.home_assistant.updated", "Home Assistant settings updated", json!({ state.log(
"configured": payload.token_configured, "info",
"flow_inputs": payload.flow_inputs.len() "settings.home_assistant.updated",
})); "Home Assistant settings updated",
state.broadcast("settings.home_assistant.updated", serde_json::to_value(&payload)?); json!({
"configured": payload.token_configured,
"flow_inputs": payload.flow_inputs.len()
}),
);
state.broadcast(
"settings.home_assistant.updated",
serde_json::to_value(&payload)?,
);
state.wake_zone_control(); state.wake_zone_control();
Ok(Json(payload)) Ok(Json(payload))
} }
@@ -487,7 +629,9 @@ async fn update_debug_settings(
settings.debug = input.clone(); settings.debug = input.clone();
state.db.save_runtime_settings(&settings)?; state.db.save_runtime_settings(&settings)?;
} }
state.debug_gree_frames.store(input.gree_frames, Ordering::Relaxed); state
.debug_gree_frames
.store(input.gree_frames, Ordering::Relaxed);
state.broadcast("settings.debug.updated", serde_json::to_value(&input)?); state.broadcast("settings.debug.updated", serde_json::to_value(&input)?);
Ok(Json(input)) Ok(Json(input))
} }
-1
View File
@@ -67,4 +67,3 @@ async fn system_info(State(state): State<AppState>) -> Result<Json<Value>, AppEr
"gree_received_frames_by_device": received_frames_by_device, "gree_received_frames_by_device": received_frames_by_device,
}))) })))
} }
+21 -6
View File
@@ -1,17 +1,33 @@
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct WsQuery { token: Option<String> } struct WsQuery {
async fn websocket(State(state): State<AppState>, Query(query): Query<WsQuery>, ws: WebSocketUpgrade) -> Result<Response, AppError> { token: Option<String>,
}
async fn websocket(
State(state): State<AppState>,
Query(query): Query<WsQuery>,
ws: WebSocketUpgrade,
) -> Result<Response, AppError> {
let expected = state.config.app_token.trim(); let expected = state.config.app_token.trim();
if !expected.is_empty() && query.token.as_deref() != Some(expected) { return Err(AppError::Unauthorized); } if !expected.is_empty() && query.token.as_deref() != Some(expected) {
return Err(AppError::Unauthorized);
}
Ok(ws.on_upgrade(move |socket| websocket_loop(state, socket))) Ok(ws.on_upgrade(move |socket| websocket_loop(state, socket)))
} }
async fn websocket_loop(state: AppState, mut socket: WebSocket) { async fn websocket_loop(state: AppState, mut socket: WebSocket) {
let initial = match build_bootstrap(&state).await { let initial = match build_bootstrap(&state).await {
Ok(data) => json!({"event":"bootstrap","timestamp":Utc::now(),"data":data}), Ok(data) => json!({"event":"bootstrap","timestamp":Utc::now(),"data":data}),
Err(err) => json!({"event":"error","timestamp":Utc::now(),"data":{"message":err.to_string()}}), Err(err) => {
json!({"event":"error","timestamp":Utc::now(),"data":{"message":err.to_string()}})
}
}; };
if socket.send(Message::Text(initial.to_string())).await.is_err() { return; } if socket
.send(Message::Text(initial.to_string()))
.await
.is_err()
{
return;
}
let mut receiver = state.events.subscribe(); let mut receiver = state.events.subscribe();
loop { loop {
tokio::select! { tokio::select! {
@@ -37,4 +53,3 @@ async fn websocket_loop(state: AppState, mut socket: WebSocket) {
} }
} }
} }
+712 -187
View File
File diff suppressed because it is too large Load Diff
+192 -63
View File
@@ -1,14 +1,21 @@
use std::{env, net::SocketAddr, path::PathBuf}; use crate::models::{
DebugSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings,
NotificationSettings, RuntimeSettings,
};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::Parser; use clap::Parser;
use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings, NotificationSettings, RuntimeSettings}; use std::{env, net::SocketAddr, path::PathBuf};
#[derive(Debug, Clone, Parser)] #[derive(Debug, Clone, Parser)]
#[command(author, version, about)] #[command(author, version, about)]
pub struct Config { pub struct Config {
#[arg(long, env = "GREE_CONTROLLER_BIND", default_value = "0.0.0.0:8787")] #[arg(long, env = "GREE_CONTROLLER_BIND", default_value = "0.0.0.0:8787")]
pub bind: SocketAddr, pub bind: SocketAddr,
#[arg(long, env = "GREE_CONTROLLER_DATABASE", default_value = "./data/gree-controller.db")] #[arg(
long,
env = "GREE_CONTROLLER_DATABASE",
default_value = "./data/gree-controller.db"
)]
pub database: PathBuf, pub database: PathBuf,
#[arg(long, env = "GREE_CONTROLLER_APP_TOKEN", default_value = "")] #[arg(long, env = "GREE_CONTROLLER_APP_TOKEN", default_value = "")]
pub app_token: String, pub app_token: String,
@@ -18,13 +25,29 @@ pub struct Config {
pub simulate: bool, pub simulate: bool,
#[arg(long, env = "GREE_CONTROLLER_AUTO_SEED", default_value_t = false)] #[arg(long, env = "GREE_CONTROLLER_AUTO_SEED", default_value_t = false)]
pub auto_seed: bool, pub auto_seed: bool,
#[arg(long, env = "GREE_CONTROLLER_POLL_INTERVAL_SECONDS", default_value_t = 15)] #[arg(
long,
env = "GREE_CONTROLLER_POLL_INTERVAL_SECONDS",
default_value_t = 15
)]
pub poll_interval_seconds: u64, pub poll_interval_seconds: u64,
#[arg(long, env = "GREE_CONTROLLER_ZONE_INTERVAL_SECONDS", default_value_t = 5)] #[arg(
long,
env = "GREE_CONTROLLER_ZONE_INTERVAL_SECONDS",
default_value_t = 5
)]
pub zone_interval_seconds: u64, pub zone_interval_seconds: u64,
#[arg(long, env = "GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS", default_value_t = 3000)] #[arg(
long,
env = "GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS",
default_value_t = 3000
)]
pub discovery_timeout_ms: u64, pub discovery_timeout_ms: u64,
#[arg(long, env = "GREE_CONTROLLER_DISCOVERY_BROADCAST", default_value = "255.255.255.255:7000")] #[arg(
long,
env = "GREE_CONTROLLER_DISCOVERY_BROADCAST",
default_value = "255.255.255.255:7000"
)]
pub discovery_broadcast: String, pub discovery_broadcast: String,
#[arg(long, env = "GREE_CONTROLLER_GREE_INTERFACE", default_value = "")] #[arg(long, env = "GREE_CONTROLLER_GREE_INTERFACE", default_value = "")]
pub gree_interface: String, pub gree_interface: String,
@@ -38,8 +61,9 @@ impl Config {
let mut config = Self::parse(); let mut config = Self::parse();
config.base_path = normalize_base_path(&config.base_path)?; config.base_path = normalize_base_path(&config.base_path)?;
if let Some(parent) = config.database.parent() { if let Some(parent) = config.database.parent() {
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent).with_context(|| {
.with_context(|| format!("cannot create database directory {}", parent.display()))?; format!("cannot create database directory {}", parent.display())
})?;
} }
Ok(config) Ok(config)
} }
@@ -54,13 +78,24 @@ impl Config {
discovery_broadcast: self.discovery_broadcast.clone(), discovery_broadcast: self.discovery_broadcast.clone(),
house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()), house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()),
control_strategy: "setpoint".into(), control_strategy: "setpoint".into(),
outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED").unwrap_or(true), outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED")
history_retention_days: env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650), .unwrap_or(true),
history_compaction_enabled: env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED").unwrap_or(true), history_retention_days: env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS")
event_log_retention_days: env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650), .unwrap_or(30)
.clamp(1, 3650),
history_compaction_enabled: env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED")
.unwrap_or(true),
event_log_retention_days: env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS")
.unwrap_or(30)
.clamp(1, 3650),
suppress_device_beep: env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP").unwrap_or(false), suppress_device_beep: env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP").unwrap_or(false),
compressor_protection_enabled: env_bool("GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED").unwrap_or(true), compressor_protection_enabled: env_bool(
compressor_protection_seconds: env_u64("GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS").unwrap_or(180).clamp(30, 1800), "GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED",
)
.unwrap_or(true),
compressor_protection_seconds: env_u64("GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS")
.unwrap_or(180)
.clamp(30, 1800),
influxdb: influx_settings_from_env(), influxdb: influx_settings_from_env(),
debug: DebugSettings { debug: DebugSettings {
overlay_enabled: env_bool("GREE_CONTROLLER_DEBUG_OVERLAY").unwrap_or(false), overlay_enabled: env_bool("GREE_CONTROLLER_DEBUG_OVERLAY").unwrap_or(false),
@@ -69,18 +104,25 @@ impl Config {
notifications: NotificationSettings::default(), notifications: NotificationSettings::default(),
night_mode: NightModeSettings { night_mode: NightModeSettings {
enabled: env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED").unwrap_or(false), enabled: env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED").unwrap_or(false),
start_time: env::var("GREE_CONTROLLER_NIGHT_MODE_START").unwrap_or_else(|_| "22:00".into()), start_time: env::var("GREE_CONTROLLER_NIGHT_MODE_START")
end_time: env::var("GREE_CONTROLLER_NIGHT_MODE_END").unwrap_or_else(|_| "06:00".into()), .unwrap_or_else(|_| "22:00".into()),
max_fan_speed: env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED").unwrap_or(1).clamp(1, 5), end_time: env::var("GREE_CONTROLLER_NIGHT_MODE_END")
.unwrap_or_else(|_| "06:00".into()),
max_fan_speed: env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED")
.unwrap_or(1)
.clamp(1, 5),
force_quiet: env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET").unwrap_or(true), force_quiet: env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET").unwrap_or(true),
use_native_sleep: env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP").unwrap_or(true), use_native_sleep: env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP")
.unwrap_or(true),
}, },
home_assistant: HomeAssistantSettings { home_assistant: HomeAssistantSettings {
url: env::var("HA_URL").unwrap_or_default(), url: env::var("HA_URL").unwrap_or_default(),
token: env::var("HA_TOKEN").unwrap_or_default(), token: env::var("HA_TOKEN").unwrap_or_default(),
default_entity_id: env::var("HA_ENTITY_ID").unwrap_or_default(), default_entity_id: env::var("HA_ENTITY_ID").unwrap_or_default(),
outdoor_entity_id: env::var("HA_OUTDOOR_ENTITY_ID").unwrap_or_default(), outdoor_entity_id: env::var("HA_OUTDOOR_ENTITY_ID").unwrap_or_default(),
sensor_stale_after_seconds: env_u64("HA_SENSOR_STALE_AFTER_SECONDS").unwrap_or(300).clamp(30, 86_400), sensor_stale_after_seconds: env_u64("HA_SENSOR_STALE_AFTER_SECONDS")
.unwrap_or(300)
.clamp(30, 86_400),
allow_invalid_tls: env::var("HA_ALLOW_INVALID_TLS") allow_invalid_tls: env::var("HA_ALLOW_INVALID_TLS")
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
.unwrap_or(false), .unwrap_or(false),
@@ -93,32 +135,80 @@ impl Config {
/// Environment values explicitly supplied by the service override persisted runtime values. /// Environment values explicitly supplied by the service override persisted runtime values.
pub fn apply_runtime_env_overrides(&self, settings: &mut RuntimeSettings) { pub fn apply_runtime_env_overrides(&self, settings: &mut RuntimeSettings) {
if env::var_os("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").is_some() { if env::var_os("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").is_some() {
settings.history_retention_days = env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(settings.history_retention_days).clamp(1, 3650); settings.history_retention_days = env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS")
.unwrap_or(settings.history_retention_days)
.clamp(1, 3650);
}
if let Some(value) = env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED") {
settings.history_compaction_enabled = value;
} }
if let Some(value) = env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED") { settings.history_compaction_enabled = value; }
if env::var_os("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").is_some() { if env::var_os("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").is_some() {
settings.event_log_retention_days = env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").unwrap_or(settings.event_log_retention_days).clamp(1, 3650); settings.event_log_retention_days = env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS")
.unwrap_or(settings.event_log_retention_days)
.clamp(1, 3650);
}
if let Some(value) = env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP") {
settings.suppress_device_beep = value;
}
if let Some(value) = env_bool("GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED") {
settings.compressor_protection_enabled = value;
}
if let Some(value) = env_u64("GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS") {
settings.compressor_protection_seconds = value.clamp(30, 1800);
}
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_OVERLAY") {
settings.debug.overlay_enabled = value;
}
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES") {
settings.debug.gree_frames = value;
}
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED") {
settings.night_mode.enabled = value;
}
if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_START") {
if !value.trim().is_empty() {
settings.night_mode.start_time = value;
}
}
if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_END") {
if !value.trim().is_empty() {
settings.night_mode.end_time = value;
}
}
if let Some(value) = env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED") {
settings.night_mode.max_fan_speed = value.clamp(1, 5);
}
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET") {
settings.night_mode.force_quiet = value;
}
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP") {
settings.night_mode.use_native_sleep = value;
}
if let Some(value) = env_u64("HA_SENSOR_STALE_AFTER_SECONDS") {
settings.home_assistant.sensor_stale_after_seconds = value.clamp(30, 86_400);
} }
if let Some(value) = env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP") { settings.suppress_device_beep = value; }
if let Some(value) = env_bool("GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED") { settings.compressor_protection_enabled = value; }
if let Some(value) = env_u64("GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS") { settings.compressor_protection_seconds = value.clamp(30, 1800); }
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_OVERLAY") { settings.debug.overlay_enabled = value; }
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES") { settings.debug.gree_frames = value; }
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED") { settings.night_mode.enabled = value; }
if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_START") { if !value.trim().is_empty() { settings.night_mode.start_time = value; } }
if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_END") { if !value.trim().is_empty() { settings.night_mode.end_time = value; } }
if let Some(value) = env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED") { settings.night_mode.max_fan_speed = value.clamp(1, 5); }
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET") { settings.night_mode.force_quiet = value; }
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP") { settings.night_mode.use_native_sleep = value; }
if let Some(value) = env_u64("HA_SENSOR_STALE_AFTER_SECONDS") { settings.home_assistant.sensor_stale_after_seconds = value.clamp(30, 86_400); }
let influx_env_present = [ let influx_env_present = [
"GREE_CONTROLLER_INFLUX_ENABLED", "GREE_CONTROLLER_INFLUX_VERSION", "GREE_CONTROLLER_INFLUX_URL", "GREE_CONTROLLER_INFLUX_ENABLED",
"GREE_CONTROLLER_INFLUX_DATABASE", "GREE_CONTROLLER_INFLUX_USERNAME", "GREE_CONTROLLER_INFLUX_PASSWORD", "GREE_CONTROLLER_INFLUX_VERSION",
"GREE_CONTROLLER_INFLUX_ORG", "GREE_CONTROLLER_INFLUX_BUCKET", "GREE_CONTROLLER_INFLUX_TOKEN", "GREE_CONTROLLER_INFLUX_URL",
"GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS", "INFLUXDB_URL", "INFLUXDB_DATABASE", "INFLUXDB_USERNAME", "GREE_CONTROLLER_INFLUX_DATABASE",
"INFLUXDB_PASSWORD", "INFLUXDB_TOKEN", "INFLUXDB_ORG", "INFLUXDB_BUCKET", "GREE_CONTROLLER_INFLUX_USERNAME",
].iter().any(|name| env::var_os(name).is_some()); "GREE_CONTROLLER_INFLUX_PASSWORD",
"GREE_CONTROLLER_INFLUX_ORG",
"GREE_CONTROLLER_INFLUX_BUCKET",
"GREE_CONTROLLER_INFLUX_TOKEN",
"GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS",
"INFLUXDB_URL",
"INFLUXDB_DATABASE",
"INFLUXDB_USERNAME",
"INFLUXDB_PASSWORD",
"INFLUXDB_TOKEN",
"INFLUXDB_ORG",
"INFLUXDB_BUCKET",
]
.iter()
.any(|name| env::var_os(name).is_some());
if influx_env_present { if influx_env_present {
let env_settings = influx_settings_from_env(); let env_settings = influx_settings_from_env();
if env::var_os("GREE_CONTROLLER_INFLUX_ENABLED").is_some() { if env::var_os("GREE_CONTROLLER_INFLUX_ENABLED").is_some() {
@@ -126,35 +216,66 @@ impl Config {
} else if first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).is_some() { } else if first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).is_some() {
settings.influxdb.enabled = true; settings.influxdb.enabled = true;
} }
if first_env(&["GREE_CONTROLLER_INFLUX_VERSION"]).is_some() { settings.influxdb.version = env_settings.version; } if first_env(&["GREE_CONTROLLER_INFLUX_VERSION"]).is_some() {
if first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).is_some() { settings.influxdb.url = env_settings.url; } settings.influxdb.version = env_settings.version;
if first_env(&["GREE_CONTROLLER_INFLUX_DATABASE", "INFLUXDB_DATABASE"]).is_some() { settings.influxdb.database = env_settings.database; } }
if first_env(&["GREE_CONTROLLER_INFLUX_USERNAME", "INFLUXDB_USERNAME"]).is_some() { settings.influxdb.username = env_settings.username; } if first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).is_some() {
if first_env(&["GREE_CONTROLLER_INFLUX_PASSWORD", "INFLUXDB_PASSWORD"]).is_some() { settings.influxdb.password = env_settings.password; } settings.influxdb.url = env_settings.url;
if first_env(&["GREE_CONTROLLER_INFLUX_ORG", "INFLUXDB_ORG"]).is_some() { settings.influxdb.org = env_settings.org; } }
if first_env(&["GREE_CONTROLLER_INFLUX_BUCKET", "INFLUXDB_BUCKET"]).is_some() { settings.influxdb.bucket = env_settings.bucket; } if first_env(&["GREE_CONTROLLER_INFLUX_DATABASE", "INFLUXDB_DATABASE"]).is_some() {
if first_env(&["GREE_CONTROLLER_INFLUX_TOKEN", "INFLUXDB_TOKEN"]).is_some() { settings.influxdb.token = env_settings.token; } settings.influxdb.database = env_settings.database;
if env::var_os("GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS").is_some() { settings.influxdb.history_threshold_days = env_settings.history_threshold_days; } }
if first_env(&["GREE_CONTROLLER_INFLUX_USERNAME", "INFLUXDB_USERNAME"]).is_some() {
settings.influxdb.username = env_settings.username;
}
if first_env(&["GREE_CONTROLLER_INFLUX_PASSWORD", "INFLUXDB_PASSWORD"]).is_some() {
settings.influxdb.password = env_settings.password;
}
if first_env(&["GREE_CONTROLLER_INFLUX_ORG", "INFLUXDB_ORG"]).is_some() {
settings.influxdb.org = env_settings.org;
}
if first_env(&["GREE_CONTROLLER_INFLUX_BUCKET", "INFLUXDB_BUCKET"]).is_some() {
settings.influxdb.bucket = env_settings.bucket;
}
if first_env(&["GREE_CONTROLLER_INFLUX_TOKEN", "INFLUXDB_TOKEN"]).is_some() {
settings.influxdb.token = env_settings.token;
}
if env::var_os("GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS").is_some() {
settings.influxdb.history_threshold_days = env_settings.history_threshold_days;
}
} }
} }
} }
fn normalize_base_path(value: &str) -> Result<String> { fn normalize_base_path(value: &str) -> Result<String> {
let value = value.trim(); let value = value.trim();
if value.is_empty() || value == "/" { return Ok(String::new()); } if value.is_empty() || value == "/" {
if value.contains('?') || value.contains('#') || value.split('/').any(|part| matches!(part, "." | "..")) { return Ok(String::new());
}
if value.contains('?')
|| value.contains('#')
|| value.split('/').any(|part| matches!(part, "." | ".."))
{
anyhow::bail!("GREE_CONTROLLER_BASE_PATH must be a simple URL path without '.', '..', query or fragment"); anyhow::bail!("GREE_CONTROLLER_BASE_PATH must be a simple URL path without '.', '..', query or fragment");
} }
Ok(format!("/{}", value.trim_matches('/'))) Ok(format!("/{}", value.trim_matches('/')))
} }
fn env_bool(name: &str) -> Option<bool> { fn env_bool(name: &str) -> Option<bool> {
env::var(name).ok().map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) env::var(name)
.ok()
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
} }
fn env_u32(name: &str) -> Option<u32> { env::var(name).ok()?.parse().ok() } fn env_u32(name: &str) -> Option<u32> {
fn env_u64(name: &str) -> Option<u64> { env::var(name).ok()?.parse().ok() } env::var(name).ok()?.parse().ok()
fn env_u8(name: &str) -> Option<u8> { env::var(name).ok()?.parse().ok() } }
fn env_u64(name: &str) -> Option<u64> {
env::var(name).ok()?.parse().ok()
}
fn env_u8(name: &str) -> Option<u8> {
env::var(name).ok()?.parse().ok()
}
fn first_env(names: &[&str]) -> Option<String> { fn first_env(names: &[&str]) -> Option<String> {
names.iter().find_map(|name| { names.iter().find_map(|name| {
@@ -167,13 +288,21 @@ fn influx_settings_from_env() -> InfluxDbSettings {
let mut settings = InfluxDbSettings::default(); let mut settings = InfluxDbSettings::default();
settings.version = first_env(&["GREE_CONTROLLER_INFLUX_VERSION"]).unwrap_or_else(|| "2".into()); settings.version = first_env(&["GREE_CONTROLLER_INFLUX_VERSION"]).unwrap_or_else(|| "2".into());
settings.url = first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).unwrap_or_default(); settings.url = first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).unwrap_or_default();
settings.enabled = env_bool("GREE_CONTROLLER_INFLUX_ENABLED").unwrap_or(!settings.url.is_empty()); settings.enabled =
settings.database = first_env(&["GREE_CONTROLLER_INFLUX_DATABASE", "INFLUXDB_DATABASE"]).unwrap_or_else(|| "gree_controller".into()); env_bool("GREE_CONTROLLER_INFLUX_ENABLED").unwrap_or(!settings.url.is_empty());
settings.username = first_env(&["GREE_CONTROLLER_INFLUX_USERNAME", "INFLUXDB_USERNAME"]).unwrap_or_default(); settings.database = first_env(&["GREE_CONTROLLER_INFLUX_DATABASE", "INFLUXDB_DATABASE"])
settings.password = first_env(&["GREE_CONTROLLER_INFLUX_PASSWORD", "INFLUXDB_PASSWORD"]).unwrap_or_default(); .unwrap_or_else(|| "gree_controller".into());
settings.username =
first_env(&["GREE_CONTROLLER_INFLUX_USERNAME", "INFLUXDB_USERNAME"]).unwrap_or_default();
settings.password =
first_env(&["GREE_CONTROLLER_INFLUX_PASSWORD", "INFLUXDB_PASSWORD"]).unwrap_or_default();
settings.org = first_env(&["GREE_CONTROLLER_INFLUX_ORG", "INFLUXDB_ORG"]).unwrap_or_default(); settings.org = first_env(&["GREE_CONTROLLER_INFLUX_ORG", "INFLUXDB_ORG"]).unwrap_or_default();
settings.bucket = first_env(&["GREE_CONTROLLER_INFLUX_BUCKET", "INFLUXDB_BUCKET"]).unwrap_or_else(|| "gree_controller".into()); settings.bucket = first_env(&["GREE_CONTROLLER_INFLUX_BUCKET", "INFLUXDB_BUCKET"])
settings.token = first_env(&["GREE_CONTROLLER_INFLUX_TOKEN", "INFLUXDB_TOKEN"]).unwrap_or_default(); .unwrap_or_else(|| "gree_controller".into());
settings.history_threshold_days = env_u32("GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS").unwrap_or(30).clamp(1, 3650); settings.token =
first_env(&["GREE_CONTROLLER_INFLUX_TOKEN", "INFLUXDB_TOKEN"]).unwrap_or_default();
settings.history_threshold_days = env_u32("GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS")
.unwrap_or(30)
.clamp(1, 3650);
settings settings
} }
+10 -5
View File
@@ -1,12 +1,18 @@
use std::{path::Path, sync::{Arc, Mutex}}; use crate::{
models::{
ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, Device, EventLog, Flow,
HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading,
},
queries,
};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value; use serde_json::Value;
use crate::{ use std::{
models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, Device, EventLog, Flow, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading}, path::Path,
queries, sync::{Arc, Mutex},
}; };
#[derive(Clone)] #[derive(Clone)]
@@ -14,7 +20,6 @@ pub struct Db {
conn: Arc<Mutex<Connection>>, conn: Arc<Mutex<Connection>>,
} }
// Functional source split intentionally keeps items in the existing module namespace. // Functional source split intentionally keeps items in the existing module namespace.
include!("db/core_devices.rs"); include!("db/core_devices.rs");
include!("db/climate.rs"); include!("db/climate.rs");
+18 -7
View File
@@ -48,7 +48,6 @@ impl Db {
pub fn delete_group(&self, id: &str) -> Result<bool> { pub fn delete_group(&self, id: &str) -> Result<bool> {
self.delete_by_id("groups", id) self.delete_by_id("groups", id)
} }
} }
impl Db { impl Db {
@@ -62,8 +61,12 @@ impl Db {
mode_changed: bool, mode_changed: bool,
at: DateTime<Utc>, at: DateTime<Utc>,
) -> Result<Vec<Zone>> { ) -> Result<Vec<Zone>> {
if !power_changed && !mode_changed { return Ok(Vec::new()); } if !power_changed && !mode_changed {
let zone_ids: Vec<String> = self.list_zones()?.into_iter() return Ok(Vec::new());
}
let zone_ids: Vec<String> = self
.list_zones()?
.into_iter()
.filter(|zone| zone.device_id == device_id) .filter(|zone| zone.device_id == device_id)
.map(|zone| zone.id) .map(|zone| zone.id)
.collect(); .collect();
@@ -71,10 +74,16 @@ impl Db {
for zone_id in zone_ids { for zone_id in zone_ids {
let mut saved = false; let mut saved = false;
for _ in 0..8 { for _ in 0..8 {
let Some(mut zone) = self.get_zone(&zone_id)? else { break; }; let Some(mut zone) = self.get_zone(&zone_id)? else {
break;
};
let expected_updated_at = zone.updated_at.to_rfc3339(); let expected_updated_at = zone.updated_at.to_rfc3339();
if power_changed { zone.last_power_change_at = Some(at); } if power_changed {
if mode_changed { zone.last_mode_change_at = Some(at); } zone.last_power_change_at = Some(at);
}
if mode_changed {
zone.last_mode_change_at = Some(at);
}
let payload = Self::to_json(&zone)?; let payload = Self::to_json(&zone)?;
let conn = self.lock()?; let conn = self.lock()?;
let changed = conn.execute( let changed = conn.execute(
@@ -89,7 +98,9 @@ impl Db {
} }
} }
if !saved && self.get_zone(&zone_id)?.is_some() { if !saved && self.get_zone(&zone_id)?.is_some() {
return Err(anyhow::anyhow!("zone {zone_id} kept changing while device transition timestamps were merged")); return Err(anyhow::anyhow!(
"zone {zone_id} kept changing while device transition timestamps were merged"
));
} }
} }
Ok(updated) Ok(updated)
+44 -8
View File
@@ -19,37 +19,73 @@ impl Db {
tx.execute_batch(queries::CLEAR_CONFIGURATION)?; tx.execute_batch(queries::CLEAR_CONFIGURATION)?;
for device in &export.devices { for device in &export.devices {
let payload = Self::to_json(device)?; let payload = Self::to_json(device)?;
tx.execute(queries::UPSERT_DEVICE, params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()])?; tx.execute(
queries::UPSERT_DEVICE,
params![
device.id,
device.mac,
device.name,
device.ip,
device.simulated as i64,
payload,
device.updated_at.to_rfc3339()
],
)?;
} }
for zone in &export.zones { for zone in &export.zones {
let payload = Self::to_json(zone)?; let payload = Self::to_json(zone)?;
tx.execute(queries::UPSERT_ZONE, params![zone.id, payload, zone.updated_at.to_rfc3339()])?; tx.execute(
queries::UPSERT_ZONE,
params![zone.id, payload, zone.updated_at.to_rfc3339()],
)?;
} }
for group in &export.groups { for group in &export.groups {
let payload = Self::to_json(group)?; let payload = Self::to_json(group)?;
tx.execute(queries::UPSERT_GROUP, params![group.id, payload, group.updated_at.to_rfc3339()])?; tx.execute(
queries::UPSERT_GROUP,
params![group.id, payload, group.updated_at.to_rfc3339()],
)?;
} }
for schedule in &export.schedules { for schedule in &export.schedules {
let payload = Self::to_json(schedule)?; let payload = Self::to_json(schedule)?;
tx.execute(queries::UPSERT_SCHEDULE, params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()])?; tx.execute(
queries::UPSERT_SCHEDULE,
params![
schedule.id,
schedule.zone_id,
payload,
schedule.updated_at.to_rfc3339()
],
)?;
} }
for item in &export.automations { for item in &export.automations {
let payload = Self::to_json(item)?; let payload = Self::to_json(item)?;
tx.execute(queries::UPSERT_AUTOMATION, params![item.id, payload, item.updated_at.to_rfc3339()])?; tx.execute(
queries::UPSERT_AUTOMATION,
params![item.id, payload, item.updated_at.to_rfc3339()],
)?;
} }
for flow in &export.flows { for flow in &export.flows {
let payload = Self::to_json(flow)?; let payload = Self::to_json(flow)?;
tx.execute(queries::UPSERT_FLOW, params![flow.id, payload, flow.updated_at.to_rfc3339()])?; tx.execute(
queries::UPSERT_FLOW,
params![flow.id, payload, flow.updated_at.to_rfc3339()],
)?;
} }
let settings_json = Self::to_json(&export.settings)?; let settings_json = Self::to_json(&export.settings)?;
tx.execute(queries::UPSERT_RUNTIME_SETTINGS, params![settings_json, Utc::now().to_rfc3339()])?; tx.execute(
queries::UPSERT_RUNTIME_SETTINGS,
params![settings_json, Utc::now().to_rfc3339()],
)?;
tx.commit()?; tx.commit()?;
Ok(()) Ok(())
} }
pub fn load_runtime_settings(&self) -> Result<Option<RuntimeSettings>> { pub fn load_runtime_settings(&self) -> Result<Option<RuntimeSettings>> {
let conn = self.lock()?; let conn = self.lock()?;
let value: Option<String> = conn.query_row(queries::LOAD_RUNTIME_SETTINGS, [], |row| row.get(0)).optional()?; let value: Option<String> = conn
.query_row(queries::LOAD_RUNTIME_SETTINGS, [], |row| row.get(0))
.optional()?;
value.map(Self::from_json).transpose() value.map(Self::from_json).transpose()
} }
+23 -7
View File
@@ -4,11 +4,15 @@ impl Db {
.with_context(|| format!("cannot open SQLite database {}", path.display()))?; .with_context(|| format!("cannot open SQLite database {}", path.display()))?;
conn.busy_timeout(std::time::Duration::from_secs(5))?; conn.busy_timeout(std::time::Duration::from_secs(5))?;
conn.execute_batch(queries::INIT_SCHEMA)?; conn.execute_batch(queries::INIT_SCHEMA)?;
Ok(Self { conn: Arc::new(Mutex::new(conn)) }) Ok(Self {
conn: Arc::new(Mutex::new(conn)),
})
} }
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Connection>> { fn lock(&self) -> Result<std::sync::MutexGuard<'_, Connection>> {
self.conn.lock().map_err(|_| anyhow::anyhow!("database mutex poisoned")) self.conn
.lock()
.map_err(|_| anyhow::anyhow!("database mutex poisoned"))
} }
fn from_json<T: DeserializeOwned>(payload: String) -> Result<T> { fn from_json<T: DeserializeOwned>(payload: String) -> Result<T> {
@@ -30,7 +34,15 @@ impl Db {
let conn = self.lock()?; let conn = self.lock()?;
conn.execute( conn.execute(
queries::UPSERT_DEVICE, queries::UPSERT_DEVICE,
params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()], params![
device.id,
device.mac,
device.name,
device.ip,
device.simulated as i64,
payload,
device.updated_at.to_rfc3339()
],
)?; )?;
Ok(()) Ok(())
} }
@@ -38,20 +50,25 @@ impl Db {
pub fn list_devices(&self) -> Result<Vec<Device>> { pub fn list_devices(&self) -> Result<Vec<Device>> {
let conn = self.lock()?; let conn = self.lock()?;
let mut stmt = conn.prepare(queries::LIST_DEVICES)?; let mut stmt = conn.prepare(queries::LIST_DEVICES)?;
let payloads = stmt.query_map([], |row| row.get::<_, String>(0))? let payloads = stmt
.query_map([], |row| row.get::<_, String>(0))?
.collect::<std::result::Result<Vec<_>, _>>()?; .collect::<std::result::Result<Vec<_>, _>>()?;
payloads.into_iter().map(Self::from_json).collect() payloads.into_iter().map(Self::from_json).collect()
} }
pub fn get_device(&self, id: &str) -> Result<Option<Device>> { pub fn get_device(&self, id: &str) -> Result<Option<Device>> {
let conn = self.lock()?; let conn = self.lock()?;
let payload: Option<String> = conn.query_row(queries::GET_DEVICE_BY_ID, [id], |row| row.get(0)).optional()?; let payload: Option<String> = conn
.query_row(queries::GET_DEVICE_BY_ID, [id], |row| row.get(0))
.optional()?;
payload.map(Self::from_json).transpose() payload.map(Self::from_json).transpose()
} }
pub fn get_device_by_mac(&self, mac: &str) -> Result<Option<Device>> { pub fn get_device_by_mac(&self, mac: &str) -> Result<Option<Device>> {
let conn = self.lock()?; let conn = self.lock()?;
let payload: Option<String> = conn.query_row(queries::GET_DEVICE_BY_MAC, [mac], |row| row.get(0)).optional()?; let payload: Option<String> = conn
.query_row(queries::GET_DEVICE_BY_MAC, [mac], |row| row.get(0))
.optional()?;
payload.map(Self::from_json).transpose() payload.map(Self::from_json).transpose()
} }
@@ -66,5 +83,4 @@ impl Db {
tx.commit()?; tx.commit()?;
Ok(changed) Ok(changed)
} }
} }
+69 -18
View File
@@ -3,41 +3,76 @@ impl Db {
let conn = self.lock()?; let conn = self.lock()?;
conn.execute( conn.execute(
queries::INSERT_READING, queries::INSERT_READING,
params![reading.device_id, reading.timestamp.to_rfc3339(), reading.indoor_temperature, params![
reading.outdoor_temperature, reading.target_temperature, reading.power as i64, reading.source], reading.device_id,
reading.timestamp.to_rfc3339(),
reading.indoor_temperature,
reading.outdoor_temperature,
reading.target_temperature,
reading.power as i64,
reading.source
],
)?; )?;
Ok(conn.last_insert_rowid()) Ok(conn.last_insert_rowid())
} }
pub fn list_readings(&self, device_id: Option<&str>, since: DateTime<Utc>, limit: u32) -> Result<Vec<Reading>> { pub fn list_readings(
&self,
device_id: Option<&str>,
since: DateTime<Utc>,
limit: u32,
) -> Result<Vec<Reading>> {
let conn = self.lock()?; let conn = self.lock()?;
let limit = limit.clamp(1, 5000) as i64; let limit = limit.clamp(1, 5000) as i64;
let mut rows_out = Vec::new(); let mut rows_out = Vec::new();
if let Some(device_id) = device_id { if let Some(device_id) = device_id {
let mut stmt = conn.prepare(queries::LIST_READINGS_BY_DEVICE)?; let mut stmt = conn.prepare(queries::LIST_READINGS_BY_DEVICE)?;
let rows = stmt.query_map(params![device_id, since.to_rfc3339(), limit], Self::map_reading)?; let rows = stmt.query_map(
for row in rows { rows_out.push(row?); } params![device_id, since.to_rfc3339(), limit],
Self::map_reading,
)?;
for row in rows {
rows_out.push(row?);
}
} else { } else {
let mut stmt = conn.prepare(queries::LIST_READINGS_ALL)?; let mut stmt = conn.prepare(queries::LIST_READINGS_ALL)?;
let rows = stmt.query_map(params![since.to_rfc3339(), limit], Self::map_reading)?; let rows = stmt.query_map(params![since.to_rfc3339(), limit], Self::map_reading)?;
for row in rows { rows_out.push(row?); } for row in rows {
rows_out.push(row?);
}
} }
Ok(rows_out) Ok(rows_out)
} }
pub fn list_device_history(&self, device_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<Reading>> { pub fn list_device_history(
&self,
device_id: Option<&str>,
since: DateTime<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<Vec<Reading>> {
let conn = self.lock()?; let conn = self.lock()?;
let bucket_seconds = bucket_seconds.max(1); let bucket_seconds = bucket_seconds.max(1);
let limit = limit.clamp(1, 20_000) as i64; let limit = limit.clamp(1, 20_000) as i64;
let mut rows_out = Vec::new(); let mut rows_out = Vec::new();
if let Some(device_id) = device_id { if let Some(device_id) = device_id {
let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_BY_DEVICE_BUCKETED)?; let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_BY_DEVICE_BUCKETED)?;
let rows = stmt.query_map(params![device_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?; let rows = stmt.query_map(
for row in rows { rows_out.push(row?); } params![device_id, since.to_rfc3339(), bucket_seconds, limit],
Self::map_reading,
)?;
for row in rows {
rows_out.push(row?);
}
} else { } else {
let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_ALL_BUCKETED)?; let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_ALL_BUCKETED)?;
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?; let rows = stmt.query_map(
for row in rows { rows_out.push(row?); } params![since.to_rfc3339(), bucket_seconds, limit],
Self::map_reading,
)?;
for row in rows {
rows_out.push(row?);
}
} }
Ok(rows_out) Ok(rows_out)
} }
@@ -58,7 +93,11 @@ impl Db {
}) })
} }
pub fn history_before(&self, before: DateTime<Utc>, limit_per_family: u32) -> Result<(Vec<Reading>, Vec<ZoneReading>, Vec<HaReading>)> { pub fn history_before(
&self,
before: DateTime<Utc>,
limit_per_family: u32,
) -> Result<(Vec<Reading>, Vec<ZoneReading>, Vec<HaReading>)> {
let conn = self.lock()?; let conn = self.lock()?;
let limit = limit_per_family.clamp(1, 5_000) as i64; let limit = limit_per_family.clamp(1, 5_000) as i64;
let before = before.to_rfc3339(); let before = before.to_rfc3339();
@@ -81,13 +120,24 @@ impl Db {
Ok((devices, zones, ha)) Ok((devices, zones, ha))
} }
pub fn delete_history_batch(&self, devices: &[Reading], zones: &[ZoneReading], ha: &[HaReading]) -> Result<u64> { pub fn delete_history_batch(
&self,
devices: &[Reading],
zones: &[ZoneReading],
ha: &[HaReading],
) -> Result<u64> {
let mut conn = self.lock()?; let mut conn = self.lock()?;
let tx = conn.transaction()?; let tx = conn.transaction()?;
let mut changed = 0_u64; let mut changed = 0_u64;
for row in devices { changed += tx.execute(queries::DELETE_READING_BY_ID, [row.id])? as u64; } for row in devices {
for row in zones { changed += tx.execute(queries::DELETE_ZONE_READING_BY_ID, [row.id])? as u64; } changed += tx.execute(queries::DELETE_READING_BY_ID, [row.id])? as u64;
for row in ha { changed += tx.execute(queries::DELETE_HA_READING_BY_ID, [row.id])? as u64; } }
for row in zones {
changed += tx.execute(queries::DELETE_ZONE_READING_BY_ID, [row.id])? as u64;
}
for row in ha {
changed += tx.execute(queries::DELETE_HA_READING_BY_ID, [row.id])? as u64;
}
tx.commit()?; tx.commit()?;
Ok(changed) Ok(changed)
} }
@@ -114,7 +164,9 @@ impl Db {
(600_i64, one_day, seven_days), (600_i64, one_day, seven_days),
(1800_i64, seven_days, retention), (1800_i64, seven_days, retention),
] { ] {
if older_than <= newer_than { continue; } if older_than <= newer_than {
continue;
}
let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()]; let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()];
changed += conn.execute(queries::COMPACT_DEVICE_HISTORY, args)? as u64; changed += conn.execute(queries::COMPACT_DEVICE_HISTORY, args)? as u64;
let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()]; let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()];
@@ -125,5 +177,4 @@ impl Db {
conn.execute_batch("PRAGMA optimize;")?; conn.execute_batch("PRAGMA optimize;")?;
Ok(changed) Ok(changed)
} }
} }
+35 -13
View File
@@ -1,15 +1,30 @@
impl Db { impl Db {
pub fn history_counts(&self) -> Result<(u64, u64, u64)> { pub fn history_counts(&self) -> Result<(u64, u64, u64)> {
let conn = self.lock()?; let conn = self.lock()?;
let (device, zone, ha): (i64, i64, i64) = conn.query_row(queries::HISTORY_COUNTS, [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?; let (device, zone, ha): (i64, i64, i64) =
conn.query_row(queries::HISTORY_COUNTS, [], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})?;
Ok((device.max(0) as u64, zone.max(0) as u64, ha.max(0) as u64)) Ok((device.max(0) as u64, zone.max(0) as u64, ha.max(0) as u64))
} }
pub fn log_event(&self, level: &str, kind: &str, message: &str, metadata: &Value) -> Result<i64> { pub fn log_event(
&self,
level: &str,
kind: &str,
message: &str,
metadata: &Value,
) -> Result<i64> {
let conn = self.lock()?; let conn = self.lock()?;
conn.execute( conn.execute(
queries::INSERT_EVENT, queries::INSERT_EVENT,
params![Utc::now().to_rfc3339(), level, kind, message, serde_json::to_string(metadata)?], params![
Utc::now().to_rfc3339(),
level,
kind,
message,
serde_json::to_string(metadata)?
],
)?; )?;
Ok(conn.last_insert_rowid()) Ok(conn.last_insert_rowid())
} }
@@ -22,14 +37,17 @@ impl Db {
let metadata: String = row.get(5)?; let metadata: String = row.get(5)?;
Ok(EventLog { Ok(EventLog {
id: row.get(0)?, id: row.get(0)?,
timestamp: DateTime::parse_from_rfc3339(&ts).map(|v| v.with_timezone(&Utc)).unwrap_or_else(|_| Utc::now()), timestamp: DateTime::parse_from_rfc3339(&ts)
.map(|v| v.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now()),
level: row.get(2)?, level: row.get(2)?,
kind: row.get(3)?, kind: row.get(3)?,
message: row.get(4)?, message: row.get(4)?,
metadata: serde_json::from_str(&metadata).unwrap_or(Value::Null), metadata: serde_json::from_str(&metadata).unwrap_or(Value::Null),
}) })
})?; })?;
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Into::into) rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
} }
pub fn prune_events(&self, retention_days: i64) -> Result<u64> { pub fn prune_events(&self, retention_days: i64) -> Result<u64> {
@@ -52,25 +70,30 @@ impl Db {
.unwrap_or_else(|_| Utc::now()), .unwrap_or_else(|_| Utc::now()),
}) })
})?; })?;
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Into::into) rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
} }
pub fn save_api_token(&self, token: &ApiTokenInfo, token_hash: &str) -> Result<()> { pub fn save_api_token(&self, token: &ApiTokenInfo, token_hash: &str) -> Result<()> {
let conn = self.lock()?; let conn = self.lock()?;
conn.execute( conn.execute(
queries::INSERT_API_TOKEN, queries::INSERT_API_TOKEN,
params![token.id, token.name, token_hash, token.token_prefix, token.created_at.to_rfc3339()], params![
token.id,
token.name,
token_hash,
token.token_prefix,
token.created_at.to_rfc3339()
],
)?; )?;
Ok(()) Ok(())
} }
pub fn api_token_exists(&self, token_hash: &str) -> Result<bool> { pub fn api_token_exists(&self, token_hash: &str) -> Result<bool> {
let conn = self.lock()?; let conn = self.lock()?;
let found: Option<i64> = conn.query_row( let found: Option<i64> = conn
queries::API_TOKEN_EXISTS, .query_row(queries::API_TOKEN_EXISTS, [token_hash], |row| row.get(0))
[token_hash], .optional()?;
|row| row.get(0),
).optional()?;
Ok(found.is_some()) Ok(found.is_some())
} }
@@ -78,5 +101,4 @@ impl Db {
let conn = self.lock()?; let conn = self.lock()?;
Ok(conn.execute(queries::DELETE_API_TOKEN, [id])? > 0) Ok(conn.execute(queries::DELETE_API_TOKEN, [id])? > 0)
} }
} }
+23 -4
View File
@@ -7,21 +7,40 @@ impl Db {
self.get_payload(queries::GET_FLOW, id) self.get_payload(queries::GET_FLOW, id)
} }
pub fn replace_flow_outputs(&self, flow: &Flow, schedules: &[Schedule], automations: &[Automation]) -> Result<()> { pub fn replace_flow_outputs(
&self,
flow: &Flow,
schedules: &[Schedule],
automations: &[Automation],
) -> Result<()> {
let mut conn = self.lock()?; let mut conn = self.lock()?;
let tx = conn.transaction()?; let tx = conn.transaction()?;
tx.execute(queries::DELETE_SCHEDULES_BY_FLOW_ID, [flow.id.as_str()])?; tx.execute(queries::DELETE_SCHEDULES_BY_FLOW_ID, [flow.id.as_str()])?;
tx.execute(queries::DELETE_AUTOMATIONS_BY_FLOW_ID, [flow.id.as_str()])?; tx.execute(queries::DELETE_AUTOMATIONS_BY_FLOW_ID, [flow.id.as_str()])?;
for schedule in schedules { for schedule in schedules {
let payload = Self::to_json(schedule)?; let payload = Self::to_json(schedule)?;
tx.execute(queries::UPSERT_SCHEDULE, params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()])?; tx.execute(
queries::UPSERT_SCHEDULE,
params![
schedule.id,
schedule.zone_id,
payload,
schedule.updated_at.to_rfc3339()
],
)?;
} }
for item in automations { for item in automations {
let payload = Self::to_json(item)?; let payload = Self::to_json(item)?;
tx.execute(queries::UPSERT_AUTOMATION, params![item.id, payload, item.updated_at.to_rfc3339()])?; tx.execute(
queries::UPSERT_AUTOMATION,
params![item.id, payload, item.updated_at.to_rfc3339()],
)?;
} }
let payload = Self::to_json(flow)?; let payload = Self::to_json(flow)?;
tx.execute(queries::UPSERT_FLOW, params![flow.id, payload, flow.updated_at.to_rfc3339()])?; tx.execute(
queries::UPSERT_FLOW,
params![flow.id, payload, flow.updated_at.to_rfc3339()],
)?;
tx.commit()?; tx.commit()?;
Ok(()) Ok(())
} }
+26 -7
View File
@@ -1,5 +1,9 @@
impl Db { impl Db {
pub fn add_ha_reading_if_due(&self, reading: &HaReading, min_interval_seconds: i64) -> Result<bool> { pub fn add_ha_reading_if_due(
&self,
reading: &HaReading,
min_interval_seconds: i64,
) -> Result<bool> {
let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1)); let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1));
let conn = self.lock()?; let conn = self.lock()?;
let changed = conn.execute( let changed = conn.execute(
@@ -16,19 +20,35 @@ impl Db {
Ok(changed > 0) Ok(changed > 0)
} }
pub fn list_ha_history(&self, entity_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<HaReading>> { pub fn list_ha_history(
&self,
entity_id: Option<&str>,
since: DateTime<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<Vec<HaReading>> {
let conn = self.lock()?; let conn = self.lock()?;
let bucket_seconds = bucket_seconds.max(1); let bucket_seconds = bucket_seconds.max(1);
let limit = limit.clamp(1, 20_000) as i64; let limit = limit.clamp(1, 20_000) as i64;
let mut rows_out = Vec::new(); let mut rows_out = Vec::new();
if let Some(entity_id) = entity_id { if let Some(entity_id) = entity_id {
let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_BY_ENTITY_BUCKETED)?; let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_BY_ENTITY_BUCKETED)?;
let rows = stmt.query_map(params![entity_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?; let rows = stmt.query_map(
for row in rows { rows_out.push(row?); } params![entity_id, since.to_rfc3339(), bucket_seconds, limit],
Self::map_ha_reading,
)?;
for row in rows {
rows_out.push(row?);
}
} else { } else {
let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_ALL_BUCKETED)?; let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_ALL_BUCKETED)?;
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?; let rows = stmt.query_map(
for row in rows { rows_out.push(row?); } params![since.to_rfc3339(), bucket_seconds, limit],
Self::map_ha_reading,
)?;
for row in rows {
rows_out.push(row?);
}
} }
Ok(rows_out) Ok(rows_out)
} }
@@ -46,5 +66,4 @@ impl Db {
temperature: row.get(5)?, temperature: row.get(5)?,
}) })
} }
} }
+14 -4
View File
@@ -4,7 +4,12 @@ impl Db {
let conn = self.lock()?; let conn = self.lock()?;
conn.execute( conn.execute(
queries::UPSERT_SCHEDULE, queries::UPSERT_SCHEDULE,
params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()], params![
schedule.id,
schedule.zone_id,
payload,
schedule.updated_at.to_rfc3339()
],
)?; )?;
Ok(()) Ok(())
} }
@@ -29,7 +34,12 @@ impl Db {
let payload = Self::to_json(schedule)?; let payload = Self::to_json(schedule)?;
tx.execute( tx.execute(
queries::UPSERT_SCHEDULE, queries::UPSERT_SCHEDULE,
params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()], params![
schedule.id,
schedule.zone_id,
payload,
schedule.updated_at.to_rfc3339()
],
)?; )?;
} }
tx.commit()?; tx.commit()?;
@@ -61,7 +71,8 @@ impl Db {
fn list_payloads<T: DeserializeOwned>(&self, sql: &str) -> Result<Vec<T>> { fn list_payloads<T: DeserializeOwned>(&self, sql: &str) -> Result<Vec<T>> {
let conn = self.lock()?; let conn = self.lock()?;
let mut stmt = conn.prepare(sql)?; let mut stmt = conn.prepare(sql)?;
let payloads = stmt.query_map([], |row| row.get::<_, String>(0))? let payloads = stmt
.query_map([], |row| row.get::<_, String>(0))?
.collect::<std::result::Result<Vec<_>, _>>()?; .collect::<std::result::Result<Vec<_>, _>>()?;
payloads.into_iter().map(Self::from_json).collect() payloads.into_iter().map(Self::from_json).collect()
} }
@@ -82,5 +93,4 @@ impl Db {
let conn = self.lock()?; let conn = self.lock()?;
Ok(conn.execute(sql, [id])? > 0) Ok(conn.execute(sql, [id])? > 0)
} }
} }
+67 -15
View File
@@ -13,10 +13,16 @@ mod tests {
let base = DateTime::<Utc>::from_timestamp(seconds, 0).unwrap(); let base = DateTime::<Utc>::from_timestamp(seconds, 0).unwrap();
for offset in [10_i64, 20_i64] { for offset in [10_i64, 20_i64] {
db.add_reading(&Reading { db.add_reading(&Reading {
id: 0, device_id: device.id.clone(), timestamp: base + Duration::seconds(offset), id: 0,
indoor_temperature: Some(22.0), outdoor_temperature: None, target_temperature: 23.0, device_id: device.id.clone(),
power: true, source: "gree".into(), timestamp: base + Duration::seconds(offset),
}).unwrap(); indoor_temperature: Some(22.0),
outdoor_temperature: None,
target_temperature: 23.0,
power: true,
source: "gree".into(),
})
.unwrap();
} }
assert_eq!(db.history_counts().unwrap().0, 2); assert_eq!(db.history_counts().unwrap().0, 2);
assert_eq!(db.compact_history(30).unwrap(), 1); assert_eq!(db.compact_history(30).unwrap(), 1);
@@ -32,11 +38,22 @@ mod tests {
let loaded = db.get_device(&device.id).unwrap().unwrap(); let loaded = db.get_device(&device.id).unwrap().unwrap();
assert_eq!(loaded.mac, device.mac); assert_eq!(loaded.mac, device.mac);
assert_eq!(db.list_devices().unwrap().len(), 1); assert_eq!(db.list_devices().unwrap().len(), 1);
db.log_event("info", "test", "ok", &serde_json::json!({"a":1})).unwrap(); db.log_event("info", "test", "ok", &serde_json::json!({"a":1}))
.unwrap();
assert_eq!(db.list_events(10).unwrap().len(), 1); assert_eq!(db.list_events(10).unwrap().len(), 1);
{ {
let conn = db.lock().unwrap(); let conn = db.lock().unwrap();
conn.execute(queries::INSERT_EVENT, rusqlite::params![(Utc::now() - Duration::days(40)).to_rfc3339(), "info", "old", "old", "{}"] ).unwrap(); conn.execute(
queries::INSERT_EVENT,
rusqlite::params![
(Utc::now() - Duration::days(40)).to_rfc3339(),
"info",
"old",
"old",
"{}"
],
)
.unwrap();
} }
assert_eq!(db.prune_events(30).unwrap(), 1); assert_eq!(db.prune_events(30).unwrap(), 1);
assert_eq!(db.list_events(10).unwrap().len(), 1); assert_eq!(db.list_events(10).unwrap().len(), 1);
@@ -55,16 +72,51 @@ mod tests {
let now = Utc::now(); let now = Utc::now();
db.add_reading(&Reading { db.add_reading(&Reading {
id: 0, device_id: device.id.clone(), timestamp: now.clone(), indoor_temperature: Some(22.5), id: 0,
outdoor_temperature: Some(31.0), target_temperature: 23.0, power: true, source: "gree".into(), device_id: device.id.clone(),
}).unwrap(); timestamp: now.clone(),
assert_eq!(db.list_device_history(Some(&device.id), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1); indoor_temperature: Some(22.5),
outdoor_temperature: Some(31.0),
target_temperature: 23.0,
power: true,
source: "gree".into(),
})
.unwrap();
assert_eq!(
db.list_device_history(
Some(&device.id),
now.clone() - Duration::minutes(1),
30,
100
)
.unwrap()
.len(),
1
);
db.add_ha_reading_if_due(&HaReading { db.add_ha_reading_if_due(
id: 0, entity_id: "sensor.room".into(), zone_id: Some("zone-room".into()), kind: "room".into(), &HaReading {
timestamp: now.clone(), temperature: 22.1, id: 0,
}, 15).unwrap(); entity_id: "sensor.room".into(),
assert_eq!(db.list_ha_history(Some("sensor.room"), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1); zone_id: Some("zone-room".into()),
kind: "room".into(),
timestamp: now.clone(),
temperature: 22.1,
},
15,
)
.unwrap();
assert_eq!(
db.list_ha_history(
Some("sensor.room"),
now.clone() - Duration::minutes(1),
30,
100
)
.unwrap()
.len(),
1
);
assert_eq!(db.history_counts().unwrap(), (1, 0, 1)); assert_eq!(db.history_counts().unwrap(), (1, 0, 1));
} }
} }
+26 -7
View File
@@ -1,5 +1,9 @@
impl Db { impl Db {
pub fn add_zone_reading_if_due(&self, reading: &ZoneReading, min_interval_seconds: i64) -> Result<bool> { pub fn add_zone_reading_if_due(
&self,
reading: &ZoneReading,
min_interval_seconds: i64,
) -> Result<bool> {
let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1)); let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1));
let conn = self.lock()?; let conn = self.lock()?;
let changed = conn.execute( let changed = conn.execute(
@@ -26,19 +30,35 @@ impl Db {
Ok(changed > 0) Ok(changed > 0)
} }
pub fn list_zone_history(&self, zone_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<ZoneReading>> { pub fn list_zone_history(
&self,
zone_id: Option<&str>,
since: DateTime<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<Vec<ZoneReading>> {
let conn = self.lock()?; let conn = self.lock()?;
let bucket_seconds = bucket_seconds.max(1); let bucket_seconds = bucket_seconds.max(1);
let limit = limit.clamp(1, 20_000) as i64; let limit = limit.clamp(1, 20_000) as i64;
let mut rows_out = Vec::new(); let mut rows_out = Vec::new();
if let Some(zone_id) = zone_id { if let Some(zone_id) = zone_id {
let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_BY_ZONE_BUCKETED)?; let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_BY_ZONE_BUCKETED)?;
let rows = stmt.query_map(params![zone_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_zone_reading)?; let rows = stmt.query_map(
for row in rows { rows_out.push(row?); } params![zone_id, since.to_rfc3339(), bucket_seconds, limit],
Self::map_zone_reading,
)?;
for row in rows {
rows_out.push(row?);
}
} else { } else {
let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_ALL_BUCKETED)?; let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_ALL_BUCKETED)?;
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_zone_reading)?; let rows = stmt.query_map(
for row in rows { rows_out.push(row?); } params![since.to_rfc3339(), bucket_seconds, limit],
Self::map_zone_reading,
)?;
for row in rows {
rows_out.push(row?);
}
} }
Ok(rows_out) Ok(rows_out)
} }
@@ -66,5 +86,4 @@ impl Db {
active_preset: row.get(15)?, active_preset: row.get(15)?,
}) })
} }
} }
+15 -9
View File
@@ -1,16 +1,22 @@
use std::{collections::HashMap, sync::atomic::Ordering, time::{Duration, Instant}}; use crate::{
error::AppError,
home_assistant, influxdb,
models::{
Automation, AutomationPlanRule, ClimateGroup, ControlPlan, ControlPlanEvent, Device,
DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, RuntimeSettings,
Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading,
},
state::{AppState, PendingControllerCommand},
};
use anyhow::Result; use anyhow::Result;
use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday}; use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday};
use serde_json::{json, Value}; use serde_json::{json, Value};
use tokio::time::sleep; use std::{
use crate::{ collections::HashMap,
error::AppError, sync::atomic::Ordering,
home_assistant, time::{Duration, Instant},
influxdb,
models::{Automation, AutomationPlanRule, ClimateGroup, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading},
state::{AppState, PendingControllerCommand},
}; };
use tokio::time::sleep;
// Functional source split intentionally keeps items in the existing module namespace. // Functional source split intentionally keeps items in the existing module namespace.
include!("engine/runtime.rs"); include!("engine/runtime.rs");
+118 -43
View File
@@ -1,8 +1,16 @@
async fn send_command_locked(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> { async fn send_command_locked(
state: &AppState,
device_id: &str,
command: DeviceCommand,
) -> Result<Device, AppError> {
send_command_locked_inner(state, device_id, command, true, true).await send_command_locked_inner(state, device_id, command, true, true).await
} }
async fn send_command_locked_forced(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> { async fn send_command_locked_forced(
state: &AppState,
device_id: &str,
command: DeviceCommand,
) -> Result<Device, AppError> {
send_command_locked_inner(state, device_id, command, false, true).await send_command_locked_inner(state, device_id, command, false, true).await
} }
@@ -14,14 +22,24 @@ async fn send_command_locked_inner(
track_controller_command: bool, track_controller_command: bool,
) -> Result<Device, AppError> { ) -> Result<Device, AppError> {
validate_command(&command)?; validate_command(&command)?;
let mut device = state.db.get_device(device_id)? let mut device = state
.db
.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); } if !device.enabled {
return Err(AppError::BadRequest("device is disabled".into()));
}
// Routine control avoids redundant frames. Explicit safety transitions (global/group OFF, // Routine control avoids redundant frames. Explicit safety transitions (global/group OFF,
// detach) may bypass cache de-duplication so stale state cannot leave a unit powered. // detach) may bypass cache de-duplication so stale state cannot leave a unit powered.
let command = if dedupe_against_cache && device.online && device.communication_failures == 0 { command.changed_from(&device) } else { command }; let command = if dedupe_against_cache && device.online && device.communication_failures == 0 {
if command.is_empty() { return Ok(device); } command.changed_from(&device)
} else {
command
};
if command.is_empty() {
return Ok(device);
}
let controller_command_baseline = device.clone(); let controller_command_baseline = device.clone();
let suppress_beep = state.settings.read().await.suppress_device_beep; let suppress_beep = state.settings.read().await.suppress_device_beep;
let response_started = Instant::now(); let response_started = Instant::now();
@@ -70,20 +88,21 @@ async fn send_command_locked_inner(
Ok(()) => { Ok(()) => {
device = observed; device = observed;
let remaining = command.changed_from(&device); let remaining = command.changed_from(&device);
if remaining.is_empty() { Ok(command.clone()) } if remaining.is_empty() {
else { state.gree.command(&device, &remaining, suppress_beep).await } Ok(command.clone())
} } else {
Err(_) => { state.gree.command(&device, &remaining, suppress_beep).await
match state.gree.bind(&device).await {
Ok(bound) => {
device.key = Some(bound.key);
device.protocol_version = bound.protocol_version;
state.db.save_device(&device)?;
state.gree.command(&device, &command, suppress_beep).await
}
Err(_) => Err(first_err),
} }
} }
Err(_) => match state.gree.bind(&device).await {
Ok(bound) => {
device.key = Some(bound.key);
device.protocol_version = bound.protocol_version;
state.db.save_device(&device)?;
state.gree.command(&device, &command, suppress_beep).await
}
Err(_) => Err(first_err),
},
}; };
match retry_result { match retry_result {
Ok(result) => applied_command = result, Ok(result) => applied_command = result,
@@ -94,8 +113,12 @@ async fn send_command_locked_inner(
} }
} }
} }
if command.quiet.is_some() && applied_command.quiet.is_none() { device.supports_quiet = Some(false); } if command.quiet.is_some() && applied_command.quiet.is_none() {
if command.sleep.is_some() && applied_command.sleep.is_none() { device.supports_sleep = Some(false); } device.supports_quiet = Some(false);
}
if command.sleep.is_some() && applied_command.sleep.is_none() {
device.supports_sleep = Some(false);
}
// A command ACK confirms transport/acceptance, but several GREE firmwares keep // A command ACK confirms transport/acceptance, but several GREE firmwares keep
// returning the pre-command status for a short settling window. Publishing that first // returning the pre-command status for a short settling window. Publishing that first
@@ -105,7 +128,9 @@ async fn send_command_locked_inner(
let verification_delays_ms = [0_u64, 150, 350, 650]; let verification_delays_ms = [0_u64, 150, 350, 650];
let mut last_verification_error: Option<String> = None; let mut last_verification_error: Option<String> = None;
for delay_ms in verification_delays_ms { for delay_ms in verification_delays_ms {
if delay_ms > 0 { sleep(Duration::from_millis(delay_ms)).await; } if delay_ms > 0 {
sleep(Duration::from_millis(delay_ms)).await;
}
let mut observed = device.clone(); let mut observed = device.clone();
match state.gree.poll(&mut observed).await { match state.gree.poll(&mut observed).await {
Ok(()) => { Ok(()) => {
@@ -114,7 +139,9 @@ async fn send_command_locked_inner(
confirmed_state = true; confirmed_state = true;
confirmed_requested_state = requested_matches; confirmed_requested_state = requested_matches;
last_verification_error = None; last_verification_error = None;
if requested_matches { break; } if requested_matches {
break;
}
} }
Err(err) => { Err(err) => {
last_verification_error = Some(err.to_string()); last_verification_error = Some(err.to_string());
@@ -125,23 +152,49 @@ async fn send_command_locked_inner(
if confirmed_state && !confirmed_requested_state { if confirmed_state && !confirmed_requested_state {
tracing::debug!(device=%device.id, command=?applied_command, "GREE command acknowledged but status still differs after settling window"); tracing::debug!(device=%device.id, command=?applied_command, "GREE command acknowledged but status still differs after settling window");
} else if !confirmed_state { } else if !confirmed_state {
let error = last_verification_error.unwrap_or_else(|| "status verification failed".into()); let error =
record_poll_failure(&mut device, &format!("command accepted but status verification failed: {error}")); last_verification_error.unwrap_or_else(|| "status verification failed".into());
state.log("warn", "device.command_unconfirmed", &format!("Command accepted by {}, but resulting state could not be verified", device.name), json!({ record_poll_failure(
"device_id": device.id, "error": error &mut device,
})); &format!("command accepted but status verification failed: {error}"),
);
state.log(
"warn",
"device.command_unconfirmed",
&format!(
"Command accepted by {}, but resulting state could not be verified",
device.name
),
json!({
"device_id": device.id, "error": error
}),
);
} }
} }
if confirmed_state { if confirmed_state {
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64); device.response_time_ms =
Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
} }
state.db.save_device(&device)?; state.db.save_device(&device)?;
if !dedupe_against_cache && confirmed_state && !confirmed_requested_state { if !dedupe_against_cache && confirmed_state && !confirmed_requested_state {
if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() { if track_controller_command
remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await; && !command_manual_control_fields(&applied_command).is_empty()
{
remember_controller_command(
state,
device_id,
&applied_command,
&controller_command_baseline,
)
.await;
} }
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); state.broadcast(
return Err(AppError::Device("device did not confirm the requested forced state change".into())); "device.updated",
serde_json::to_value(&device).unwrap_or_default(),
);
return Err(AppError::Device(
"device did not confirm the requested forced state change".into(),
));
} }
} }
@@ -150,32 +203,54 @@ async fn send_command_locked_inner(
// observed once. Several GREE modules can briefly publish an older snapshot again // observed once. Several GREE modules can briefly publish an older snapshot again
// and then return to the controller-requested state. Without this guard that normal // and then return to the controller-requested state. Without this guard that normal
// firmware bounce can be misclassified as a physical/pilot takeover. // firmware bounce can be misclassified as a physical/pilot takeover.
remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await; remember_controller_command(
state,
device_id,
&applied_command,
&controller_command_baseline,
)
.await;
} }
record_device_transition_timestamps(state, &controller_command_baseline, &device)?; record_device_transition_timestamps(state, &controller_command_baseline, &device)?;
state.log("info", "device.command", &format!("Updated {}", device.name), json!({ state.log(
"device_id": device.id, "info",
"command": applied_command, "device.command",
"confirmed": confirmed_state, &format!("Updated {}", device.name),
})); json!({
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); "device_id": device.id,
"command": applied_command,
"confirmed": confirmed_state,
}),
);
state.broadcast(
"device.updated",
serde_json::to_value(&device).unwrap_or_default(),
);
Ok(device) Ok(device)
} }
fn record_device_transition_timestamps(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> { fn record_device_transition_timestamps(
state: &AppState,
before: &Device,
after: &Device,
) -> Result<(), AppError> {
let power_changed = before.power != after.power; let power_changed = before.power != after.power;
let mode_changed = before.mode != after.mode; let mode_changed = before.mode != after.mode;
if !power_changed && !mode_changed { return Ok(()); } if !power_changed && !mode_changed {
return Ok(());
}
// This function is often called while the device lock is held, so acquiring a zone lock // This function is often called while the device lock is held, so acquiring a zone lock
// here would invert the global zone -> device order. Merge only these timestamp fields // here would invert the global zone -> device order. Merge only these timestamp fields
// with a DB compare-and-swap instead of saving a stale whole-zone snapshot. // with a DB compare-and-swap instead of saving a stale whole-zone snapshot.
for zone in state.db.merge_zone_device_transition_timestamps( for zone in state.db.merge_zone_device_transition_timestamps(
&after.id, power_changed, mode_changed, Utc::now(), &after.id,
power_changed,
mode_changed,
Utc::now(),
)? { )? {
state.broadcast("zone.updated", serde_json::to_value(&zone)?); state.broadcast("zone.updated", serde_json::to_value(&zone)?);
} }
Ok(()) Ok(())
} }
+158 -40
View File
@@ -4,10 +4,15 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
let devices = state.db.list_devices()?; let devices = state.db.list_devices()?;
let zones = state.db.list_zones()?; let zones = state.db.list_zones()?;
let groups = state.db.list_groups()?; let groups = state.db.list_groups()?;
let zone_names: HashMap<String, String> = zones.iter().map(|zone| (zone.id.clone(), zone.name.clone())).collect(); let zone_names: HashMap<String, String> = zones
.iter()
.map(|zone| (zone.id.clone(), zone.name.clone()))
.collect();
let house_preset = zones.first().and_then(|first| { let house_preset = zones.first().and_then(|first| {
let first_preset = first.manual_preset.as_deref().unwrap_or("auto"); 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) zones
.iter()
.all(|zone| zone.manual_preset.as_deref().unwrap_or("auto") == first_preset)
.then(|| first_preset.to_string()) .then(|| first_preset.to_string())
}); });
let house_power = true; let house_power = true;
@@ -21,9 +26,14 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
let configured_effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode); let configured_effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
refresh_control_ownership(&mut zone); refresh_control_ownership(&mut zone);
let configured_effective_mode = configured_effective_mode_owned.as_str(); let configured_effective_mode = configured_effective_mode_owned.as_str();
let target_mode = if configured_effective_mode == "off" { zone.mode.as_str() } else { configured_effective_mode }; 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 active_for_target = active_schedule_for_zone(&zone, &schedules, now);
let has_intent_source = zone_has_thermostat_intent_source(&zone, active_for_target, Utc::now()); let has_intent_source =
zone_has_thermostat_intent_source(&zone, active_for_target, Utc::now());
let automation_idle = zone.enabled let automation_idle = zone.enabled
&& !zone.device_manual_override && !zone.device_manual_override
&& zone.local_thermostat_power != Some(false) && zone.local_thermostat_power != Some(false)
@@ -34,7 +44,13 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
&& zone.local_thermostat_power != Some(false) && zone.local_thermostat_power != Some(false)
&& configured_effective_mode != "off" && configured_effective_mode != "off"
&& has_intent_source; && has_intent_source;
let manual_device_mode = device.map(|item| if item.power { item.mode.as_str() } else { "off" }); let manual_device_mode = device.map(|item| {
if item.power {
item.mode.as_str()
} else {
"off"
}
});
let effective_mode = if zone.device_manual_override { let effective_mode = if zone.device_manual_override {
manual_device_mode.unwrap_or(configured_effective_mode) manual_device_mode.unwrap_or(configured_effective_mode)
} else if automatic_active { } else if automatic_active {
@@ -43,8 +59,13 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
"off" "off"
}; };
let (resolved_preset, resolved_target) = resolve_zone_target(&zone, active_for_target, target_mode); let (resolved_preset, resolved_target) =
let active = if automatic_active { active_for_target } else { None }; resolve_zone_target(&zone, active_for_target, target_mode);
let active = if automatic_active {
active_for_target
} else {
None
};
// Future schedule events remain visible while the zone is currently idle between // Future schedule events remain visible while the zone is currently idle between
// windows; idle must not be confused with a disabled schedule system. // windows; idle must not be confused with a disabled schedule system.
let next_events = if configured_effective_mode == "off" { let next_events = if configured_effective_mode == "off" {
@@ -57,19 +78,24 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
event.label = format!("{}: {}", zone.name, event.label); event.label = format!("{}: {}", zone.name, event.label);
house_events.push(event); house_events.push(event);
} }
let effective_enabled = zone.enabled let effective_enabled = zone.enabled && zone.local_thermostat_power != Some(false);
&& zone.local_thermostat_power != Some(false);
zones_out.push(ZoneControlPlan { zones_out.push(ZoneControlPlan {
zone_id: zone.id.clone(), zone_id: zone.id.clone(),
zone_name: zone.name.clone(), zone_name: zone.name.clone(),
device_id: zone.device_id.clone(), device_id: zone.device_id.clone(),
device_name: device.map(|item| item.name.clone()).unwrap_or_else(|| 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,
effective_enabled, effective_enabled,
mode: effective_mode.to_string(), mode: effective_mode.to_string(),
configured_mode: zone.mode.clone(), configured_mode: zone.mode.clone(),
inherit_house_mode: zone.inherit_house_mode, inherit_house_mode: zone.inherit_house_mode,
preset: if has_intent_source { resolved_preset } else { "auto".into() }, preset: if has_intent_source {
resolved_preset
} else {
"auto".into()
},
preset_override: zone.manual_preset.clone(), preset_override: zone.manual_preset.clone(),
current_temperature: zone.current_temperature, current_temperature: zone.current_temperature,
target_temperature: if !has_intent_source { target_temperature: if !has_intent_source {
@@ -81,12 +107,22 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
} else { } else {
zone.effective_setpoint.or(Some(resolved_target)) zone.effective_setpoint.or(Some(resolved_target))
}, },
device_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature), device_setpoint: device
.filter(|item| item.power)
.map(|item| item.target_temperature),
desired_power: automatic_active, desired_power: automatic_active,
desired_mode: effective_mode.to_string(), desired_mode: effective_mode.to_string(),
actual_power: device.map(|item| item.power), actual_power: device.map(|item| item.power),
actual_mode: device.map(|item| if item.power { item.mode.clone() } else { "off".into() }), actual_mode: device.map(|item| {
actual_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature), if item.power {
item.mode.clone()
} else {
"off".into()
}
}),
actual_setpoint: device
.filter(|item| item.power)
.map(|item| item.target_temperature),
demand: automatic_active && zone.demand, demand: automatic_active && zone.demand,
control_source: zone.control_temperature_source.clone(), control_source: zone.control_temperature_source.clone(),
manual_override_until: zone.manual_override_until, manual_override_until: zone.manual_override_until,
@@ -103,7 +139,30 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
} else { } else {
zone.control_reason.clone() zone.control_reason.clone()
}, },
blocked_reason: if zone.device_manual_override { Some("manual_override".into()) } else if zone.lockout_until.map(|until| until > Utc::now()).unwrap_or(false) { Some(zone.lockout_reason.clone().unwrap_or_else(|| "lockout".into())) } else if !zone.enabled { Some("zone_disabled".into()) } else if device.map(|d| !d.online || d.communication_failures > 0).unwrap_or(true) { Some("offline".into()) } else if automation_idle { Some("no_active_thermostat_intent".into()) } else { None }, blocked_reason: if zone.device_manual_override {
Some("manual_override".into())
} else if zone
.lockout_until
.map(|until| until > Utc::now())
.unwrap_or(false)
{
Some(
zone.lockout_reason
.clone()
.unwrap_or_else(|| "lockout".into()),
)
} else if !zone.enabled {
Some("zone_disabled".into())
} else if device
.map(|d| !d.online || d.communication_failures > 0)
.unwrap_or(true)
{
Some("offline".into())
} else if automation_idle {
Some("no_active_thermostat_intent".into())
} else {
None
},
lockout_until: zone.lockout_until, lockout_until: zone.lockout_until,
current_schedule_id: active.map(|item| item.id.clone()), current_schedule_id: active.map(|item| item.id.clone()),
current_schedule_name: active.map(|item| item.name.clone()), current_schedule_name: active.map(|item| item.name.clone()),
@@ -112,19 +171,33 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
} }
let mut rules = Vec::new(); let mut rules = Vec::new();
for item in state.db.list_automations()? { for item in state.db.list_automations()? {
let action_zone_name = item.action_zone_id.as_deref() let action_zone_name = item
.action_zone_id
.as_deref()
.and_then(|id| zone_names.get(id)) .and_then(|id| zone_names.get(id))
.cloned(); .cloned();
let action_group_name = item.action_group_id.as_deref() let action_group_name = item
.action_group_id
.as_deref()
.and_then(|id| groups.iter().find(|group| group.id == id)) .and_then(|id| groups.iter().find(|group| group.id == id))
.map(|group| group.name.clone()); .map(|group| group.name.clone());
let action_name = action_zone_name.or_else(|| action_group_name.clone()).unwrap_or_else(|| { let action_name = action_zone_name
devices.iter().find(|device| device.id == item.action_device_id) .or_else(|| action_group_name.clone())
.map(|device| device.name.clone()) .unwrap_or_else(|| {
.unwrap_or_else(|| item.action_device_id.clone()) devices
}); .iter()
let trigger_name = item.trigger_device_id.as_deref().and_then(|id| devices.iter().find(|device| device.id == id)).map(|device| device.name.clone()); .find(|device| device.id == item.action_device_id)
let next_ready_at = item.last_fired_at.map(|last| last + chrono::Duration::seconds(item.cooldown_seconds as i64)); .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" { if item.enabled && item.trigger_kind == "time" {
if let Some(event) = next_time_automation_event(&item, &action_name, now) { if let Some(event) = next_time_automation_event(&item, &action_name, now) {
house_events.push(event); house_events.push(event);
@@ -170,11 +243,20 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
}) })
} }
fn next_night_mode_events(
fn next_night_mode_events(settings: &NightModeSettings, now: DateTime<Local>, limit: usize) -> Vec<ControlPlanEvent> { settings: &NightModeSettings,
if !settings.enabled || limit == 0 { return Vec::new(); } now: DateTime<Local>,
let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return Vec::new(); }; limit: usize,
let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return Vec::new(); }; ) -> Vec<ControlPlanEvent> {
if !settings.enabled || limit == 0 {
return Vec::new();
}
let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else {
return Vec::new();
};
let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else {
return Vec::new();
};
let mut events = Vec::new(); let mut events = Vec::new();
let base = minute_floor(now); let base = minute_floor(now);
for minute in 1..=(48 * 60) { for minute in 1..=(48 * 60) {
@@ -182,7 +264,14 @@ fn next_night_mode_events(settings: &NightModeSettings, now: DateTime<Local>, li
let time = candidate.time(); let time = candidate.time();
let (kind, label) = if time.hour() == start.hour() && time.minute() == start.minute() { let (kind, label) = if time.hour() == start.hour() && time.minute() == start.minute() {
let quiet = if settings.force_quiet { " + Quiet" } else { "" }; let quiet = if settings.force_quiet { " + Quiet" } else { "" };
("night_mode_start", format!("Night mode -> fan max {}{}", settings.max_fan_speed.clamp(1, 5), quiet)) (
"night_mode_start",
format!(
"Night mode -> fan max {}{}",
settings.max_fan_speed.clamp(1, 5),
quiet
),
)
} else if time.hour() == end.hour() && time.minute() == end.minute() { } else if time.hour() == end.hour() && time.minute() == end.minute() {
("night_mode_end", "Night mode ends".to_string()) ("night_mode_end", "Night mode ends".to_string())
} else { } else {
@@ -195,12 +284,18 @@ fn next_night_mode_events(settings: &NightModeSettings, now: DateTime<Local>, li
preset: None, preset: None,
target_temperature: None, target_temperature: None,
}); });
if events.len() >= limit { break; } if events.len() >= limit {
break;
}
} }
events events
} }
fn next_time_automation_event(item: &Automation, action_name: &str, now: DateTime<Local>) -> Option<ControlPlanEvent> { fn next_time_automation_event(
item: &Automation,
action_name: &str,
now: DateTime<Local>,
) -> Option<ControlPlanEvent> {
let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?; let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?;
let base = minute_floor(now.clone()); let base = minute_floor(now.clone());
if time_automation_due(item, now) { if time_automation_due(item, now) {
@@ -228,8 +323,16 @@ fn next_time_automation_event(item: &Automation, action_name: &str, now: DateTim
None None
} }
fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: DateTime<Local>, limit: usize) -> Vec<ControlPlanEvent> { fn next_schedule_events(
if mode == "off" { return Vec::new(); } zone: &Zone,
schedules: &[Schedule],
mode: &str,
now: DateTime<Local>,
limit: usize,
) -> Vec<ControlPlanEvent> {
if mode == "off" {
return Vec::new();
}
let mut events = Vec::new(); let mut events = Vec::new();
let mut current = active_schedule_for_zone(zone, schedules, now).map(|item| item.id.as_str()); let mut current = active_schedule_for_zone(zone, schedules, now).map(|item| item.id.as_str());
let base = minute_floor(now); let base = minute_floor(now);
@@ -237,14 +340,28 @@ fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: Da
let candidate = base + chrono::Duration::minutes(minute); let candidate = base + chrono::Duration::minutes(minute);
let next = active_schedule_for_zone(zone, schedules, candidate); let next = active_schedule_for_zone(zone, schedules, candidate);
let next_id = next.map(|item| item.id.as_str()); let next_id = next.map(|item| item.id.as_str());
if next_id == current { continue; } if next_id == current {
continue;
}
current = next_id; current = next_id;
let (preset, target, label) = if let Some(item) = next { let (preset, target, label) = if let Some(item) = next {
let target = if item.preset == "custom" { item.setpoint } else { profile_setpoint(zone, &item.preset, mode) }; let target = if item.preset == "custom" {
(Some(item.preset.clone()), Some(target), format!("{} -> {} {:.1} C", item.name, item.preset, target)) item.setpoint
} else {
profile_setpoint(zone, &item.preset, mode)
};
(
Some(item.preset.clone()),
Some(target),
format!("{} -> {} {:.1} C", item.name, item.preset, target),
)
} else { } else {
let target = profile_setpoint(zone, "comfort", mode); let target = profile_setpoint(zone, "comfort", mode);
(Some("comfort".into()), Some(target), format!("comfort {:.1} C", target)) (
Some("comfort".into()),
Some(target),
format!("comfort {:.1} C", target),
)
}; };
events.push(ControlPlanEvent { events.push(ControlPlanEvent {
at: candidate.with_timezone(&Utc), at: candidate.with_timezone(&Utc),
@@ -253,8 +370,9 @@ fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: Da
preset, preset,
target_temperature: target, target_temperature: target,
}); });
if events.len() >= limit { break; } if events.len() >= limit {
break;
}
} }
events events
} }
+27 -9
View File
@@ -1,10 +1,14 @@
fn next_time_automation_utc(item: &Automation, now: DateTime<Local>) -> Option<DateTime<Utc>> { fn next_time_automation_utc(item: &Automation, now: DateTime<Local>) -> Option<DateTime<Utc>> {
if !item.enabled || item.trigger_kind != "time" { return None; } if !item.enabled || item.trigger_kind != "time" {
return None;
}
let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?; let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?;
let minute_floor = now.with_second(0)?.with_nanosecond(0)?; let minute_floor = now.with_second(0)?.with_nanosecond(0)?;
for offset in 0..=(24 * 60) { for offset in 0..=(24 * 60) {
let candidate = minute_floor + chrono::Duration::minutes(offset); let candidate = minute_floor + chrono::Duration::minutes(offset);
if candidate <= now { continue; } if candidate <= now {
continue;
}
if candidate.hour() == expected.hour() && candidate.minute() == expected.minute() { if candidate.hour() == expected.hour() && candidate.minute() == expected.minute() {
return Some(candidate.with_timezone(&Utc)); return Some(candidate.with_timezone(&Utc));
} }
@@ -22,25 +26,39 @@ fn next_zone_control_deadline_delay(state: &AppState) -> Result<Option<Duration>
for zone in &zones { for zone in &zones {
if local_thermostat_handback_is_active(zone) { if local_thermostat_handback_is_active(zone) {
if let Some(at) = zone.local_thermostat_resume_at.clone() { deadlines.push(at); } if let Some(at) = zone.local_thermostat_resume_at.clone() {
deadlines.push(at);
}
} }
if zone.compressor_pending_action.is_some() { if zone.compressor_pending_action.is_some() {
if let Some(at) = zone.compressor_pending_until.clone().or(zone.lockout_until.clone()) { deadlines.push(at); } if let Some(at) = zone
.compressor_pending_until
.clone()
.or(zone.lockout_until.clone())
{
deadlines.push(at);
}
}
if let Some(at) = temporary_quick_thermostat_wakeup_at(zone, now.clone()) {
deadlines.push(at);
}
if let Some(at) = next_schedule_boundary_utc(&zone.id, &schedules, local_now.clone()) {
deadlines.push(at);
} }
if let Some(at) = temporary_quick_thermostat_wakeup_at(zone, now.clone()) { deadlines.push(at); }
if let Some(at) = next_schedule_boundary_utc(&zone.id, &schedules, local_now.clone()) { deadlines.push(at); }
} }
for automation in &automations { for automation in &automations {
if let Some(at) = next_time_automation_utc(automation, local_now.clone()) { deadlines.push(at); } if let Some(at) = next_time_automation_utc(automation, local_now.clone()) {
deadlines.push(at);
}
} }
// Ignore already-expired deadlines here. The control cycle that just ran had the // Ignore already-expired deadlines here. The control cycle that just ran had the
// opportunity to consume them; if another prerequisite (offline sensor/device, manual // opportunity to consume them; if another prerequisite (offline sensor/device, manual
// ownership, etc.) prevents execution, the normal thermostat interval should retry // ownership, etc.) prevents execution, the normal thermostat interval should retry
// instead of creating a zero-delay busy loop. // instead of creating a zero-delay busy loop.
Ok(deadlines.into_iter() Ok(deadlines
.into_iter()
.filter(|at| at > &now) .filter(|at| at > &now)
.filter_map(|at| (at - now.clone()).to_std().ok()) .filter_map(|at| (at - now.clone()).to_std().ok())
.min()) .min())
} }
+144 -40
View File
@@ -1,36 +1,58 @@
fn validate_group_control_patch(patch: &GroupControlPatch) -> Result<Option<f64>, AppError> { fn validate_group_control_patch(patch: &GroupControlPatch) -> Result<Option<f64>, AppError> {
if let Some(mode) = patch.mode.as_deref() { if let Some(mode) = patch.mode.as_deref() {
if !matches!(mode, "house" | "auto" | "cool" | "heat") { if !matches!(mode, "house" | "auto" | "cool" | "heat") {
return Err(AppError::BadRequest("group mode must be house, cool or heat".into())); return Err(AppError::BadRequest(
"group mode must be house, cool or heat".into(),
));
} }
} }
if let Some(preset) = patch.preset.as_deref() { if let Some(preset) = patch.preset.as_deref() {
if !matches!(preset, "auto" | "comfort" | "sleep" | "away" | "custom") { if !matches!(preset, "auto" | "comfort" | "sleep" | "away" | "custom") {
return Err(AppError::BadRequest("group preset must be auto, comfort, sleep, away or custom".into())); return Err(AppError::BadRequest(
"group preset must be auto, comfort, sleep, away or custom".into(),
));
} }
} }
if patch.preset.as_deref() == Some("custom") && patch.setpoint.is_none() { if patch.preset.as_deref() == Some("custom") && patch.setpoint.is_none() {
return Err(AppError::BadRequest("group custom preset requires a setpoint".into())); return Err(AppError::BadRequest(
"group custom preset requires a setpoint".into(),
));
} }
if let Some(setpoint) = patch.setpoint { if let Some(setpoint) = patch.setpoint {
if !(8.0..=30.0).contains(&setpoint) { if !(8.0..=30.0).contains(&setpoint) {
return Err(AppError::BadRequest("group setpoint must be between 8 and 30 C".into())); return Err(AppError::BadRequest(
"group setpoint must be between 8 and 30 C".into(),
));
} }
if patch.preset.as_deref() != Some("custom") { if patch.preset.as_deref() != Some("custom") {
return Err(AppError::BadRequest("group setpoint requires preset=custom".into())); return Err(AppError::BadRequest(
"group setpoint requires preset=custom".into(),
));
} }
} }
Ok(patch.setpoint.map(|value| (value * 10.0).round() / 10.0)) Ok(patch.setpoint.map(|value| (value * 10.0).round() / 10.0))
} }
fn defer_group_climate_change(zone: &mut Zone, patch: &GroupControlPatch, custom_setpoint: Option<f64>) { fn defer_group_climate_change(
let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return; }; zone: &mut Zone,
if let Some(mode) = patch.mode.as_deref() { session.deferred_mode = Some(mode.to_string()); } patch: &GroupControlPatch,
custom_setpoint: Option<f64>,
) {
let Some(session) = zone.temporary_quick_thermostat.as_mut() else {
return;
};
if let Some(mode) = patch.mode.as_deref() {
session.deferred_mode = Some(mode.to_string());
}
if let Some(preset) = patch.preset.as_deref() { if let Some(preset) = patch.preset.as_deref() {
session.deferred_preset = Some(preset.to_string()); session.deferred_preset = Some(preset.to_string());
if preset != "custom" { session.deferred_setpoint = None; } if preset != "custom" {
session.deferred_setpoint = None;
}
}
if let Some(setpoint) = custom_setpoint {
session.deferred_setpoint = Some(setpoint);
} }
if let Some(setpoint) = custom_setpoint { session.deferred_setpoint = Some(setpoint); }
} }
fn apply_group_climate_change( fn apply_group_climate_change(
@@ -57,7 +79,9 @@ fn apply_group_climate_change(
zone.manual_override_until = None; zone.manual_override_until = None;
} else { } else {
zone.manual_preset = Some(preset.to_string()); zone.manual_preset = Some(preset.to_string());
if preset != "custom" { zone.manual_setpoint = None; } if preset != "custom" {
zone.manual_setpoint = None;
}
zone.manual_override_until = if manual_group_control { zone.manual_override_until = if manual_group_control {
None None
} else { } else {
@@ -85,9 +109,13 @@ fn apply_group_member_power(
temporary_owns_zone: bool, temporary_owns_zone: bool,
group_handback_at: Option<DateTime<Utc>>, group_handback_at: Option<DateTime<Utc>>,
) -> Result<(Option<bool>, bool), Value> { ) -> Result<(Option<bool>, bool), Value> {
let Some(power) = requested_power else { return Ok((None, false)); }; let Some(power) = requested_power else {
return Ok((None, false));
};
let automatic_power_blocked = source == "automation.group" let automatic_power_blocked = source == "automation.group"
&& (zone.device_manual_override || zone.local_thermostat_power.is_some() || temporary_owns_zone); && (zone.device_manual_override
|| zone.local_thermostat_power.is_some()
|| temporary_owns_zone);
if automatic_power_blocked { if automatic_power_blocked {
return Err(json!({ return Err(json!({
"scope": "ownership", "scope": "ownership",
@@ -119,16 +147,24 @@ fn update_group_member_ownership(
applied_group_power: Option<bool>, applied_group_power: Option<bool>,
group_handback_at: Option<DateTime<Utc>>, group_handback_at: Option<DateTime<Utc>>,
) { ) {
if temporary_owns_zone || zone.device_manual_override { return; } if temporary_owns_zone || zone.device_manual_override {
return;
}
if applied_group_power == Some(false) { if applied_group_power == Some(false) {
zone.control_owner = "local_thermostat".into(); zone.control_owner = "local_thermostat".into();
zone.control_source = "local_thermostat".into(); zone.control_source = "local_thermostat".into();
zone.control_since = Some(Utc::now()); zone.control_since = Some(Utc::now());
zone.control_resume_at = group_handback_at.clone(); zone.control_resume_at = group_handback_at.clone();
zone.control_reason = if group_handback_at.is_some() { zone.control_reason = if group_handback_at.is_some() {
format!("Group {} powered the thermostat off; automation resumes after {} minutes", group.name, LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES) format!(
"Group {} powered the thermostat off; automation resumes after {} minutes",
group.name, LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES
)
} else { } else {
format!("Group {} powered the thermostat off; group ownership released", group.name) format!(
"Group {} powered the thermostat off; group ownership released",
group.name
)
}; };
} else if group.power_enabled && zone.local_thermostat_power.is_none() { } else if group.power_enabled && zone.local_thermostat_power.is_none() {
zone.control_owner = "automation".into(); zone.control_owner = "automation".into();
@@ -141,34 +177,55 @@ fn update_group_member_ownership(
zone.control_source = "automation".into(); zone.control_source = "automation".into();
zone.control_since = Some(Utc::now()); zone.control_since = Some(Utc::now());
zone.control_resume_at = zone.manual_override_until; zone.control_resume_at = zone.manual_override_until;
zone.control_reason = format!("Group {} ownership released; zone automation resumed", group.name); zone.control_reason = format!(
"Group {} ownership released; zone automation resumed",
group.name
);
} }
} }
pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControlPatch, source: &str) -> Result<Value, AppError> { pub async fn control_group(
state: &AppState,
group_id: &str,
patch: GroupControlPatch,
source: &str,
) -> Result<Value, AppError> {
let custom_setpoint = validate_group_control_patch(&patch)?; let custom_setpoint = validate_group_control_patch(&patch)?;
let climate_change = patch.mode.is_some() || patch.preset.is_some() || custom_setpoint.is_some(); let climate_change =
patch.mode.is_some() || patch.preset.is_some() || custom_setpoint.is_some();
// House/group actions share one ordering domain. This prevents a concurrent group ON // House/group actions share one ordering domain. This prevents a concurrent group ON
// from racing with whole-house OFF. Lock order stays house -> cycle -> group -> zone -> device. // from racing with whole-house OFF. Lock order stays house -> cycle -> group -> zone -> device.
let _house_guard = state.lock_house_operation().await; let _house_guard = state.lock_house_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await; let _cycle_guard = state.lock_zone_control_cycle().await;
let _group_guard = state.lock_group_operation(group_id).await; let _group_guard = state.lock_group_operation(group_id).await;
let mut group = state.db.get_group(group_id)? let mut group = state
.db
.get_group(group_id)?
.ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?; .ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?;
let mut locked_zone_ids = group.zone_ids.clone(); let mut locked_zone_ids = group.zone_ids.clone();
locked_zone_ids.sort(); locked_zone_ids.sort();
locked_zone_ids.dedup(); locked_zone_ids.dedup();
let mut _zone_guards = Vec::with_capacity(locked_zone_ids.len()); let mut _zone_guards = Vec::with_capacity(locked_zone_ids.len());
for zone_id in &locked_zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); } for zone_id in &locked_zone_ids {
_zone_guards.push(state.lock_zone_operation(zone_id).await);
}
let schedules = state.db.list_schedules()?; let schedules = state.db.list_schedules()?;
let resulting_control_enabled = patch.power.unwrap_or(group.power_enabled); let resulting_control_enabled = patch.power.unwrap_or(group.power_enabled);
if climate_change && !resulting_control_enabled { if climate_change && !resulting_control_enabled {
if source == "automation.group" { if source == "automation.group" {
state.log("info", "automation.group_control_disabled", &format!("Group automation suppressed because group control is disabled for {}", group.name), json!({ state.log(
"group_id": group.id, "source": source "info",
})); "automation.group_control_disabled",
&format!(
"Group automation suppressed because group control is disabled for {}",
group.name
),
json!({
"group_id": group.id, "source": source
}),
);
return Ok(json!({ return Ok(json!({
"group": group, "group": group,
"zones": [], "zones": [],
@@ -177,17 +234,24 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
"suppressed": true, "suppressed": true,
})); }));
} }
return Err(AppError::BadRequest("enable group control before changing group mode, preset or setpoint".into())); return Err(AppError::BadRequest(
"enable group control before changing group mode, preset or setpoint".into(),
));
} }
if let Some(power) = patch.power { group.power_enabled = power; } if let Some(power) = patch.power {
group.power_enabled = power;
}
group.updated_at = Utc::now(); group.updated_at = Utc::now();
state.db.save_group(&group)?; state.db.save_group(&group)?;
state.broadcast("group.updated", serde_json::to_value(&group)?); state.broadcast("group.updated", serde_json::to_value(&group)?);
let manual_group_control = source != "automation.group"; let manual_group_control = source != "automation.group";
let group_handback_at = if manual_group_control && patch.power == Some(false) { let group_handback_at = if manual_group_control && patch.power == Some(false) {
Some(group.updated_at.clone() + chrono::Duration::minutes(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES)) Some(
group.updated_at.clone()
+ chrono::Duration::minutes(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES),
)
} else { } else {
None None
}; };
@@ -200,22 +264,34 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
let mut failed = Vec::new(); let mut failed = Vec::new();
let mut forced_off_devices = std::collections::HashSet::new(); let mut forced_off_devices = std::collections::HashSet::new();
for zone_id in &group.zone_ids { for zone_id in &group.zone_ids {
let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; }; let Some(zone_snapshot) = state.db.get_zone(zone_id)? else {
continue;
};
let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await; let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await;
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; }; let Some(mut zone) = state.db.get_zone(zone_id)? else {
if patch.power.is_some() || climate_change { rearm_compressor_queue(&mut zone); } continue;
};
if patch.power.is_some() || climate_change {
rearm_compressor_queue(&mut zone);
}
let mut temporary_owns_zone = temporary_quick_thermostat_is_active(&zone, Utc::now()); let mut temporary_owns_zone = temporary_quick_thermostat_is_active(&zone, Utc::now());
if explicit_group_takeover { if explicit_group_takeover {
if zone.temporary_quick_thermostat.is_some() { if zone.temporary_quick_thermostat.is_some() {
if temporary_owns_zone { if temporary_owns_zone {
finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode); finish_temporary_quick_thermostat(
&mut zone,
&schedules,
&state.settings.read().await.house_mode,
);
} else { } else {
zone.temporary_quick_thermostat = None; zone.temporary_quick_thermostat = None;
} }
temporary_owns_zone = false; temporary_owns_zone = false;
} }
if zone.device_manual_override { reset_device_manual_override(&mut zone); } if zone.device_manual_override {
reset_device_manual_override(&mut zone);
}
} }
if temporary_owns_zone { if temporary_owns_zone {
@@ -226,16 +302,35 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
})); }));
} }
} else { } else {
apply_group_climate_change(&mut zone, &patch, custom_setpoint, manual_group_control, &schedules); apply_group_climate_change(
&mut zone,
&patch,
custom_setpoint,
manual_group_control,
&schedules,
);
} }
let (applied_group_power, should_force_off) = match apply_group_member_power( let (applied_group_power, should_force_off) = match apply_group_member_power(
&mut zone, requested_member_power, source, temporary_owns_zone, group_handback_at.clone(), &mut zone,
requested_member_power,
source,
temporary_owns_zone,
group_handback_at.clone(),
) { ) {
Ok(result) => result, Ok(result) => result,
Err(error) => { failed.push(error); (None, false) } Err(error) => {
failed.push(error);
(None, false)
}
}; };
update_group_member_ownership(&mut zone, &group, temporary_owns_zone, applied_group_power, group_handback_at); update_group_member_ownership(
&mut zone,
&group,
temporary_owns_zone,
applied_group_power,
group_handback_at,
);
zone.revision = zone.revision.saturating_add(1); zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now(); zone.updated_at = Utc::now();
@@ -261,12 +356,21 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
drop(_cycle_guard); drop(_cycle_guard);
drop(_house_guard); drop(_house_guard);
if let Err(err) = run_zone_control_now(state).await { if let Err(err) = run_zone_control_now(state).await {
state.log("error", "group.immediate_control_error", &err.to_string(), json!({ state.log(
"group_id": group.id, "source": source, "error",
})); "group.immediate_control_error",
&err.to_string(),
json!({
"group_id": group.id, "source": source,
}),
);
failed.push(json!({"scope":"thermostat_cycle","error":err.to_string()})); failed.push(json!({"scope":"thermostat_cycle","error":err.to_string()}));
} }
group.zone_ids.iter().filter_map(|zone_id| state.db.get_zone(zone_id).ok().flatten()).collect::<Vec<_>>() group
.zone_ids
.iter()
.filter_map(|zone_id| state.db.get_zone(zone_id).ok().flatten())
.collect::<Vec<_>>()
} else { } else {
zones zones
}; };
+36 -11
View File
@@ -1,4 +1,9 @@
fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Option<f64>, poll_interval_seconds: u64) { fn record_zone_history(
state: &AppState,
zone: &Zone,
outdoor_temperature: Option<f64>,
poll_interval_seconds: u64,
) {
let device = match state.db.get_device(&zone.device_id) { let device = match state.db.get_device(&zone.device_id) {
Ok(Some(device)) => device, Ok(Some(device)) => device,
Ok(None) => return, Ok(None) => return,
@@ -14,12 +19,22 @@ fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Optio
timestamp: Utc::now(), timestamp: Utc::now(),
gree_temperature: zone.device_temperature.or(device.current_temperature), gree_temperature: zone.device_temperature.or(device.current_temperature),
external_temperature: zone.external_temperature, external_temperature: zone.external_temperature,
control_temperature: zone.current_temperature.or(zone.device_temperature).or(device.current_temperature), control_temperature: zone
target_temperature: zone.effective_setpoint.or(zone.manual_setpoint).or(Some(zone.setpoint)), .current_temperature
.or(zone.device_temperature)
.or(device.current_temperature),
target_temperature: zone
.effective_setpoint
.or(zone.manual_setpoint)
.or(Some(zone.setpoint)),
device_setpoint: zone.device_setpoint.or(Some(device.target_temperature)), device_setpoint: zone.device_setpoint.or(Some(device.target_temperature)),
outdoor_temperature: outdoor_temperature.or(device.outdoor_temperature), outdoor_temperature: outdoor_temperature.or(device.outdoor_temperature),
power: device.power, power: device.power,
mode: if zone.effective_mode.is_empty() { device.mode.clone() } else { zone.effective_mode.clone() }, mode: if zone.effective_mode.is_empty() {
device.mode.clone()
} else {
zone.effective_mode.clone()
},
fan_speed: device.fan_speed, fan_speed: device.fan_speed,
demand: zone.demand, demand: zone.demand,
control_source: zone.control_temperature_source.clone(), control_source: zone.control_temperature_source.clone(),
@@ -53,7 +68,9 @@ fn record_ha_history(
match state.db.add_ha_reading_if_due(&reading, interval) { match state.db.add_ha_reading_if_due(&reading, interval) {
Ok(true) => queue_influx_ha(state, reading), Ok(true) => queue_influx_ha(state, reading),
Ok(false) => {} Ok(false) => {}
Err(err) => tracing::warn!(error=?err, entity_id=%entity_id, "cannot save Home Assistant history sample"), Err(err) => {
tracing::warn!(error=?err, entity_id=%entity_id, "cannot save Home Assistant history sample")
}
} }
} }
@@ -61,7 +78,9 @@ fn queue_influx_device(state: &AppState, reading: Reading) {
let state = state.clone(); let state = state.clone();
tokio::spawn(async move { tokio::spawn(async move {
let settings = state.settings.read().await.influxdb.clone(); let settings = state.settings.read().await.influxdb.clone();
if !settings.enabled { return; } if !settings.enabled {
return;
}
if let Err(err) = influxdb::write_device(&state.http, &settings, &reading).await { if let Err(err) = influxdb::write_device(&state.http, &settings, &reading).await {
tracing::warn!(error=?err, device_id=%reading.device_id, "cannot write device metric to InfluxDB"); tracing::warn!(error=?err, device_id=%reading.device_id, "cannot write device metric to InfluxDB");
} }
@@ -72,7 +91,9 @@ fn queue_influx_zone(state: &AppState, reading: ZoneReading) {
let state = state.clone(); let state = state.clone();
tokio::spawn(async move { tokio::spawn(async move {
let settings = state.settings.read().await.influxdb.clone(); let settings = state.settings.read().await.influxdb.clone();
if !settings.enabled { return; } if !settings.enabled {
return;
}
if let Err(err) = influxdb::write_zone(&state.http, &settings, &reading).await { if let Err(err) = influxdb::write_zone(&state.http, &settings, &reading).await {
tracing::warn!(error=?err, zone_id=%reading.zone_id, "cannot write zone metric to InfluxDB"); tracing::warn!(error=?err, zone_id=%reading.zone_id, "cannot write zone metric to InfluxDB");
} }
@@ -83,7 +104,9 @@ fn queue_influx_ha(state: &AppState, reading: HaReading) {
let state = state.clone(); let state = state.clone();
tokio::spawn(async move { tokio::spawn(async move {
let settings = state.settings.read().await.influxdb.clone(); let settings = state.settings.read().await.influxdb.clone();
if !settings.enabled { return; } if !settings.enabled {
return;
}
if let Err(err) = influxdb::write_ha(&state.http, &settings, &reading).await { if let Err(err) = influxdb::write_ha(&state.http, &settings, &reading).await {
tracing::warn!(error=?err, entity_id=%reading.entity_id, "cannot write HA metric to InfluxDB"); tracing::warn!(error=?err, entity_id=%reading.entity_id, "cannot write HA metric to InfluxDB");
} }
@@ -91,12 +114,15 @@ fn queue_influx_ha(state: &AppState, reading: HaReading) {
} }
fn gree_outdoor_temperature(devices: &[Device]) -> Option<f64> { fn gree_outdoor_temperature(devices: &[Device]) -> Option<f64> {
let mut values: Vec<f64> = devices.iter() let mut values: Vec<f64> = devices
.iter()
.filter(|device| device.enabled && device.online && device.communication_failures == 0) .filter(|device| device.enabled && device.online && device.communication_failures == 0)
.filter_map(|device| device.outdoor_temperature) .filter_map(|device| device.outdoor_temperature)
.filter(|value| value.is_finite() && (-60.0..=70.0).contains(value)) .filter(|value| value.is_finite() && (-60.0..=70.0).contains(value))
.collect(); .collect();
if values.is_empty() { return None; } if values.is_empty() {
return None;
}
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let middle = values.len() / 2; let middle = values.len() / 2;
let value = if values.len() % 2 == 0 { let value = if values.len() % 2 == 0 {
@@ -106,4 +132,3 @@ fn gree_outdoor_temperature(devices: &[Device]) -> Option<f64> {
}; };
Some((value * 10.0).round() / 10.0) Some((value * 10.0).round() / 10.0)
} }
+13 -5
View File
@@ -48,7 +48,10 @@ pub fn set_local_thermostat_power(zone: &mut Zone, power: bool, now: DateTime<Ut
let previous_restore = zone.local_thermostat_restore_zone_enabled; let previous_restore = zone.local_thermostat_restore_zone_enabled;
// Ordinary Quick Thermostat has its own restore state. A temporary session never uses // Ordinary Quick Thermostat has its own restore state. A temporary session never uses
// this field, so its lifecycle cannot be erased by the 15-minute local hand-back. // this field, so its lifecycle cannot be erased by the 15-minute local hand-back.
if power && zone.local_thermostat_power != Some(true) && zone.local_thermostat_restore_zone_enabled.is_none() { if power
&& zone.local_thermostat_power != Some(true)
&& zone.local_thermostat_restore_zone_enabled.is_none()
{
zone.local_thermostat_restore_zone_enabled = Some(zone.enabled); zone.local_thermostat_restore_zone_enabled = Some(zone.enabled);
} }
let resume_at = if power { let resume_at = if power {
@@ -69,7 +72,9 @@ pub fn set_local_thermostat_power(zone: &mut Zone, power: bool, now: DateTime<Ut
/// physically powers members down while releasing group ownership) and must never gain /// physically powers members down while releasing group ownership) and must never gain
/// a 15-minute timer merely because another ownership mode ended. /// a 15-minute timer merely because another ownership mode ended.
fn rearm_local_thermostat_resume(zone: &mut Zone, now: DateTime<Utc>) -> bool { fn rearm_local_thermostat_resume(zone: &mut Zone, now: DateTime<Utc>) -> bool {
if zone.local_thermostat_power != Some(false) || zone.local_thermostat_resume_at.is_none() { return false; } if zone.local_thermostat_power != Some(false) || zone.local_thermostat_resume_at.is_none() {
return false;
}
set_local_thermostat_power(zone, false, now) set_local_thermostat_power(zone, false, now)
} }
@@ -82,11 +87,15 @@ fn local_thermostat_handback_is_active(zone: &Zone) -> bool {
/// Clear only the ordinary local Quick Thermostat. Temporary Quick Thermostat state is /// Clear only the ordinary local Quick Thermostat. Temporary Quick Thermostat state is
/// deliberately untouched; the two ownership mechanisms have independent cleanup paths. /// deliberately untouched; the two ownership mechanisms have independent cleanup paths.
pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool { pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool {
let temporary_active = zone.temporary_quick_thermostat.as_ref() let temporary_active = zone
.temporary_quick_thermostat
.as_ref()
.map(|session| session.activated_at.is_some()) .map(|session| session.activated_at.is_some())
.unwrap_or(false); .unwrap_or(false);
if temporary_active { if temporary_active {
let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return false; }; let Some(session) = zone.temporary_quick_thermostat.as_mut() else {
return false;
};
let changed = session.restore_local_thermostat_power.is_some() let changed = session.restore_local_thermostat_power.is_some()
|| session.restore_local_thermostat_resume_at.is_some() || session.restore_local_thermostat_resume_at.is_some()
|| session.restore_local_thermostat_zone_enabled.is_some() || session.restore_local_thermostat_zone_enabled.is_some()
@@ -120,4 +129,3 @@ pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool {
} }
changed changed
} }
+275 -98
View File
@@ -5,13 +5,20 @@ pub fn refresh_control_ownership(zone: &mut Zone) {
"home_assistant_direct" | "web_direct" | "external" => zone.control_source.clone(), "home_assistant_direct" | "web_direct" | "external" => zone.control_source.clone(),
_ => "external".into(), _ => "external".into(),
}; };
("direct_manual", source, zone.device_manual_override_until, "Direct/manual device control has priority".to_string()) (
"direct_manual",
source,
zone.device_manual_override_until,
"Direct/manual device control has priority".to_string(),
)
} else if zone.local_thermostat_power.is_some() { } else if zone.local_thermostat_power.is_some() {
let source = match zone.control_source.as_str() { let source = match zone.control_source.as_str() {
"home_assistant_thermostat" | "web_thermostat" => zone.control_source.clone(), "home_assistant_thermostat" | "web_thermostat" => zone.control_source.clone(),
_ => "local_thermostat".into(), _ => "local_thermostat".into(),
}; };
let resume_at = zone.temporary_quick_thermostat.as_ref() let resume_at = zone
.temporary_quick_thermostat
.as_ref()
.and_then(temporary_quick_thermostat_next_deadline) .and_then(temporary_quick_thermostat_next_deadline)
.or(zone.local_thermostat_resume_at.clone()); .or(zone.local_thermostat_resume_at.clone());
let reason = if zone.local_thermostat_power == Some(false) { let reason = if zone.local_thermostat_power == Some(false) {
@@ -24,13 +31,20 @@ pub fn refresh_control_ownership(zone: &mut Zone) {
("local_thermostat", source, resume_at, reason) ("local_thermostat", source, resume_at, reason)
} else { } else {
let source = if zone.control_source.starts_with("group:") let source = if zone.control_source.starts_with("group:")
|| matches!(zone.control_source.as_str(), "automation.device" | "house_power") || matches!(
{ zone.control_source.as_str(),
"automation.device" | "house_power"
) {
zone.control_source.clone() zone.control_source.clone()
} else { } else {
"automation".into() "automation".into()
}; };
("automation", source, zone.manual_override_until, "Automatic thermostat/schedule control".to_string()) (
"automation",
source,
zone.manual_override_until,
"Automatic thermostat/schedule control".to_string(),
)
}; };
if zone.control_owner != owner || zone.control_source != source { if zone.control_owner != owner || zone.control_source != source {
zone.control_since = Some(now); zone.control_since = Some(now);
@@ -44,9 +58,13 @@ pub fn refresh_control_ownership(zone: &mut Zone) {
} }
fn normalized_direct_source(source: &str) -> &'static str { fn normalized_direct_source(source: &str) -> &'static str {
if source.contains("home_assistant") { "home_assistant_direct" } if source.contains("home_assistant") {
else if source == "device.manual_control" { "web_direct" } "home_assistant_direct"
else { "external" } } else if source == "device.manual_control" {
"web_direct"
} else {
"external"
}
} }
pub fn reset_device_manual_override(zone: &mut Zone) -> bool { pub fn reset_device_manual_override(zone: &mut Zone) -> bool {
@@ -62,7 +80,9 @@ pub fn reset_device_manual_override(zone: &mut Zone) -> bool {
zone.device_manual_override_fields.clear(); zone.device_manual_override_fields.clear();
zone.device_manual_override_baseline = None; zone.device_manual_override_baseline = None;
if let Some(session) = zone.temporary_quick_thermostat.as_mut() { if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
let pause = session.paused_at.take() let pause = session
.paused_at
.take()
.map(|paused_at| now.signed_duration_since(paused_at)) .map(|paused_at| now.signed_duration_since(paused_at))
.filter(|pause| *pause > chrono::Duration::zero()); .filter(|pause| *pause > chrono::Duration::zero());
if session.activated_at.is_some() { if session.activated_at.is_some() {
@@ -70,7 +90,10 @@ pub fn reset_device_manual_override(zone: &mut Zone) -> bool {
if matches!(session.finish_kind.as_str(), "duration" | "until") { if matches!(session.finish_kind.as_str(), "duration" | "until") {
session.expires_at = session.expires_at.map(|at| at + pause); session.expires_at = session.expires_at.map(|at| at + pause);
} }
if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { if matches!(
session.finish_kind.as_str(),
"temperature_reached" | "temperature_stable"
) {
session.safety_expires_at = session.safety_expires_at.map(|at| at + pause); session.safety_expires_at = session.safety_expires_at.map(|at| at + pause);
} }
} }
@@ -100,46 +123,85 @@ pub fn reset_device_manual_override(zone: &mut Zone) -> bool {
#[cfg(test)] #[cfg(test)]
fn manual_override_matches_baseline(zone: &Zone, device: &Device) -> bool { fn manual_override_matches_baseline(zone: &Zone, device: &Device) -> bool {
let Some(baseline) = zone.device_manual_override_baseline.as_ref() else { return false; }; let Some(baseline) = zone.device_manual_override_baseline.as_ref() else {
if zone.device_manual_override_fields.is_empty() { return false; } return false;
};
if zone.device_manual_override_fields.is_empty() {
return false;
}
// If the unit was OFF before takeover, returning it to OFF is operationally the same // If the unit was OFF before takeover, returning it to OFF is operationally the same
// controller state even if the remote retained a different mode/target internally. // controller state even if the remote retained a different mode/target internally.
// Those dormant values will be set explicitly if automation later powers the unit. // Those dormant values will be set explicitly if automation later powers the unit.
if !baseline.power { return !device.power; } if !baseline.power {
zone.device_manual_override_fields.iter().all(|field| match field.as_str() { return !device.power;
"power" => device.power == baseline.power, }
"mode" => device.mode == baseline.mode, zone.device_manual_override_fields
"target_temperature" => device.target_temperature.round() == baseline.target_temperature.round(), .iter()
"fan_speed" => device.fan_speed == baseline.fan_speed, .all(|field| match field.as_str() {
"quiet" => device.quiet == baseline.quiet, "power" => device.power == baseline.power,
"sleep" => device.sleep == baseline.sleep, "mode" => device.mode == baseline.mode,
_ => false, "target_temperature" => {
}) device.target_temperature.round() == baseline.target_temperature.round()
}
"fan_speed" => device.fan_speed == baseline.fan_speed,
"quiet" => device.quiet == baseline.quiet,
"sleep" => device.sleep == baseline.sleep,
_ => false,
})
} }
fn persist_manual_override_clear(state: &AppState, zone: &mut Zone, source: &str, restored: bool) -> Result<bool, AppError> { fn persist_manual_override_clear(
if !reset_device_manual_override(zone) { return Ok(false); } state: &AppState,
zone: &mut Zone,
source: &str,
restored: bool,
) -> Result<bool, AppError> {
if !reset_device_manual_override(zone) {
return Ok(false);
}
let now = Utc::now(); let now = Utc::now();
let local_resume_rearmed = restored && rearm_local_thermostat_resume(zone, now.clone()); let local_resume_rearmed = restored && rearm_local_thermostat_resume(zone, now.clone());
zone.updated_at = now; zone.updated_at = now;
state.db.save_zone(zone)?; state.db.save_zone(zone)?;
state.broadcast("zone.updated", serde_json::to_value(&*zone)?); state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
let (kind, message) = if restored { let (kind, message) = if restored {
("zone.device_manual_override_restored", format!("Manual device control returned {} to its previous state", zone.name)) (
"zone.device_manual_override_restored",
format!(
"Manual device control returned {} to its previous state",
zone.name
),
)
} else { } else {
("zone.device_manual_override_cleared", format!("Manual device control ended for {}", zone.name)) (
"zone.device_manual_override_cleared",
format!("Manual device control ended for {}", zone.name),
)
}; };
state.log("info", kind, &message, json!({ state.log(
"zone_id": zone.id, "device_id": zone.device_id, "source": source, "info",
"local_thermostat_resume_rearmed": local_resume_rearmed, kind,
"local_thermostat_resume_at": zone.local_thermostat_resume_at, &message,
})); json!({
"zone_id": zone.id, "device_id": zone.device_id, "source": source,
"local_thermostat_resume_rearmed": local_resume_rearmed,
"local_thermostat_resume_at": zone.local_thermostat_resume_at,
}),
);
state.wake_zone_control(); state.wake_zone_control();
Ok(true) Ok(true)
} }
fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec<String>, source: &str, baseline: &Device) -> Result<(), AppError> { fn set_device_manual_override(
if fields.is_empty() { return Ok(()); } state: &AppState,
zone: &mut Zone,
fields: Vec<String>,
source: &str,
baseline: &Device,
) -> Result<(), AppError> {
if fields.is_empty() {
return Ok(());
}
let now = Utc::now(); let now = Utc::now();
if !zone.device_manual_override { if !zone.device_manual_override {
zone.device_manual_override_since = Some(now); zone.device_manual_override_since = Some(now);
@@ -151,7 +213,9 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec<Str
// A future scheduled session has no ownership yet. Do not mark it paused until its // A future scheduled session has no ownership yet. Do not mark it paused until its
// requested start actually becomes due while manual control is still present. // requested start actually becomes due while manual control is still present.
if session.activated_at.is_some() || session.started_at <= now { if session.activated_at.is_some() || session.started_at <= now {
if session.paused_at.is_none() { session.paused_at = Some(now); } if session.paused_at.is_none() {
session.paused_at = Some(now);
}
session.state = "paused_manual".into(); session.state = "paused_manual".into();
session.condition_started_at = None; session.condition_started_at = None;
session.condition_last_observed_at = None; session.condition_last_observed_at = None;
@@ -167,7 +231,11 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec<Str
zone.device_manual_override_until = None; zone.device_manual_override_until = None;
zone.control_resume_at = None; zone.control_resume_at = None;
for field in fields { for field in fields {
if !zone.device_manual_override_fields.iter().any(|existing| existing == &field) { if !zone
.device_manual_override_fields
.iter()
.any(|existing| existing == &field)
{
zone.device_manual_override_fields.push(field); zone.device_manual_override_fields.push(field);
} }
} }
@@ -179,77 +247,114 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec<Str
zone.updated_at = now; zone.updated_at = now;
state.db.save_zone(zone)?; state.db.save_zone(zone)?;
state.broadcast("zone.updated", serde_json::to_value(&*zone)?); state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
state.log("info", "zone.device_manual_override", &format!("Manual device control detected for {}", zone.name), json!({ state.log(
"zone_id": zone.id, "info",
"device_id": zone.device_id, "zone.device_manual_override",
"fields": zone.device_manual_override_fields, &format!("Manual device control detected for {}", zone.name),
"source": source, json!({
"override_until": zone.device_manual_override_until, "zone_id": zone.id,
})); "device_id": zone.device_id,
"fields": zone.device_manual_override_fields,
"source": source,
"override_until": zone.device_manual_override_until,
}),
);
Ok(()) Ok(())
} }
async fn detect_external_device_control(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> { async fn detect_external_device_control(
if before.id != after.id { return Ok(()); } state: &AppState,
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) { before: &Device,
after: &Device,
) -> Result<(), AppError> {
if before.id != after.id {
return Ok(());
}
for mut zone in state
.db
.list_zones()?
.into_iter()
.filter(|zone| zone.device_id == after.id)
{
let raw_fields = externally_changed_control_fields(before, after, &zone); let raw_fields = externally_changed_control_fields(before, after, &zone);
let controller_settling = if raw_fields.is_empty() { let controller_settling = if raw_fields.is_empty() {
json!({ "active": false, "reason": "no_changed_control_fields" }) json!({ "active": false, "reason": "no_changed_control_fields" })
} else { } else {
controller_settling_diagnostics(state, &after.id).await controller_settling_diagnostics(state, &after.id).await
}; };
let fields = suppress_expected_controller_changes( let fields = suppress_expected_controller_changes(state, after, raw_fields.clone()).await;
state,
after,
raw_fields.clone(),
).await;
// Once direct/pilot ownership has been detected, merely returning the device to a // Once direct/pilot ownership has been detected, merely returning the device to a
// previous physical state must not silently hand control back to schedules. Only the // previous physical state must not silently hand control back to schedules. Only the
// explicit Resume automation action ends manual ownership. // explicit Resume automation action ends manual ownership.
if fields.is_empty() { continue; } if fields.is_empty() {
continue;
}
// A disabled zone is outside controller ownership. When its manually operated unit is // A disabled zone is outside controller ownership. When its manually operated unit is
// switched off there is no takeover left to display or remember. // switched off there is no takeover left to display or remember.
if !zone.enabled && !after.power { if !zone.enabled && !after.power {
persist_manual_override_clear(state, &mut zone, "gree_poll", false)?; persist_manual_override_clear(state, &mut zone, "gree_poll", false)?;
continue; continue;
} }
state.log("info", "device.remote_control_detected", &format!("External/pilot control detected for {}", zone.name), json!({ state.log(
"zone_id": zone.id.clone(), "info",
"device_id": zone.device_id.clone(), "device.remote_control_detected",
"raw_fields": raw_fields, &format!("External/pilot control detected for {}", zone.name),
"detected_fields": fields.clone(), json!({
"before": device_control_snapshot(before), "zone_id": zone.id.clone(),
"after": device_control_snapshot(after), "device_id": zone.device_id.clone(),
"controller_settling": controller_settling, "raw_fields": raw_fields,
"source": "gree_poll", "detected_fields": fields.clone(),
"zone_state": { "before": device_control_snapshot(before),
"enabled": zone.enabled, "after": device_control_snapshot(after),
"control_owner": zone.control_owner.clone(), "controller_settling": controller_settling,
"control_source": zone.control_source.clone(), "source": "gree_poll",
"demand": zone.demand, "zone_state": {
"local_thermostat_power": zone.local_thermostat_power, "enabled": zone.enabled,
"temporary_quick_thermostat": zone.temporary_quick_thermostat.clone(), "control_owner": zone.control_owner.clone(),
}, "control_source": zone.control_source.clone(),
})); "demand": zone.demand,
"local_thermostat_power": zone.local_thermostat_power,
"temporary_quick_thermostat": zone.temporary_quick_thermostat.clone(),
},
}),
);
set_device_manual_override(state, &mut zone, fields, "gree_poll", before)?; set_device_manual_override(state, &mut zone, fields, "gree_poll", before)?;
} }
Ok(()) Ok(())
} }
pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str, allow_disabled_zone: bool) -> Result<Device, AppError> { pub async fn send_manual_command(
state: &AppState,
device_id: &str,
command: DeviceCommand,
source: &str,
allow_disabled_zone: bool,
) -> Result<Device, AppError> {
// Stabilize device <-> zone membership while validating the manual-control safety gate. // Stabilize device <-> zone membership while validating the manual-control safety gate.
// Lock order remains configuration -> zone(s) -> device. // Lock order remains configuration -> zone(s) -> device.
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
let zones: Vec<Zone> = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).collect(); let zones: Vec<Zone> = state
.db
.list_zones()?
.into_iter()
.filter(|zone| zone.device_id == device_id)
.collect();
let mut zone_ids: Vec<String> = zones.iter().map(|zone| zone.id.clone()).collect(); let mut zone_ids: Vec<String> = zones.iter().map(|zone| zone.id.clone()).collect();
zone_ids.sort(); zone_ids.sort();
zone_ids.dedup(); zone_ids.dedup();
let mut _zone_guards = Vec::new(); let mut _zone_guards = Vec::new();
for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); } for zone_id in &zone_ids {
_zone_guards.push(state.lock_zone_operation(zone_id).await);
}
if !allow_disabled_zone { if !allow_disabled_zone {
// Re-read after taking the zone lock(s): a quick thermostat action may have changed // Re-read after taking the zone lock(s): a quick thermostat action may have changed
// enabled state while this request was waiting, even though membership is stable. // enabled state while this request was waiting, even though membership is stable.
if let Some(zone) = state.db.list_zones()?.into_iter().find(|zone| zone.device_id == device_id && !zone.enabled) { if let Some(zone) = state
.db
.list_zones()?
.into_iter()
.find(|zone| zone.device_id == device_id && !zone.enabled)
{
return Err(AppError::BadRequest(format!( return Err(AppError::BadRequest(format!(
"device belongs to disabled thermostat zone '{}'; explicit manual_override=true is required for direct control", "device belongs to disabled thermostat zone '{}'; explicit manual_override=true is required for direct control",
zone.name zone.name
@@ -259,7 +364,9 @@ pub async fn send_manual_command(state: &AppState, device_id: &str, command: Dev
// Keep the device lock until the zone takeover marker is persisted. Otherwise a poll // Keep the device lock until the zone takeover marker is persisted. Otherwise a poll
// could observe our own just-sent command before the controller records manual ownership. // could observe our own just-sent command before the controller records manual ownership.
let _device_guard = state.lock_device_operation(device_id).await; let _device_guard = state.lock_device_operation(device_id).await;
let before = state.db.get_device(device_id)? let before = state
.db
.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
// An explicit direct-control request is an ownership action even when the requested // An explicit direct-control request is an ownership action even when the requested
// value already matches the cached device state. Derive takeover fields from the user's // value already matches the cached device state. Derive takeover fields from the user's
@@ -268,7 +375,12 @@ pub async fn send_manual_command(state: &AppState, device_id: &str, command: Dev
let fields = command_manual_control_fields(&command); let fields = command_manual_control_fields(&command);
let updated = send_command_locked_inner(state, device_id, command, true, false).await?; let updated = send_command_locked_inner(state, device_id, command, true, false).await?;
if !fields.is_empty() { if !fields.is_empty() {
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) { for mut zone in state
.db
.list_zones()?
.into_iter()
.filter(|zone| zone.device_id == device_id)
{
if !zone.enabled && !updated.power { if !zone.enabled && !updated.power {
persist_manual_override_clear(state, &mut zone, source, false)?; persist_manual_override_clear(state, &mut zone, source, false)?;
continue; continue;
@@ -279,10 +391,17 @@ pub async fn send_manual_command(state: &AppState, device_id: &str, command: Dev
Ok(updated) Ok(updated)
} }
pub async fn force_house_power_off_device(state: &AppState, device_id: &str, _source: &str) -> Result<Device, AppError> { pub async fn force_house_power_off_device(
state: &AppState,
device_id: &str,
_source: &str,
) -> Result<Device, AppError> {
// Whole-house OFF physically forces the unit down after the API has persisted per-zone local OFF. // Whole-house OFF physically forces the unit down after the API has persisted per-zone local OFF.
// Keep zone -> device ordering so a concurrent local/manual action cannot race the frame. // Keep zone -> device ordering so a concurrent local/manual action cannot race the frame.
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter() let mut zone_ids: Vec<String> = state
.db
.list_zones()?
.into_iter()
.filter(|zone| zone.device_id == device_id) .filter(|zone| zone.device_id == device_id)
.map(|zone| zone.id) .map(|zone| zone.id)
.collect(); .collect();
@@ -296,11 +415,17 @@ pub async fn force_house_power_off_device(state: &AppState, device_id: &str, _so
force_power_off_device_locked(state, device_id).await force_power_off_device_locked(state, device_id).await
} }
pub async fn one_shot_house_power_on_device(state: &AppState, device_id: &str) -> Result<Device, AppError> { pub async fn one_shot_house_power_on_device(
state: &AppState,
device_id: &str,
) -> Result<Device, AppError> {
// Global ON releases per-zone OFF state in the API and must not create a local-ON ownership marker. For thermostat-managed // Global ON releases per-zone OFF state in the API and must not create a local-ON ownership marker. For thermostat-managed
// units it still respects compressor protection; a protected start is stored as a visible // units it still respects compressor protection; a protected start is stored as a visible
// queue item and executed at the protection deadline unless a newer intent cancels/replaces it. // queue item and executed at the protection deadline unless a newer intent cancels/replaces it.
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter() let mut zone_ids: Vec<String> = state
.db
.list_zones()?
.into_iter()
.filter(|zone| zone.device_id == device_id) .filter(|zone| zone.device_id == device_id)
.map(|zone| zone.id) .map(|zone| zone.id)
.collect(); .collect();
@@ -311,9 +436,13 @@ pub async fn one_shot_house_power_on_device(state: &AppState, device_id: &str) -
_zone_guards.push(state.lock_zone_operation(zone_id).await); _zone_guards.push(state.lock_zone_operation(zone_id).await);
} }
let _device_guard = state.lock_device_operation(device_id).await; let _device_guard = state.lock_device_operation(device_id).await;
let device = state.db.get_device(device_id)? let device = state
.db
.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); } if !device.enabled {
return Err(AppError::BadRequest("device is disabled".into()));
}
if device.power { if device.power {
return Ok(device); return Ok(device);
} }
@@ -323,22 +452,36 @@ pub async fn one_shot_house_power_on_device(state: &AppState, device_id: &str) -
if let Some(zone_id) = zone_ids.first() { if let Some(zone_id) = zone_ids.first() {
if let Some(mut zone) = state.db.get_zone(zone_id)? { if let Some(mut zone) = state.db.get_zone(zone_id)? {
let now = Utc::now(); let now = Utc::now();
let protection = chrono::Duration::seconds(settings.compressor_protection_seconds as i64); let protection =
chrono::Duration::seconds(settings.compressor_protection_seconds as i64);
if let Some(last_change) = zone.last_power_change_at { if let Some(last_change) = zone.last_power_change_at {
let until = last_change + protection; let until = last_change + protection;
if until > now { if until > now {
rearm_compressor_queue(&mut zone); rearm_compressor_queue(&mut zone);
queue_compressor_action(&mut zone, "global_power_on".into(), until, "minimum_off_before_global_start"); queue_compressor_action(
&mut zone,
"global_power_on".into(),
until,
"minimum_off_before_global_start",
);
zone.revision = zone.revision.saturating_add(1); zone.revision = zone.revision.saturating_add(1);
zone.updated_at = now; zone.updated_at = now;
state.db.save_zone(&zone)?; state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?); state.broadcast("zone.updated", serde_json::to_value(&zone)?);
state.log("info", "zone.compressor_queue_queued", &format!("Queued global ON for {} behind compressor protection", zone.name), json!({ state.log(
"zone_id": zone.id, "info",
"device_id": zone.device_id, "zone.compressor_queue_queued",
"action": "global_power_on", &format!(
"resume_at": zone.compressor_pending_until, "Queued global ON for {} behind compressor protection",
})); zone.name
),
json!({
"zone_id": zone.id,
"device_id": zone.device_id,
"action": "global_power_on",
"resume_at": zone.compressor_pending_until,
}),
);
state.wake_zone_control(); state.wake_zone_control();
return Ok(device); return Ok(device);
} }
@@ -348,32 +491,67 @@ pub async fn one_shot_house_power_on_device(state: &AppState, device_id: &str) -
} }
// The device lock is already held. This is physical global power only: no manual marker. // The device lock is already held. This is physical global power only: no manual marker.
send_command_locked(state, device_id, DeviceCommand { power: Some(true), ..Default::default() }).await send_command_locked(
state,
device_id,
DeviceCommand {
power: Some(true),
..Default::default()
},
)
.await
} }
pub async fn force_power_off_device(state: &AppState, device_id: &str) -> Result<Device, AppError> { pub async fn force_power_off_device(state: &AppState, device_id: &str) -> Result<Device, AppError> {
let _device_guard = state.lock_device_operation(device_id).await; let _device_guard = state.lock_device_operation(device_id).await;
send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await send_command_locked_forced(
state,
device_id,
DeviceCommand {
power: Some(false),
..Default::default()
},
)
.await
} }
/// Same safety transition for callers that already hold the per-device operation lock. /// Same safety transition for callers that already hold the per-device operation lock.
/// Keeping this separate avoids recursive lock acquisition during atomic configuration import. /// Keeping this separate avoids recursive lock acquisition during atomic configuration import.
pub async fn force_power_off_device_locked(state: &AppState, device_id: &str) -> Result<Device, AppError> { pub async fn force_power_off_device_locked(
send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await state: &AppState,
device_id: &str,
) -> Result<Device, AppError> {
send_command_locked_forced(
state,
device_id,
DeviceCommand {
power: Some(false),
..Default::default()
},
)
.await
} }
/// Technical device disable is a safety transition, not just a database flag. The unit is /// Technical device disable is a safety transition, not just a database flag. The unit is
/// explicitly powered off while it is still commandable, then removed from controller polling. /// explicitly powered off while it is still commandable, then removed from controller polling.
pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result<Device, AppError> { pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result<Device, AppError> {
let _device_guard = state.lock_device_operation(device_id).await; let _device_guard = state.lock_device_operation(device_id).await;
let mut device = state.db.get_device(device_id)? let mut device = state
.db
.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
if !device.enabled { return Ok(device); } if !device.enabled {
return Ok(device);
}
device = send_command_locked_forced( device = send_command_locked_forced(
state, state,
device_id, device_id,
DeviceCommand { power: Some(false), ..Default::default() }, DeviceCommand {
).await?; power: Some(false),
..Default::default()
},
)
.await?;
device.enabled = false; device.enabled = false;
device.updated_at = Utc::now(); device.updated_at = Utc::now();
state.db.save_device(&device)?; state.db.save_device(&device)?;
@@ -381,4 +559,3 @@ pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result<
state.wake_zone_control(); state.wake_zone_control();
Ok(device) Ok(device)
} }
+65 -18
View File
@@ -1,8 +1,14 @@
fn reset_temporary_condition_observations_after_restart(state: &AppState) -> Result<usize, AppError> { fn reset_temporary_condition_observations_after_restart(
state: &AppState,
) -> Result<usize, AppError> {
let mut changed = 0usize; let mut changed = 0usize;
for mut zone in state.db.list_zones()? { for mut zone in state.db.list_zones()? {
let Some(session) = zone.temporary_quick_thermostat.as_mut() else { continue; }; let Some(session) = zone.temporary_quick_thermostat.as_mut() else {
if session.condition_started_at.is_none() && session.condition_last_observed_at.is_none() { continue; } continue;
};
if session.condition_started_at.is_none() && session.condition_last_observed_at.is_none() {
continue;
}
session.condition_started_at = None; session.condition_started_at = None;
session.condition_last_observed_at = None; session.condition_last_observed_at = None;
zone.updated_at = Utc::now(); zone.updated_at = Utc::now();
@@ -24,13 +30,23 @@ pub fn start(state: AppState) {
loop { loop {
match poll_all(&poll_state).await { match poll_all(&poll_state).await {
Ok(()) => { Ok(()) => {
if !poll_state.initial_device_sync_complete.swap(true, Ordering::AcqRel) { if !poll_state
tracing::info!("initial device state synchronized; thermostat control enabled"); .initial_device_sync_complete
.swap(true, Ordering::AcqRel)
{
tracing::info!(
"initial device state synchronized; thermostat control enabled"
);
} }
} }
Err(err) => tracing::error!(error=?err, "device poll cycle failed"), Err(err) => tracing::error!(error=?err, "device poll cycle failed"),
} }
let seconds = poll_state.settings.read().await.poll_interval_seconds.max(2); let seconds = poll_state
.settings
.read()
.await
.poll_interval_seconds
.max(2);
sleep(Duration::from_secs(seconds)).await; sleep(Duration::from_secs(seconds)).await;
} }
}); });
@@ -42,7 +58,10 @@ pub fn start(state: AppState) {
// A restart must never make decisions from the persisted, potentially stale // A restart must never make decisions from the persisted, potentially stale
// device snapshot. Wait for one full live poll before thermostat/schedule/automation // device snapshot. Wait for one full live poll before thermostat/schedule/automation
// ownership can emit commands. Manual API/remote control remains available. // ownership can emit commands. Manual API/remote control remains available.
if !control_state.initial_device_sync_complete.load(Ordering::Acquire) { if !control_state
.initial_device_sync_complete
.load(Ordering::Acquire)
{
sleep(Duration::from_millis(250)).await; sleep(Duration::from_millis(250)).await;
continue; continue;
} }
@@ -52,7 +71,12 @@ pub fn start(state: AppState) {
if let Err(err) = run_automations(&control_state).await { if let Err(err) = run_automations(&control_state).await {
tracing::error!(error=?err, "automation cycle failed"); tracing::error!(error=?err, "automation cycle failed");
} }
let seconds = control_state.settings.read().await.zone_interval_seconds.max(2); let seconds = control_state
.settings
.read()
.await
.zone_interval_seconds
.max(2);
let normal_delay = Duration::from_secs(seconds); let normal_delay = Duration::from_secs(seconds);
let resume_delay = match next_zone_control_deadline_delay(&control_state) { let resume_delay = match next_zone_control_deadline_delay(&control_state) {
Ok(value) => value, Ok(value) => value,
@@ -61,7 +85,9 @@ pub fn start(state: AppState) {
None None
} }
}; };
let sleep_for = resume_delay.map(|delay| delay.min(normal_delay)).unwrap_or(normal_delay); let sleep_for = resume_delay
.map(|delay| delay.min(normal_delay))
.unwrap_or(normal_delay);
tokio::select! { tokio::select! {
_ = sleep(sleep_for) => {}, _ = sleep(sleep_for) => {},
_ = control_state.zone_control_wakeup.notified() => {}, _ = control_state.zone_control_wakeup.notified() => {},
@@ -76,7 +102,11 @@ pub fn start(state: AppState) {
let settings = maintenance_state.settings.read().await.clone(); let settings = maintenance_state.settings.read().await.clone();
// When InfluxDB is enabled, compact all locally retained legacy history before // When InfluxDB is enabled, compact all locally retained legacy history before
// transferring old buckets. Without Influx, compact only the configured retention window. // transferring old buckets. Without Influx, compact only the configured retention window.
let compaction_days = if settings.influxdb.enabled { 3650 } else { settings.history_retention_days.max(1) } as i64; let compaction_days = if settings.influxdb.enabled {
3650
} else {
settings.history_retention_days.max(1)
} as i64;
if settings.history_compaction_enabled { if settings.history_compaction_enabled {
match maintenance_state.db.compact_history(compaction_days) { match maintenance_state.db.compact_history(compaction_days) {
Ok(count) if count > 0 => tracing::info!(count, "history samples compacted"), Ok(count) if count > 0 => tracing::info!(count, "history samples compacted"),
@@ -85,22 +115,36 @@ pub fn start(state: AppState) {
} }
} }
if settings.influxdb.enabled { if settings.influxdb.enabled {
match archive_old_history(&maintenance_state, settings.influxdb.history_threshold_days.max(1)).await { match archive_old_history(
Ok(count) if count > 0 => tracing::info!(count, "old local readings archived to InfluxDB and removed from SQLite"), &maintenance_state,
settings.influxdb.history_threshold_days.max(1),
)
.await
{
Ok(count) if count > 0 => tracing::info!(
count,
"old local readings archived to InfluxDB and removed from SQLite"
),
Ok(_) => {} Ok(_) => {}
Err(err) => tracing::warn!(error=?err, "cannot archive old history to InfluxDB; SQLite copies were kept"), Err(err) => {
tracing::warn!(error=?err, "cannot archive old history to InfluxDB; SQLite copies were kept")
}
} }
} else { } else {
let retention_days = settings.history_retention_days.max(1) as i64; let retention_days = settings.history_retention_days.max(1) as i64;
match maintenance_state.db.prune_readings(retention_days) { match maintenance_state.db.prune_readings(retention_days) {
Ok(count) if count > 0 => tracing::info!(count, retention_days, "old local readings pruned"), Ok(count) if count > 0 => {
tracing::info!(count, retention_days, "old local readings pruned")
}
Ok(_) => {} Ok(_) => {}
Err(err) => tracing::warn!(error=?err, "cannot prune readings"), Err(err) => tracing::warn!(error=?err, "cannot prune readings"),
} }
} }
let event_retention_days = settings.event_log_retention_days.max(1) as i64; let event_retention_days = settings.event_log_retention_days.max(1) as i64;
match maintenance_state.db.prune_events(event_retention_days) { match maintenance_state.db.prune_events(event_retention_days) {
Ok(count) if count > 0 => tracing::info!(count, event_retention_days, "old event log rows pruned"), Ok(count) if count > 0 => {
tracing::info!(count, event_retention_days, "old event log rows pruned")
}
Ok(_) => {} Ok(_) => {}
Err(err) => tracing::warn!(error=?err, "cannot prune event log"), Err(err) => tracing::warn!(error=?err, "cannot prune event log"),
} }
@@ -117,12 +161,15 @@ async fn archive_old_history(state: &AppState, threshold_days: u32) -> Result<u6
// Successful batches are deleted from SQLite, so the next pass naturally continues forward. // Successful batches are deleted from SQLite, so the next pass naturally continues forward.
for _ in 0..50 { for _ in 0..50 {
let (devices, zones, ha) = state.db.history_before(cutoff, 1_000)?; let (devices, zones, ha) = state.db.history_before(cutoff, 1_000)?;
if devices.is_empty() && zones.is_empty() && ha.is_empty() { break; } if devices.is_empty() && zones.is_empty() && ha.is_empty() {
break;
}
influxdb::write_batch(&state.http, &settings, &devices, &zones, &ha).await?; influxdb::write_batch(&state.http, &settings, &devices, &zones, &ha).await?;
let deleted = state.db.delete_history_batch(&devices, &zones, &ha)?; let deleted = state.db.delete_history_batch(&devices, &zones, &ha)?;
moved += deleted; moved += deleted;
if deleted == 0 { break; } if deleted == 0 {
break;
}
} }
Ok(moved) Ok(moved)
} }
+46 -16
View File
@@ -1,5 +1,10 @@
fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> { fn active_schedule_for_zone<'a>(
schedules.iter() zone: &Zone,
schedules: &'a [Schedule],
now: DateTime<Local>,
) -> Option<&'a Schedule> {
schedules
.iter()
.filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now)) .filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now))
// Overlaps are rejected by the API, but imported/legacy data may still contain one. // Overlaps are rejected by the API, but imported/legacy data may still contain one.
// Prefer the most recently edited entry instead of depending on database/name order. // Prefer the most recently edited entry instead of depending on database/name order.
@@ -7,11 +12,18 @@ fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: Dat
} }
fn minute_floor(now: DateTime<Local>) -> DateTime<Local> { fn minute_floor(now: DateTime<Local>) -> DateTime<Local> {
now.with_second(0).and_then(|value| value.with_nanosecond(0)).unwrap_or(now) now.with_second(0)
.and_then(|value| value.with_nanosecond(0))
.unwrap_or(now)
} }
pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: DateTime<Local>) -> Option<DateTime<Utc>> { pub fn next_schedule_boundary_utc(
let current = schedules.iter() zone_id: &str,
schedules: &[Schedule],
now: DateTime<Local>,
) -> Option<DateTime<Utc>> {
let current = schedules
.iter()
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, now)) .filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, now))
.max_by_key(|item| item.updated_at) .max_by_key(|item| item.updated_at)
.map(|item| item.id.as_str()); .map(|item| item.id.as_str());
@@ -19,8 +31,11 @@ pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: Da
// Eight days cover a complete weekly schedule plus the next transition. // Eight days cover a complete weekly schedule plus the next transition.
for minute in 1..=(8 * 24 * 60) { for minute in 1..=(8 * 24 * 60) {
let candidate = base + chrono::Duration::minutes(minute); let candidate = base + chrono::Duration::minutes(minute);
let next = schedules.iter() let next = schedules
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, candidate)) .iter()
.filter(|item| {
item.enabled && item.zone_id == zone_id && schedule_active(item, candidate)
})
.max_by_key(|item| item.updated_at) .max_by_key(|item| item.updated_at)
.map(|item| item.id.as_str()); .map(|item| item.id.as_str());
if next != current { if next != current {
@@ -32,8 +47,12 @@ pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: Da
} }
fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool { fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
let Ok(start) = NaiveTime::parse_from_str(&item.start_time, "%H:%M") else { return false; }; let Ok(start) = NaiveTime::parse_from_str(&item.start_time, "%H:%M") else {
let Ok(end) = NaiveTime::parse_from_str(&item.end_time, "%H:%M") else { return false; }; return false;
};
let Ok(end) = NaiveTime::parse_from_str(&item.end_time, "%H:%M") else {
return false;
};
let time = now.time(); let time = now.time();
let today = now.weekday().number_from_monday(); let today = now.weekday().number_from_monday();
if start == end { if start == end {
@@ -63,11 +82,15 @@ fn schedule_week_mask(item: &Schedule) -> Option<Vec<bool>> {
let end_minute = (end.hour() * 60 + end.minute()) as usize; let end_minute = (end.hour() * 60 + end.minute()) as usize;
let mut mask = vec![false; 7 * 24 * 60]; let mut mask = vec![false; 7 * 24 * 60];
for weekday in &item.weekdays { for weekday in &item.weekdays {
if !(1..=7).contains(weekday) { return None; } if !(1..=7).contains(weekday) {
return None;
}
let day = (*weekday as usize) - 1; let day = (*weekday as usize) - 1;
let mark = |mask: &mut [bool], day: usize, from: usize, to: usize| { let mark = |mask: &mut [bool], day: usize, from: usize, to: usize| {
let base = (day % 7) * 24 * 60; let base = (day % 7) * 24 * 60;
for minute in from..to { mask[base + minute] = true; } for minute in from..to {
mask[base + minute] = true;
}
}; };
if start_minute == end_minute { if start_minute == end_minute {
mark(&mut mask, day, start_minute, 24 * 60); mark(&mut mask, day, start_minute, 24 * 60);
@@ -83,16 +106,23 @@ fn schedule_week_mask(item: &Schedule) -> Option<Vec<bool>> {
} }
pub(crate) fn schedules_overlap(a: &Schedule, b: &Schedule) -> bool { pub(crate) fn schedules_overlap(a: &Schedule, b: &Schedule) -> bool {
if !a.enabled || !b.enabled || a.zone_id != b.zone_id { return false; } if !a.enabled || !b.enabled || a.zone_id != b.zone_id {
let (Some(left), Some(right)) = (schedule_week_mask(a), schedule_week_mask(b)) else { return false; }; return false;
}
let (Some(left), Some(right)) = (schedule_week_mask(a), schedule_week_mask(b)) else {
return false;
};
left.iter().zip(right.iter()).any(|(a, b)| *a && *b) left.iter().zip(right.iter()).any(|(a, b)| *a && *b)
} }
fn previous_weekday(day: Weekday) -> Weekday { fn previous_weekday(day: Weekday) -> Weekday {
match day { match day {
Weekday::Mon => Weekday::Sun, Weekday::Tue => Weekday::Mon, Weekday::Wed => Weekday::Tue, Weekday::Mon => Weekday::Sun,
Weekday::Thu => Weekday::Wed, Weekday::Fri => Weekday::Thu, Weekday::Sat => Weekday::Fri, Weekday::Tue => Weekday::Mon,
Weekday::Wed => Weekday::Tue,
Weekday::Thu => Weekday::Wed,
Weekday::Fri => Weekday::Thu,
Weekday::Sat => Weekday::Fri,
Weekday::Sun => Weekday::Sat, Weekday::Sun => Weekday::Sat,
} }
} }
+41 -11
View File
@@ -1,5 +1,7 @@
fn profile_setpoint(zone: &Zone, preset: &str, mode: &str) -> f64 { fn profile_setpoint(zone: &Zone, preset: &str, mode: &str) -> f64 {
if zone.profile_version == 0 && preset == "comfort" { return zone.setpoint; } if zone.profile_version == 0 && preset == "comfort" {
return zone.setpoint;
}
match (mode, preset) { match (mode, preset) {
("heat", "sleep") => zone.heat_sleep_setpoint, ("heat", "sleep") => zone.heat_sleep_setpoint,
("heat", "away") => zone.heat_away_setpoint, ("heat", "away") => zone.heat_away_setpoint,
@@ -21,7 +23,10 @@ fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) ->
if item.preset == "custom" { if item.preset == "custom" {
("custom".into(), item.setpoint) ("custom".into(), item.setpoint)
} else { } else {
(item.preset.clone(), profile_setpoint(zone, &item.preset, mode)) (
item.preset.clone(),
profile_setpoint(zone, &item.preset, mode),
)
} }
} else { } else {
// Comfort is only a fallback target for an explicit controller (local/group/etc.). // Comfort is only a fallback target for an explicit controller (local/group/etc.).
@@ -36,20 +41,31 @@ fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) ->
/// Return whether some thermostat source currently asks this zone to participate in climate /// Return whether some thermostat source currently asks this zone to participate in climate
/// control. Heat/Cool configuration plus the implicit Comfort fallback is intentionally not a /// control. Heat/Cool configuration plus the implicit Comfort fallback is intentionally not a
/// source on its own. /// source on its own.
fn zone_has_thermostat_intent_source(zone: &Zone, schedule: Option<&Schedule>, now: DateTime<Utc>) -> bool { fn zone_has_thermostat_intent_source(
zone: &Zone,
schedule: Option<&Schedule>,
now: DateTime<Utc>,
) -> bool {
zone.local_thermostat_power == Some(true) zone.local_thermostat_power == Some(true)
|| temporary_quick_thermostat_is_active(zone, now) || temporary_quick_thermostat_is_active(zone, now)
|| zone.manual_preset.is_some() || zone.manual_preset.is_some()
|| zone.manual_setpoint.is_some() || zone.manual_setpoint.is_some()
|| zone.control_source.starts_with("group:") || zone.control_source.starts_with("group:")
|| matches!(zone.control_source.as_str(), "automation.device" | "house_power") || matches!(
zone.control_source.as_str(),
"automation.device" | "house_power"
)
|| schedule.is_some() || schedule.is_some()
} }
/// Automatic thermostat arbitration may run only when an explicit source exists and no higher /// Automatic thermostat arbitration may run only when an explicit source exists and no higher
/// priority OFF/manual gate blocks it. This keeps a resumed/global/group-OFF zone physically OFF /// priority OFF/manual gate blocks it. This keeps a resumed/global/group-OFF zone physically OFF
/// while it waits for the next schedule window or another explicit thermostat request. /// while it waits for the next schedule window or another explicit thermostat request.
fn zone_has_active_thermostat_intent(zone: &Zone, schedule: Option<&Schedule>, now: DateTime<Utc>) -> bool { fn zone_has_active_thermostat_intent(
zone: &Zone,
schedule: Option<&Schedule>,
now: DateTime<Utc>,
) -> bool {
zone.enabled zone.enabled
&& !zone.device_manual_override && !zone.device_manual_override
&& zone.local_thermostat_power != Some(false) && zone.local_thermostat_power != Some(false)
@@ -58,7 +74,11 @@ fn zone_has_active_thermostat_intent(zone: &Zone, schedule: Option<&Schedule>, n
pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) { pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) {
let configured_mode = effective_zone_mode(zone, house_mode); let configured_mode = effective_zone_mode(zone, house_mode);
let target_mode = if configured_mode == "off" { zone.mode.as_str() } else { configured_mode.as_str() }; let target_mode = if configured_mode == "off" {
zone.mode.as_str()
} else {
configured_mode.as_str()
};
let schedule = active_schedule_for_zone(zone, schedules, Local::now()); let schedule = active_schedule_for_zone(zone, schedules, Local::now());
let has_intent_source = zone_has_thermostat_intent_source(zone, schedule, Utc::now()); let has_intent_source = zone_has_thermostat_intent_source(zone, schedule, Utc::now());
@@ -95,12 +115,23 @@ pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], hous
fn effective_zone_mode(zone: &Zone, house_mode: &str) -> String { fn effective_zone_mode(zone: &Zone, house_mode: &str) -> String {
if temporary_quick_thermostat_is_active(zone, Utc::now()) { if temporary_quick_thermostat_is_active(zone, Utc::now()) {
if let Some(mode) = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.active_mode.as_deref()) { if let Some(mode) = zone
if matches!(mode, "cool" | "heat") { return mode.to_string(); } .temporary_quick_thermostat
.as_ref()
.and_then(|session| session.active_mode.as_deref())
{
if matches!(mode, "cool" | "heat") {
return mode.to_string();
}
} }
} }
let configured = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() }; let configured = if zone.inherit_house_mode {
let scoped_manual = zone.local_thermostat_power == Some(true) || zone.control_source.starts_with("group:"); house_mode
} else {
zone.mode.as_str()
};
let scoped_manual =
zone.local_thermostat_power == Some(true) || zone.control_source.starts_with("group:");
if scoped_manual && configured == "off" { if scoped_manual && configured == "off" {
// Explicit local/group control is independent from house "Do not control". Reuse the // Explicit local/group control is independent from house "Do not control". Reuse the
// zone's last concrete heat/cool mode instead of turning a manual action into a no-op. // zone's last concrete heat/cool mode instead of turning a manual action into a no-op.
@@ -109,4 +140,3 @@ fn effective_zone_mode(zone: &Zone, house_mode: &str) -> String {
configured.to_string() configured.to_string()
} }
} }
+76 -21
View File
@@ -1,4 +1,8 @@
fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, external_temperature: Option<f64>) -> (Option<f64>, String, bool) { fn select_zone_temperature(
zone: &Zone,
device_temperature: Option<f64>,
external_temperature: Option<f64>,
) -> (Option<f64>, String, bool) {
match zone.sensor_source.as_str() { match zone.sensor_source.as_str() {
"home_assistant" => match (external_temperature, device_temperature) { "home_assistant" => match (external_temperature, device_temperature) {
(Some(value), _) => (Some(value), "external".into(), false), (Some(value), _) => (Some(value), "external".into(), false),
@@ -12,7 +16,11 @@ fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, externa
} else { } else {
let external_weight = zone.external_sensor_weight.clamp(0.0, 1.0); let external_weight = zone.external_sensor_weight.clamp(0.0, 1.0);
let value = device * (1.0 - external_weight) + external * external_weight; let value = device * (1.0 - external_weight) + external * external_weight;
(Some((value * 10.0).round() / 10.0), "combined".into(), false) (
Some((value * 10.0).round() / 10.0),
"combined".into(),
false,
)
} }
} }
(Some(value), None) => (Some(value), "device_fallback".into(), false), (Some(value), None) => (Some(value), "device_fallback".into(), false),
@@ -27,19 +35,29 @@ fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, externa
} }
fn adjustment_allowed(zone: &Zone) -> bool { fn adjustment_allowed(zone: &Zone) -> bool {
let Some(last) = zone.last_action_at else { return true; }; let Some(last) = zone.last_action_at else {
return true;
};
(Utc::now() - last).num_seconds().max(0) as u64 >= zone.min_adjust_seconds.max(15) (Utc::now() - last).num_seconds().max(0) as u64 >= zone.min_adjust_seconds.max(15)
} }
fn external_room_sensor_cooling_assist(mode: &str, control_source: &str) -> f64 { fn external_room_sensor_cooling_assist(mode: &str, control_source: &str) -> f64 {
if mode == "cool" && matches!(control_source, "external" | "combined") { 1.0 } else { 0.0 } if mode == "cool" && matches!(control_source, "external" | "combined") {
1.0
} else {
0.0
}
} }
fn effective_sensor_stale_after_seconds(zone_value: u64, global_value: u64) -> u64 { fn effective_sensor_stale_after_seconds(zone_value: u64, global_value: u64) -> u64 {
let global = global_value.clamp(30, 86_400); let global = global_value.clamp(30, 86_400);
// 0 and the historical hidden default (300 s) mean "inherit the HA setting". // 0 and the historical hidden default (300 s) mean "inherit the HA setting".
// A non-default value supplied through the existing zone API remains a per-zone override. // A non-default value supplied through the existing zone API remains a per-zone override.
if zone_value == 0 || zone_value == 300 { global } else { zone_value.clamp(30, 86_400) } if zone_value == 0 || zone_value == 300 {
global
} else {
zone_value.clamp(30, 86_400)
}
} }
fn round_device_setpoint(mode: &str, demand: bool, value: f64) -> f64 { fn round_device_setpoint(mode: &str, demand: bool, value: f64) -> f64 {
@@ -53,7 +71,9 @@ fn round_device_setpoint(mode: &str, demand: bool, value: f64) -> f64 {
} }
fn outdoor_assist_offset(mode: &str, outdoor: Option<f64>, room: f64, target: f64) -> f64 { fn outdoor_assist_offset(mode: &str, outdoor: Option<f64>, room: f64, target: f64) -> f64 {
let Some(outdoor) = outdoor else { return 0.0; }; let Some(outdoor) = outdoor else {
return 0.0;
};
let room_error = (room - target).abs(); let room_error = (room - target).abs();
let weather = match mode { let weather = match mode {
"heat" => ((5.0 - outdoor) / 15.0).clamp(0.0, 1.0), "heat" => ((5.0 - outdoor) / 15.0).clamp(0.0, 1.0),
@@ -72,7 +92,9 @@ fn smart_quiet_command(
night_active: bool, night_active: bool,
night_force_quiet: bool, night_force_quiet: bool,
) -> Option<bool> { ) -> Option<bool> {
if !quiet_supported { return None; } if !quiet_supported {
return None;
}
if night_enabled && night_force_quiet && night_active { if night_enabled && night_force_quiet && night_active {
return if device_quiet { None } else { Some(true) }; return if device_quiet { None } else { Some(true) };
} }
@@ -84,13 +106,19 @@ fn smart_quiet_command(
// Smart Quiet follows demand transitions. Do not keep reasserting Quiet while a // Smart Quiet follows demand transitions. Do not keep reasserting Quiet while a
// satisfied room remains in standby: some units report Quiet=false again even after // satisfied room remains in standby: some units report Quiet=false again even after
// accepting the command, which otherwise produces a beep every adjustment interval. // accepting the command, which otherwise produces a beep every adjustment interval.
if previous_demand && !demand && !device_quiet { return Some(true); } if previous_demand && !demand && !device_quiet {
if !previous_demand && demand && device_quiet { return Some(false); } return Some(true);
}
if !previous_demand && demand && device_quiet {
return Some(false);
}
return None; return None;
} }
// Without Smart Fan, Quiet can only have been requested by scheduled night mode, // Without Smart Fan, Quiet can only have been requested by scheduled night mode,
// so release it after the night window ends. // so release it after the night window ends.
if night_enabled && night_force_quiet && device_quiet { return Some(false); } if night_enabled && night_force_quiet && device_quiet {
return Some(false);
}
None None
} }
@@ -101,40 +129,67 @@ fn native_sleep_command(
sleep_supported: bool, sleep_supported: bool,
device_sleep: bool, device_sleep: bool,
) -> Option<bool> { ) -> Option<bool> {
if !sleep_supported { return None; } if !sleep_supported {
return None;
}
if night_enabled && use_native_sleep && night_active { if night_enabled && use_native_sleep && night_active {
return if device_sleep { None } else { Some(true) }; return if device_sleep { None } else { Some(true) };
} }
// If night mode ended or native Sleep was disabled in settings, remove a previously // If night mode ended or native Sleep was disabled in settings, remove a previously
// active device Sleep flag instead of leaving it latched indefinitely. // active device Sleep flag instead of leaving it latched indefinitely.
if device_sleep { return Some(false); } if device_sleep {
return Some(false);
}
None None
} }
fn night_limited_fan_speed(requested: u8, max_fan: u8) -> u8 { fn night_limited_fan_speed(requested: u8, max_fan: u8) -> u8 {
let max_fan = max_fan.clamp(1, 5); let max_fan = max_fan.clamp(1, 5);
if requested == 0 { 1 } else { requested.min(max_fan) } if requested == 0 {
1
} else {
requested.min(max_fan)
}
} }
pub fn night_mode_active(settings: &NightModeSettings, time: NaiveTime) -> bool { pub fn night_mode_active(settings: &NightModeSettings, time: NaiveTime) -> bool {
if !settings.enabled { return false; } if !settings.enabled {
let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return false; }; return false;
let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return false; }; }
if start == end { return true; } let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else {
if start < end { time >= start && time < end } else { time >= start || time < end } return false;
};
let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else {
return false;
};
if start == end {
return true;
}
if start < end {
time >= start && time < end
} else {
time >= start || time < end
}
} }
fn smart_fan_speed(mode: &str, room: f64, target: f64, outdoor: Option<f64>, demand: bool) -> u8 { fn smart_fan_speed(mode: &str, room: f64, target: f64, outdoor: Option<f64>, demand: bool) -> u8 {
// When the thermostat is satisfied, keep airflow quiet instead of leaving the // When the thermostat is satisfied, keep airflow quiet instead of leaving the
// unit in Auto. The caller sends this together with the standby setpoint in // unit in Auto. The caller sends this together with the standby setpoint in
// the same GREE command, so e.g. 21 C reached -> 19 C + Low fan for heating. // the same GREE command, so e.g. 21 C reached -> 19 C + Low fan for heating.
if !demand { return 1; } if !demand {
return 1;
}
let error = (room - target).abs(); let error = (room - target).abs();
let extreme_weather = match (mode, outdoor) { let extreme_weather = match (mode, outdoor) {
("heat", Some(value)) => value <= 0.0, ("heat", Some(value)) => value <= 0.0,
(_, Some(value)) => value >= 32.0, (_, Some(value)) => value >= 32.0,
_ => false, _ => false,
}; };
if error >= 2.0 || extreme_weather { 3 } else if error >= 1.0 { 2 } else { 0 } if error >= 2.0 || extreme_weather {
3
} else if error >= 1.0 {
2
} else {
0
}
} }
+248 -72
View File
@@ -1,12 +1,17 @@
pub fn temporary_quick_thermostat_is_active(zone: &Zone, now: DateTime<Utc>) -> bool { pub fn temporary_quick_thermostat_is_active(zone: &Zone, now: DateTime<Utc>) -> bool {
zone.temporary_quick_thermostat.as_ref() zone.temporary_quick_thermostat
.as_ref()
.and_then(|session| session.activated_at.as_ref()) .and_then(|session| session.activated_at.as_ref())
.map(|activated_at| activated_at <= &now) .map(|activated_at| activated_at <= &now)
.unwrap_or(false) .unwrap_or(false)
} }
fn temporary_quick_thermostat_hard_deadline(session: &TemporaryQuickThermostat) -> Option<DateTime<Utc>> { fn temporary_quick_thermostat_hard_deadline(
if session.state == "paused_manual" { return None; } session: &TemporaryQuickThermostat,
) -> Option<DateTime<Utc>> {
if session.state == "paused_manual" {
return None;
}
match (session.expires_at, session.safety_expires_at) { match (session.expires_at, session.safety_expires_at) {
(Some(a), Some(b)) => Some(a.min(b)), (Some(a), Some(b)) => Some(a.min(b)),
(Some(a), None) => Some(a), (Some(a), None) => Some(a),
@@ -15,10 +20,14 @@ fn temporary_quick_thermostat_hard_deadline(session: &TemporaryQuickThermostat)
} }
} }
fn temporary_quick_thermostat_next_deadline(session: &TemporaryQuickThermostat) -> Option<DateTime<Utc>> { fn temporary_quick_thermostat_next_deadline(
session: &TemporaryQuickThermostat,
) -> Option<DateTime<Utc>> {
let hard = temporary_quick_thermostat_hard_deadline(session); let hard = temporary_quick_thermostat_hard_deadline(session);
let hold = if session.finish_kind == "temperature_stable" && session.hold_seconds > 0 { let hold = if session.finish_kind == "temperature_stable" && session.hold_seconds > 0 {
session.condition_started_at.map(|started| started + chrono::Duration::seconds(session.hold_seconds as i64)) session
.condition_started_at
.map(|started| started + chrono::Duration::seconds(session.hold_seconds as i64))
} else { } else {
None None
}; };
@@ -42,11 +51,19 @@ fn temporary_quick_thermostat_wakeup_at(zone: &Zone, now: DateTime<Utc>) -> Opti
/// Finish an active temporary session and apply climate changes that were deferred while /// Finish an active temporary session and apply climate changes that were deferred while
/// it owned the zone. Pending-session cancellation should simply remove the session instead. /// it owned the zone. Pending-session cancellation should simply remove the session instead.
pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) -> bool { pub fn finish_temporary_quick_thermostat(
zone: &mut Zone,
schedules: &[Schedule],
house_mode: &str,
) -> bool {
let now = Utc::now(); let now = Utc::now();
let was_active = temporary_quick_thermostat_is_active(zone, now); let was_active = temporary_quick_thermostat_is_active(zone, now);
let Some(session) = zone.temporary_quick_thermostat.take() else { return false; }; let Some(session) = zone.temporary_quick_thermostat.take() else {
if !was_active { return false; } return false;
};
if !was_active {
return false;
}
zone.local_thermostat_power = session.restore_local_thermostat_power; zone.local_thermostat_power = session.restore_local_thermostat_power;
zone.local_thermostat_resume_at = session.restore_local_thermostat_resume_at; zone.local_thermostat_resume_at = session.restore_local_thermostat_resume_at;
@@ -75,8 +92,11 @@ pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule]
zone.manual_override_until = None; zone.manual_override_until = None;
} else if matches!(preset, "comfort" | "sleep" | "away" | "custom") { } else if matches!(preset, "comfort" | "sleep" | "away" | "custom") {
zone.manual_preset = Some(preset.to_string()); zone.manual_preset = Some(preset.to_string());
if preset != "custom" { zone.manual_setpoint = None; } if preset != "custom" {
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now()); zone.manual_setpoint = None;
}
zone.manual_override_until =
next_schedule_boundary_utc(&zone.id, schedules, Local::now());
} }
} }
// A deferred custom temperature is valid only while the final deferred preset is custom. // A deferred custom temperature is valid only while the final deferred preset is custom.
@@ -87,7 +107,8 @@ pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule]
zone.setpoint = setpoint; zone.setpoint = setpoint;
zone.manual_preset = Some("custom".into()); zone.manual_preset = Some("custom".into());
zone.manual_setpoint = Some(setpoint); zone.manual_setpoint = Some(setpoint);
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now()); zone.manual_override_until =
next_schedule_boundary_utc(&zone.id, schedules, Local::now());
} }
} }
refresh_zone_runtime_target(zone, schedules, house_mode); refresh_zone_runtime_target(zone, schedules, house_mode);
@@ -97,27 +118,53 @@ pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule]
true true
} }
async fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result<Vec<String>, AppError> { async fn expire_temporary_quick_thermostats(
state: &AppState,
zones: &mut [Zone],
schedules: &[Schedule],
house_mode: &str,
) -> Result<Vec<String>, AppError> {
let now = Utc::now(); let now = Utc::now();
let mut restored_disabled_zones = Vec::new(); let mut restored_disabled_zones = Vec::new();
for zone in zones.iter_mut() { for zone in zones.iter_mut() {
let zone_id = zone.id.clone(); let zone_id = zone.id.clone();
let _zone_guard = state.lock_zone_operation(&zone_id).await; let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; }; let Some(snapshot) = state.db.get_zone(&zone_id)? else {
continue;
};
let _device_guard = state.lock_device_operation(&snapshot.device_id).await; let _device_guard = state.lock_device_operation(&snapshot.device_id).await;
let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; let Some(latest) = state.db.get_zone(&zone_id)? else {
continue;
};
*zone = latest; *zone = latest;
let active_under_manual = zone.device_manual_override let active_under_manual = zone.device_manual_override
&& zone.temporary_quick_thermostat.as_ref().and_then(|session| session.activated_at).is_some(); && zone
.temporary_quick_thermostat
.as_ref()
.and_then(|session| session.activated_at)
.is_some();
if active_under_manual { if active_under_manual {
set_temporary_wait_state(state, zone, "paused_manual", now)?; set_temporary_wait_state(state, zone, "paused_manual", now)?;
continue; continue;
} }
let Some((deadline, finish_kind, restore_zone_enabled)) = zone.temporary_quick_thermostat.as_ref() let Some((deadline, finish_kind, restore_zone_enabled)) = zone
.and_then(|session| temporary_quick_thermostat_hard_deadline(session) .temporary_quick_thermostat
.map(|deadline| (deadline, session.finish_kind.clone(), session.restore_zone_enabled))) .as_ref()
else { continue; }; .and_then(|session| {
if deadline > now { continue; } temporary_quick_thermostat_hard_deadline(session).map(|deadline| {
(
deadline,
session.finish_kind.clone(),
session.restore_zone_enabled,
)
})
})
else {
continue;
};
if deadline > now {
continue;
}
let was_activated = temporary_quick_thermostat_is_active(zone, now); let was_activated = temporary_quick_thermostat_is_active(zone, now);
let restores_disabled = was_activated && restore_zone_enabled == Some(false); let restores_disabled = was_activated && restore_zone_enabled == Some(false);
if was_activated { if was_activated {
@@ -130,17 +177,31 @@ async fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone]
zone.updated_at = now; zone.updated_at = now;
state.db.save_zone(zone)?; state.db.save_zone(zone)?;
state.broadcast("zone.updated", serde_json::to_value(&*zone)?); state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({ state.log(
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "info",
"reason": if was_activated { "deadline" } else { "expired_before_activation" } "zone.temporary_quick_thermostat_finished",
})); &format!("Temporary Quick Thermostat finished for {}", zone.name),
if restores_disabled { restored_disabled_zones.push(zone.id.clone()); } json!({
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind,
"reason": if was_activated { "deadline" } else { "expired_before_activation" }
}),
);
if restores_disabled {
restored_disabled_zones.push(zone.id.clone());
}
} }
Ok(restored_disabled_zones) Ok(restored_disabled_zones)
} }
fn set_temporary_wait_state(state: &AppState, zone: &mut Zone, value: &str, now: DateTime<Utc>) -> Result<(), AppError> { fn set_temporary_wait_state(
let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return Ok(()); }; state: &AppState,
zone: &mut Zone,
value: &str,
now: DateTime<Utc>,
) -> Result<(), AppError> {
let Some(session) = zone.temporary_quick_thermostat.as_mut() else {
return Ok(());
};
let mut changed = false; let mut changed = false;
if session.state != value { if session.state != value {
session.state = value.to_string(); session.state = value.to_string();
@@ -150,7 +211,9 @@ fn set_temporary_wait_state(state: &AppState, zone: &mut Zone, value: &str, now:
session.paused_at = Some(now); session.paused_at = Some(now);
changed = true; changed = true;
} }
if !changed { return Ok(()); } if !changed {
return Ok(());
}
session.condition_started_at = None; session.condition_started_at = None;
session.condition_last_observed_at = None; session.condition_last_observed_at = None;
zone.updated_at = now; zone.updated_at = now;
@@ -169,28 +232,46 @@ async fn activate_due_temporary_quick_thermostats(
for zone in zones.iter_mut() { for zone in zones.iter_mut() {
let zone_id = zone.id.clone(); let zone_id = zone.id.clone();
let _zone_guard = state.lock_zone_operation(&zone_id).await; let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; }; let Some(snapshot) = state.db.get_zone(&zone_id)? else {
continue;
};
let _device_guard = state.lock_device_operation(&snapshot.device_id).await; let _device_guard = state.lock_device_operation(&snapshot.device_id).await;
let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; let Some(latest) = state.db.get_zone(&zone_id)? else {
continue;
};
*zone = latest; *zone = latest;
let Some((session_state, started_at)) = zone.temporary_quick_thermostat.as_ref() let Some((session_state, started_at)) = zone
.temporary_quick_thermostat
.as_ref()
.map(|session| (session.state.clone(), session.started_at)) .map(|session| (session.state.clone(), session.started_at))
else { continue; }; else {
continue;
};
if temporary_quick_thermostat_is_active(zone, now) { if temporary_quick_thermostat_is_active(zone, now) {
if session_state != "active" && !zone.device_manual_override { if session_state != "active" && !zone.device_manual_override {
set_temporary_wait_state(state, zone, "active", now)?; set_temporary_wait_state(state, zone, "active", now)?;
} }
continue; continue;
} }
if started_at > now { continue; } if started_at > now {
continue;
}
if zone.device_manual_override { if zone.device_manual_override {
set_temporary_wait_state(state, zone, "paused_manual", now)?; set_temporary_wait_state(state, zone, "paused_manual", now)?;
continue; continue;
} }
let (temperature_target, duration_seconds, safety_duration_seconds, finish_kind) = { let (temperature_target, duration_seconds, safety_duration_seconds, finish_kind) = {
let session = zone.temporary_quick_thermostat.as_ref().expect("temporary session checked above"); let session = zone
(session.temperature_target, session.duration_seconds, session.safety_duration_seconds, session.finish_kind.clone()) .temporary_quick_thermostat
.as_ref()
.expect("temporary session checked above");
(
session.temperature_target,
session.duration_seconds,
session.safety_duration_seconds,
session.finish_kind.clone(),
)
}; };
let target = temperature_target let target = temperature_target
.or(zone.manual_setpoint) .or(zone.manual_setpoint)
@@ -203,11 +284,21 @@ async fn activate_due_temporary_quick_thermostats(
let restore_manual_preset = zone.manual_preset.clone(); let restore_manual_preset = zone.manual_preset.clone();
let restore_manual_setpoint = zone.manual_setpoint; let restore_manual_setpoint = zone.manual_setpoint;
let restore_manual_override_until = zone.manual_override_until; let restore_manual_override_until = zone.manual_override_until;
let configured_mode = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() }; let configured_mode = if zone.inherit_house_mode {
let active_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() }; house_mode
} else {
zone.mode.as_str()
};
let active_mode = if configured_mode == "off" {
zone.mode.clone()
} else {
configured_mode.to_string()
};
let schedule_boundary = if finish_kind == "schedule_boundary" { let schedule_boundary = if finish_kind == "schedule_boundary" {
next_schedule_boundary_utc(&zone.id, schedules, Local::now()) next_schedule_boundary_utc(&zone.id, schedules, Local::now())
} else { None }; } else {
None
};
if finish_kind == "schedule_boundary" && schedule_boundary.is_none() { if finish_kind == "schedule_boundary" && schedule_boundary.is_none() {
zone.temporary_quick_thermostat = None; zone.temporary_quick_thermostat = None;
zone.updated_at = now; zone.updated_at = now;
@@ -247,12 +338,17 @@ async fn activate_due_temporary_quick_thermostats(
session.condition_last_observed_at = None; session.condition_last_observed_at = None;
session.paused_at = None; session.paused_at = None;
if session.finish_kind == "duration" { if session.finish_kind == "duration" {
session.expires_at = duration_seconds.map(|seconds| now + chrono::Duration::seconds(seconds as i64)); session.expires_at =
duration_seconds.map(|seconds| now + chrono::Duration::seconds(seconds as i64));
} else if session.finish_kind == "schedule_boundary" { } else if session.finish_kind == "schedule_boundary" {
session.expires_at = schedule_boundary; session.expires_at = schedule_boundary;
} }
if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { if matches!(
session.safety_expires_at = safety_duration_seconds.map(|seconds| now + chrono::Duration::seconds(seconds as i64)); session.finish_kind.as_str(),
"temperature_reached" | "temperature_stable"
) {
session.safety_expires_at = safety_duration_seconds
.map(|seconds| now + chrono::Duration::seconds(seconds as i64));
} }
} }
zone.updated_at = now; zone.updated_at = now;
@@ -265,25 +361,62 @@ async fn activate_due_temporary_quick_thermostats(
Ok(()) Ok(())
} }
async fn ensure_device_off_after_temporary_disabled_restore(state: &AppState, zone: &Zone, device: &Device) { async fn ensure_device_off_after_temporary_disabled_restore(
if zone.enabled || zone.device_manual_override || !device.enabled || !device.online || device.communication_failures > 0 || !device.power { return; } state: &AppState,
zone: &Zone,
device: &Device,
) {
if zone.enabled
|| zone.device_manual_override
|| !device.enabled
|| !device.online
|| device.communication_failures > 0
|| !device.power
{
return;
}
let _device_guard = state.lock_device_operation(&zone.device_id).await; let _device_guard = state.lock_device_operation(&zone.device_id).await;
let should_stop = state.db.get_zone(&zone.id).ok().flatten() let should_stop = state
.map(|latest| !latest.enabled .db
&& !latest.device_manual_override .get_zone(&zone.id)
&& latest.temporary_quick_thermostat.is_none() .ok()
&& latest.local_thermostat_power.is_none()) .flatten()
.map(|latest| {
!latest.enabled
&& !latest.device_manual_override
&& latest.temporary_quick_thermostat.is_none()
&& latest.local_thermostat_power.is_none()
})
.unwrap_or(false); .unwrap_or(false);
if !should_stop { return; } if !should_stop {
if let Err(err) = send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await { return;
state.log("error", "zone.temporary_quick_thermostat_poweroff_error", &err.to_string(), json!({ }
"zone_id": zone.id, "device_id": zone.device_id if let Err(err) = send_command_locked(
})); state,
&zone.device_id,
DeviceCommand {
power: Some(false),
..Default::default()
},
)
.await
{
state.log(
"error",
"zone.temporary_quick_thermostat_poweroff_error",
&err.to_string(),
json!({
"zone_id": zone.id, "device_id": zone.device_id
}),
);
} }
} }
fn temporary_temperature_condition_met(zone: &Zone, session: &TemporaryQuickThermostat) -> bool { fn temporary_temperature_condition_met(zone: &Zone, session: &TemporaryQuickThermostat) -> bool {
let (Some(current), Some(target)) = (zone.current_temperature, session.temperature_target) else { return false; }; let (Some(current), Some(target)) = (zone.current_temperature, session.temperature_target)
else {
return false;
};
let tolerance = session.tolerance_c.max(0.0); let tolerance = session.tolerance_c.max(0.0);
match session.temperature_operator.as_deref().unwrap_or("within") { match session.temperature_operator.as_deref().unwrap_or("within") {
"at_or_below" => current <= target + tolerance, "at_or_below" => current <= target + tolerance,
@@ -300,11 +433,22 @@ fn evaluate_temporary_quick_thermostat_condition(
sample_at: Option<DateTime<Utc>>, sample_at: Option<DateTime<Utc>>,
max_gap_seconds: u64, max_gap_seconds: u64,
) -> Option<String> { ) -> Option<String> {
if !temporary_quick_thermostat_is_active(zone, now) || zone.device_manual_override { return None; } if !temporary_quick_thermostat_is_active(zone, now) || zone.device_manual_override {
let is_condition = zone.temporary_quick_thermostat.as_ref() return None;
.map(|session| matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable")) }
let is_condition = zone
.temporary_quick_thermostat
.as_ref()
.map(|session| {
matches!(
session.finish_kind.as_str(),
"temperature_reached" | "temperature_stable"
)
})
.unwrap_or(false); .unwrap_or(false);
if !is_condition { return None; } if !is_condition {
return None;
}
let Some(sample_at) = sample_at else { let Some(sample_at) = sample_at else {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() { if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
@@ -313,20 +457,33 @@ fn evaluate_temporary_quick_thermostat_condition(
} }
return None; return None;
}; };
let last_observed = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.condition_last_observed_at); let last_observed = zone
if last_observed.map(|last| sample_at <= last).unwrap_or(false) { return None; } .temporary_quick_thermostat
.as_ref()
.and_then(|session| session.condition_last_observed_at);
if last_observed.map(|last| sample_at <= last).unwrap_or(false) {
return None;
}
let gap_broken = last_observed let gap_broken = last_observed
.map(|last| sample_at.signed_duration_since(last).num_seconds() > max_gap_seconds.max(1) as i64) .map(|last| {
sample_at.signed_duration_since(last).num_seconds() > max_gap_seconds.max(1) as i64
})
.unwrap_or(false); .unwrap_or(false);
let met = zone.temporary_quick_thermostat.as_ref() let met = zone
.temporary_quick_thermostat
.as_ref()
.map(|session| temporary_temperature_condition_met(zone, session))?; .map(|session| temporary_temperature_condition_met(zone, session))?;
let session = zone.temporary_quick_thermostat.as_mut()?; let session = zone.temporary_quick_thermostat.as_mut()?;
session.condition_last_observed_at = Some(sample_at); session.condition_last_observed_at = Some(sample_at);
if gap_broken { session.condition_started_at = None; } if gap_broken {
session.condition_started_at = None;
}
match session.finish_kind.as_str() { match session.finish_kind.as_str() {
"temperature_reached" => { "temperature_reached" => {
if met { return Some("temperature_reached".into()); } if met {
return Some("temperature_reached".into());
}
session.condition_started_at = None; session.condition_started_at = None;
} }
"temperature_stable" => { "temperature_stable" => {
@@ -335,7 +492,10 @@ fn evaluate_temporary_quick_thermostat_condition(
return None; return None;
} }
let started = session.condition_started_at.get_or_insert(sample_at); let started = session.condition_started_at.get_or_insert(sample_at);
if session.hold_seconds == 0 || sample_at.signed_duration_since(*started).num_seconds() >= session.hold_seconds as i64 { if session.hold_seconds == 0
|| sample_at.signed_duration_since(*started).num_seconds()
>= session.hold_seconds as i64
{
return Some("temperature_stable".into()); return Some("temperature_stable".into());
} }
} }
@@ -344,14 +504,23 @@ fn evaluate_temporary_quick_thermostat_condition(
None None
} }
async fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result<(), AppError> { async fn expire_local_thermostat_overrides(
state: &AppState,
zones: &mut [Zone],
schedules: &[Schedule],
house_mode: &str,
) -> Result<(), AppError> {
let now = Utc::now(); let now = Utc::now();
for zone in zones.iter_mut() { for zone in zones.iter_mut() {
let zone_id = zone.id.clone(); let zone_id = zone.id.clone();
let _zone_guard = state.lock_zone_operation(&zone_id).await; let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; }; let Some(snapshot) = state.db.get_zone(&zone_id)? else {
continue;
};
let _device_guard = state.lock_device_operation(&snapshot.device_id).await; let _device_guard = state.lock_device_operation(&snapshot.device_id).await;
let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; let Some(latest) = state.db.get_zone(&zone_id)? else {
continue;
};
*zone = latest; *zone = latest;
// A direct device/pilot takeover has higher priority than the local-OFF hand-back. // A direct device/pilot takeover has higher priority than the local-OFF hand-back.
// Do not let the old timer expire underneath someone who is actively controlling // Do not let the old timer expire underneath someone who is actively controlling
@@ -361,9 +530,17 @@ async fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone],
// local_thermostat_power=false with resume_at=None is a deliberate indefinite OFF // local_thermostat_power=false with resume_at=None is a deliberate indefinite OFF
// (notably the state produced by group OFF) and must stay off until the user/group // (notably the state produced by group OFF) and must stay off until the user/group
// explicitly turns it back on or resumes automation. // explicitly turns it back on or resumes automation.
if !local_thermostat_handback_is_active(zone) { continue; } if !local_thermostat_handback_is_active(zone) {
let expired = zone.local_thermostat_resume_at.as_ref().map(|at| at <= &now).unwrap_or(false); continue;
if !expired { continue; } }
let expired = zone
.local_thermostat_resume_at
.as_ref()
.map(|at| at <= &now)
.unwrap_or(false);
if !expired {
continue;
}
reset_local_thermostat_override(zone); reset_local_thermostat_override(zone);
refresh_zone_runtime_target(zone, schedules, house_mode); refresh_zone_runtime_target(zone, schedules, house_mode);
zone.updated_at = now.clone(); zone.updated_at = now.clone();
@@ -375,4 +552,3 @@ async fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone],
} }
Ok(()) Ok(())
} }
+62 -17
View File
@@ -1,5 +1,11 @@
fn persist_zone_cycle(state: &AppState, computed: &Zone, cycle_started_at: DateTime<Utc>) -> Result<Zone, AppError> { fn persist_zone_cycle(
let Some(mut latest) = state.db.get_zone(&computed.id)? else { return Ok(computed.clone()); }; state: &AppState,
computed: &Zone,
cycle_started_at: DateTime<Utc>,
) -> Result<Zone, AppError> {
let Some(mut latest) = state.db.get_zone(&computed.id)? else {
return Ok(computed.clone());
};
if latest.updated_at <= cycle_started_at { if latest.updated_at <= cycle_started_at {
state.db.save_zone(computed)?; state.db.save_zone(computed)?;
return Ok(computed.clone()); return Ok(computed.clone());
@@ -18,9 +24,21 @@ fn persist_zone_cycle(state: &AppState, computed: &Zone, cycle_started_at: DateT
Ok(latest) Ok(latest)
} }
async fn thermostat_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result<bool, AppError> { async fn thermostat_ownership_is_current(
let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); }; state: &AppState,
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power == Some(false) { return Ok(false); } zone_id: &str,
device_id: &str,
) -> Result<bool, AppError> {
let Some(zone) = state.db.get_zone(zone_id)? else {
return Ok(false);
};
if zone.device_id != device_id
|| !zone.enabled
|| zone.device_manual_override
|| zone.local_thermostat_power == Some(false)
{
return Ok(false);
}
Ok(true) Ok(true)
} }
@@ -35,8 +53,12 @@ async fn send_zone_command_if_owned(
// and a stale thermostat decision would still be sent immediately afterwards. // and a stale thermostat decision would still be sent immediately afterwards.
let _device_guard = state.lock_device_operation(device_id).await; let _device_guard = state.lock_device_operation(device_id).await;
let owned = thermostat_ownership_is_current(state, zone_id, device_id).await?; let owned = thermostat_ownership_is_current(state, zone_id, device_id).await?;
if !owned { return Ok(None); } if !owned {
send_command_locked(state, device_id, command).await.map(Some) return Ok(None);
}
send_command_locked(state, device_id, command)
.await
.map(Some)
} }
async fn send_automatic_device_command_if_owned( async fn send_automatic_device_command_if_owned(
@@ -53,7 +75,9 @@ async fn send_automatic_device_command_if_owned(
{ {
return Ok(None); return Ok(None);
} }
send_command_locked(state, device_id, command).await.map(Some) send_command_locked(state, device_id, command)
.await
.map(Some)
} }
async fn apply_automatic_device_action( async fn apply_automatic_device_action(
@@ -68,19 +92,31 @@ async fn apply_automatic_device_action(
// the thermostat cycle so it cannot act on a snapshot taken before this automation. // the thermostat cycle so it cannot act on a snapshot taken before this automation.
let _cycle_guard = state.lock_zone_control_cycle().await; let _cycle_guard = state.lock_zone_control_cycle().await;
let zones = state.db.list_zones()?; let zones = state.db.list_zones()?;
let Some(zone_id) = zones.iter().find(|zone| zone.device_id == device_id).map(|zone| zone.id.clone()) else { let Some(zone_id) = zones
.iter()
.find(|zone| zone.device_id == device_id)
.map(|zone| zone.id.clone())
else {
return send_automatic_device_command_if_owned(state, device_id, command).await; return send_automatic_device_command_if_owned(state, device_id, command).await;
}; };
let _zone_guard = state.lock_zone_operation(&zone_id).await; let _zone_guard = state.lock_zone_operation(&zone_id).await;
let _device_guard = state.lock_device_operation(device_id).await; let _device_guard = state.lock_device_operation(device_id).await;
let mut zone = state.db.get_zone(&zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?; let mut zone = state
if zone.device_manual_override || zone.local_thermostat_power.is_some() || temporary_quick_thermostat_is_active(&zone, Utc::now()) { .db
.get_zone(&zone_id)?
.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?;
if zone.device_manual_override
|| zone.local_thermostat_power.is_some()
|| temporary_quick_thermostat_is_active(&zone, Utc::now())
{
return Ok(None); return Ok(None);
} }
// A power-on automation is an explicit domain transition and may re-enable a zone that // A power-on automation is an explicit domain transition and may re-enable a zone that
// a previous power automation disabled. Other actions still respect a disabled zone gate. // a previous power automation disabled. Other actions still respect a disabled zone gate.
if !zone.enabled && command.power != Some(true) { return Ok(None); } if !zone.enabled && command.power != Some(true) {
return Ok(None);
}
let mut domain_changed = false; let mut domain_changed = false;
if let Some(power) = command.power { if let Some(power) = command.power {
@@ -105,7 +141,8 @@ async fn apply_automatic_device_action(
} }
if let Some(target) = command.target_temperature { if let Some(target) = command.target_temperature {
zone.manual_setpoint = Some((target.clamp(8.0, 30.0) * 2.0).round() / 2.0); zone.manual_setpoint = Some((target.clamp(8.0, 30.0) * 2.0).round() / 2.0);
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now()); zone.manual_override_until =
next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now());
domain_changed = true; domain_changed = true;
} }
@@ -128,8 +165,13 @@ async fn apply_automatic_device_action(
return send_command_locked_forced( return send_command_locked_forced(
state, state,
device_id, device_id,
DeviceCommand { power: Some(false), ..Default::default() }, DeviceCommand {
).await.map(Some); power: Some(false),
..Default::default()
},
)
.await
.map(Some);
} }
// Climate fields above are durable zone state. Only non-climate device capabilities remain // Climate fields above are durable zone state. Only non-climate device capabilities remain
@@ -150,9 +192,12 @@ async fn apply_automatic_device_action(
sleep: command.sleep, sleep: command.sleep,
}; };
if residual.is_empty() { if residual.is_empty() {
return state.db.get_device(device_id)?.map(Some).ok_or_else(|| AppError::NotFound(format!("device {device_id}"))); return state
.db
.get_device(device_id)?
.map(Some)
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")));
} }
drop(_device_guard); drop(_device_guard);
send_automatic_device_command_if_owned(state, device_id, residual).await send_automatic_device_command_if_owned(state, device_id, residual).await
} }
+497 -135
View File
@@ -8,10 +8,17 @@ pub(crate) fn clear_compressor_pending(zone: &mut Zone, clear_cancelled: bool) {
zone.compressor_pending_action = None; zone.compressor_pending_action = None;
zone.compressor_pending_since = None; zone.compressor_pending_since = None;
zone.compressor_pending_until = None; zone.compressor_pending_until = None;
if clear_cancelled { zone.compressor_cancelled_action = None; } if clear_cancelled {
zone.compressor_cancelled_action = None;
}
} }
pub(crate) fn queue_compressor_action(zone: &mut Zone, action: String, until: DateTime<Utc>, reason: &str) { pub(crate) fn queue_compressor_action(
zone: &mut Zone,
action: String,
until: DateTime<Utc>,
reason: &str,
) {
let now = Utc::now(); let now = Utc::now();
if zone.compressor_pending_action.as_deref() != Some(action.as_str()) { if zone.compressor_pending_action.as_deref() != Some(action.as_str()) {
zone.compressor_pending_since = Some(now); zone.compressor_pending_since = Some(now);
@@ -30,7 +37,6 @@ pub(crate) fn rearm_compressor_queue(zone: &mut Zone) {
clear_compressor_pending(zone, true); clear_compressor_pending(zone, true);
} }
async fn resolve_cycle_outdoor_temperature( async fn resolve_cycle_outdoor_temperature(
state: &AppState, state: &AppState,
settings: &RuntimeSettings, settings: &RuntimeSettings,
@@ -48,9 +54,18 @@ async fn resolve_cycle_outdoor_temperature(
&settings.home_assistant, &settings.home_assistant,
Some(entity_id), Some(entity_id),
Some(settings.home_assistant.sensor_stale_after_seconds), Some(settings.home_assistant.sensor_stale_after_seconds),
).await { )
.await
{
Ok(value) => { Ok(value) => {
record_ha_history(state, entity_id, None, "outdoor", value, settings.poll_interval_seconds); record_ha_history(
state,
entity_id,
None,
"outdoor",
value,
settings.poll_interval_seconds,
);
Some(value) Some(value)
} }
Err(err) => { Err(err) => {
@@ -76,9 +91,14 @@ async fn read_cycle_room_sensors(
zones: &[Zone], zones: &[Zone],
) -> HashMap<String, (Option<String>, Result<f64, String>)> { ) -> HashMap<String, (Option<String>, Result<f64, String>)> {
futures_util::future::join_all(zones.iter().filter_map(|zone| { futures_util::future::join_all(zones.iter().filter_map(|zone| {
if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { return None; } if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
return None;
}
let zone_id = zone.id.clone(); let zone_id = zone.id.clone();
let resolved_entity = home_assistant::resolve_entity_id(&settings.home_assistant, zone.ha_entity_id.as_deref()); let resolved_entity = home_assistant::resolve_entity_id(
&settings.home_assistant,
zone.ha_entity_id.as_deref(),
);
let http = &state.http; let http = &state.http;
let ha_settings = &settings.home_assistant; let ha_settings = &settings.home_assistant;
let stale_after_seconds = effective_sensor_stale_after_seconds( let stale_after_seconds = effective_sensor_stale_after_seconds(
@@ -86,8 +106,14 @@ async fn read_cycle_room_sensors(
ha_settings.sensor_stale_after_seconds, ha_settings.sensor_stale_after_seconds,
); );
Some(async move { Some(async move {
let result = home_assistant::read_temperature(http, ha_settings, resolved_entity.as_deref(), Some(stale_after_seconds)).await let result = home_assistant::read_temperature(
.map_err(|err| err.to_string()); http,
ha_settings,
resolved_entity.as_deref(),
Some(stale_after_seconds),
)
.await
.map_err(|err| err.to_string());
(zone_id, resolved_entity, result) (zone_id, resolved_entity, result)
}) })
})) }))
@@ -105,36 +131,58 @@ fn refresh_zone_temperature(
room_sensor_results: &mut HashMap<String, (Option<String>, Result<f64, String>)>, room_sensor_results: &mut HashMap<String, (Option<String>, Result<f64, String>)>,
) -> (String, bool) { ) -> (String, bool) {
let previous_source = zone.control_temperature_source.clone(); let previous_source = zone.control_temperature_source.clone();
let device_temperature = if device.enabled && device.online && device.communication_failures == 0 { let device_temperature =
device.current_temperature if device.enabled && device.online && device.communication_failures == 0 {
} else { device.current_temperature
None } else {
}; None
let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { };
match room_sensor_results.remove(&zone.id) { let external_temperature =
Some((resolved_entity, Ok(value))) => { if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
if let Some(entity_id) = resolved_entity.as_deref() { match room_sensor_results.remove(&zone.id) {
record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds); Some((resolved_entity, Ok(value))) => {
if let Some(entity_id) = resolved_entity.as_deref() {
record_ha_history(
state,
entity_id,
Some(&zone.id),
"room",
value,
settings.poll_interval_seconds,
);
}
Some(value)
} }
Some(value) Some((resolved_entity, Err(err))) => {
} if !matches!(
Some((resolved_entity, Err(err))) => { previous_source.as_str(),
if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") { "device_fallback" | "device_discrepancy_fallback"
let kind = if err.contains("Home Assistant sensor is stale:") { "ha.sensor_stale" } else { "ha.sensor_error" }; ) {
state.log("warn", kind, &err, json!({ let kind = if err.contains("Home Assistant sensor is stale:") {
"zone_id": zone.id, "ha.sensor_stale"
"configured_entity_id": zone.ha_entity_id.as_deref(), } else {
"resolved_entity_id": resolved_entity, "ha.sensor_error"
})); };
state.log(
"warn",
kind,
&err,
json!({
"zone_id": zone.id,
"configured_entity_id": zone.ha_entity_id.as_deref(),
"resolved_entity_id": resolved_entity,
}),
);
}
None
} }
None None => None,
} }
None => None, } else {
} None
} else { };
None let (temperature, source, discrepancy) =
}; select_zone_temperature(zone, device_temperature, external_temperature);
let (temperature, source, discrepancy) = select_zone_temperature(zone, device_temperature, external_temperature);
zone.device_temperature = device_temperature; zone.device_temperature = device_temperature;
zone.external_temperature = external_temperature; zone.external_temperature = external_temperature;
zone.current_temperature = temperature; zone.current_temperature = temperature;
@@ -173,45 +221,92 @@ async fn handle_zone_pre_control_state(
if device.power { if device.power {
clear_compressor_pending(zone, true); clear_compressor_pending(zone, true);
zone.updated_at = now; zone.updated_at = now;
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; persist_zone_cycle_with_history(
state,
zone,
cycle_started_at,
outdoor_temperature,
settings.poll_interval_seconds,
)?;
return Ok(true); return Ok(true);
} }
let due = !settings.compressor_protection_enabled let due = !settings.compressor_protection_enabled
|| zone.compressor_pending_until.as_ref().map(|until| until <= &now).unwrap_or(true); || zone
.compressor_pending_until
.as_ref()
.map(|until| until <= &now)
.unwrap_or(true);
if due { if due {
let _device_guard = state.lock_device_operation(&zone.device_id).await; let _device_guard = state.lock_device_operation(&zone.device_id).await;
match send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(true), ..Default::default() }).await { match send_command_locked(
state,
&zone.device_id,
DeviceCommand {
power: Some(true),
..Default::default()
},
)
.await
{
Ok(updated_device) => { Ok(updated_device) => {
if !device.power && updated_device.power { zone.last_power_change_at = Some(Utc::now()); } if !device.power && updated_device.power {
zone.last_power_change_at = Some(Utc::now());
}
clear_compressor_pending(zone, true); clear_compressor_pending(zone, true);
zone.last_action_at = Some(Utc::now()); zone.last_action_at = Some(Utc::now());
state.log("info", "house.power_one_shot_executed", &format!("Executed queued global ON for {}", zone.name), json!({ state.log(
"zone_id": zone.id, "device_id": zone.device_id "info",
})); "house.power_one_shot_executed",
&format!("Executed queued global ON for {}", zone.name),
json!({
"zone_id": zone.id, "device_id": zone.device_id
}),
);
} }
Err(err) => { Err(err) => {
zone.compressor_pending_until = Some(Utc::now() + chrono::Duration::seconds(10)); zone.compressor_pending_until =
Some(Utc::now() + chrono::Duration::seconds(10));
zone.lockout_until = zone.compressor_pending_until.clone(); zone.lockout_until = zone.compressor_pending_until.clone();
zone.lockout_reason = Some("global_start_retry".into()); zone.lockout_reason = Some("global_start_retry".into());
state.log("error", "house.power_one_shot_error", &err.to_string(), json!({ state.log(
"zone_id": zone.id, "device_id": zone.device_id "error",
})); "house.power_one_shot_error",
&err.to_string(),
json!({
"zone_id": zone.id, "device_id": zone.device_id
}),
);
} }
} }
} }
zone.updated_at = Utc::now(); zone.updated_at = Utc::now();
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; persist_zone_cycle_with_history(
state,
zone,
cycle_started_at,
outdoor_temperature,
settings.poll_interval_seconds,
)?;
return Ok(true); return Ok(true);
} }
if !zone.enabled { if !zone.enabled {
if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) { if temporary_restored_disabled
.iter()
.any(|zone_id| zone_id == &zone.id)
{
ensure_device_off_after_temporary_disabled_restore(state, zone, device).await; ensure_device_off_after_temporary_disabled_restore(state, zone, device).await;
} }
clear_compressor_pending(zone, true); clear_compressor_pending(zone, true);
zone.demand = false; zone.demand = false;
zone.demand_since = None; zone.demand_since = None;
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; persist_zone_cycle_with_history(
state,
zone,
cycle_started_at,
outdoor_temperature,
settings.poll_interval_seconds,
)?;
return Ok(true); return Ok(true);
} }
@@ -220,7 +315,13 @@ async fn handle_zone_pre_control_state(
zone.demand = false; zone.demand = false;
zone.demand_since = None; zone.demand_since = None;
zone.device_setpoint = None; zone.device_setpoint = None;
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; persist_zone_cycle_with_history(
state,
zone,
cycle_started_at,
outdoor_temperature,
settings.poll_interval_seconds,
)?;
return Ok(true); return Ok(true);
} }
@@ -230,23 +331,43 @@ async fn handle_zone_pre_control_state(
let pause_started_at = zone.updated_at.clone(); let pause_started_at = zone.updated_at.clone();
if temporary_active { if temporary_active {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() { if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
if session.paused_at.is_none() { session.paused_at = Some(pause_started_at); } if session.paused_at.is_none() {
session.paused_at = Some(pause_started_at);
}
session.state = "paused_manual".into(); session.state = "paused_manual".into();
session.condition_started_at = None; session.condition_started_at = None;
session.condition_last_observed_at = None; session.condition_last_observed_at = None;
} }
} }
let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode }; let target_mode = if effective_mode == "off" {
zone.mode.as_str()
} else {
effective_mode
};
let active_schedule = active_schedule_for_zone(zone, schedules, Local::now()); let active_schedule = active_schedule_for_zone(zone, schedules, Local::now());
let (preset, target) = resolve_zone_target(zone, active_schedule, target_mode); let (preset, target) = resolve_zone_target(zone, active_schedule, target_mode);
zone.active_preset = preset; zone.active_preset = preset;
zone.effective_setpoint = Some(target); zone.effective_setpoint = Some(target);
zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() }; zone.effective_mode = if device.power {
zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None }; device.mode.clone()
} else {
"off".into()
};
zone.device_setpoint = if device.power {
Some(device.target_temperature)
} else {
None
};
zone.demand = false; zone.demand = false;
zone.demand_since = None; zone.demand_since = None;
zone.target_alerted_at = None; zone.target_alerted_at = None;
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; persist_zone_cycle_with_history(
state,
zone,
cycle_started_at,
outdoor_temperature,
settings.poll_interval_seconds,
)?;
return Ok(true); return Ok(true);
} }
@@ -254,7 +375,8 @@ async fn handle_zone_pre_control_state(
"home_assistant" | "combined" => Some(zone.updated_at.clone()), "home_assistant" | "combined" => Some(zone.updated_at.clone()),
_ => device.last_seen.clone(), _ => device.last_seen.clone(),
}; };
let max_condition_gap_seconds = settings.poll_interval_seconds let max_condition_gap_seconds = settings
.poll_interval_seconds
.max(settings.zone_interval_seconds) .max(settings.zone_interval_seconds)
.saturating_mul(2) .saturating_mul(2)
.saturating_add(5); .saturating_add(5);
@@ -265,9 +387,19 @@ async fn handle_zone_pre_control_state(
condition_sample_at, condition_sample_at,
max_condition_gap_seconds, max_condition_gap_seconds,
) { ) {
let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default(); let finish_kind = zone
.temporary_quick_thermostat
.as_ref()
.map(|item| item.finish_kind.clone())
.unwrap_or_default();
finish_temporary_quick_thermostat(zone, schedules, &settings.house_mode); finish_temporary_quick_thermostat(zone, schedules, &settings.house_mode);
let persisted = persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; let persisted = persist_zone_cycle_with_history(
state,
zone,
cycle_started_at,
outdoor_temperature,
settings.poll_interval_seconds,
)?;
ensure_device_off_after_temporary_disabled_restore(state, &persisted, device).await; ensure_device_off_after_temporary_disabled_restore(state, &persisted, device).await;
state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({ state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": reason "zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": reason
@@ -284,17 +416,39 @@ async fn handle_zone_pre_control_state(
if device.online && device.communication_failures == 0 && device.power { if device.online && device.communication_failures == 0 && device.power {
let _device_guard = state.lock_device_operation(&zone.device_id).await; let _device_guard = state.lock_device_operation(&zone.device_id).await;
let latest = state.db.get_zone(&zone.id)?; let latest = state.db.get_zone(&zone.id)?;
if latest.as_ref().map(|item| item.local_thermostat_power == Some(false) && !item.device_manual_override).unwrap_or(false) { if latest
.as_ref()
.map(|item| {
item.local_thermostat_power == Some(false) && !item.device_manual_override
})
.unwrap_or(false)
{
if let Err(err) = send_command_locked( if let Err(err) = send_command_locked(
state, state,
&zone.device_id, &zone.device_id,
DeviceCommand { power: Some(false), ..Default::default() }, DeviceCommand {
).await { power: Some(false),
state.log("error", "zone.local_power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id})); ..Default::default()
},
)
.await
{
state.log(
"error",
"zone.local_power_error",
&err.to_string(),
json!({"zone_id": zone.id, "device_id": zone.device_id}),
);
} }
} }
} }
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; persist_zone_cycle_with_history(
state,
zone,
cycle_started_at,
outdoor_temperature,
settings.poll_interval_seconds,
)?;
return Ok(true); return Ok(true);
} }
@@ -307,13 +461,31 @@ async fn control_zones(state: &AppState) -> Result<()> {
let settings = state.settings.read().await.clone(); let settings = state.settings.read().await.clone();
let mut zone_snapshot = state.db.list_zones()?; let mut zone_snapshot = state.db.list_zones()?;
// Local/temporary thermostat ownership has its own deadlines. Expire and activate sessions independently. // Local/temporary thermostat ownership has its own deadlines. Expire and activate sessions independently.
expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode)
let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; .await?;
activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; let temporary_restored_disabled = expire_temporary_quick_thermostats(
state,
&mut zone_snapshot,
&schedules,
&settings.house_mode,
)
.await?;
activate_due_temporary_quick_thermostats(
state,
&mut zone_snapshot,
&schedules,
&settings.house_mode,
)
.await?;
let device_snapshot = state.db.list_devices()?; let device_snapshot = state.db.list_devices()?;
let outdoor_temperature = resolve_cycle_outdoor_temperature(state, &settings, &device_snapshot).await; let outdoor_temperature =
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None }; resolve_cycle_outdoor_temperature(state, &settings, &device_snapshot).await;
let outdoor_assist_temperature = if settings.outdoor_assist_enabled {
outdoor_temperature
} else {
None
};
let night_active = night_mode_active(&settings.night_mode, Local::now().time()); let night_active = night_mode_active(&settings.night_mode, Local::now().time());
let mut room_sensor_results = read_cycle_room_sensors(state, &settings, &zone_snapshot).await; let mut room_sensor_results = read_cycle_room_sensors(state, &settings, &zone_snapshot).await;
@@ -322,9 +494,15 @@ async fn control_zones(state: &AppState) -> Result<()> {
// Web/HA/manual control and polling. Re-read after taking the zone lock so an // Web/HA/manual control and polling. Re-read after taking the zone lock so an
// interactive change cannot be evaluated from a stale snapshot. // interactive change cannot be evaluated from a stale snapshot.
let _zone_guard = state.lock_zone_operation(&zone_snapshot_item.id).await; let _zone_guard = state.lock_zone_operation(&zone_snapshot_item.id).await;
let Some(mut zone) = state.db.get_zone(&zone_snapshot_item.id)? else { continue; }; let Some(mut zone) = state.db.get_zone(&zone_snapshot_item.id)? else {
continue;
};
let cycle_started_at = zone.updated_at.clone(); let cycle_started_at = zone.updated_at.clone();
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) { if zone
.manual_override_until
.map(|until| until <= Utc::now())
.unwrap_or(false)
{
zone.manual_preset = None; zone.manual_preset = None;
zone.manual_setpoint = None; zone.manual_setpoint = None;
zone.manual_override_until = None; zone.manual_override_until = None;
@@ -338,13 +516,26 @@ async fn control_zones(state: &AppState) -> Result<()> {
if zone.device_manual_override && zone.device_manual_override_until.is_some() { if zone.device_manual_override && zone.device_manual_override_until.is_some() {
zone.device_manual_override_until = None; zone.device_manual_override_until = None;
zone.control_resume_at = None; zone.control_resume_at = None;
state.log("info", "zone.device_manual_override_migrated", &format!("Manual device control remains active for {} until explicit resume", zone.name), json!({ state.log(
"zone_id": zone.id, "device_id": zone.device_id "info",
})); "zone.device_manual_override_migrated",
&format!(
"Manual device control remains active for {} until explicit resume",
zone.name
),
json!({
"zone_id": zone.id, "device_id": zone.device_id
}),
);
} }
let Some(device) = state.db.get_device(&zone.device_id)? else { let Some(device) = state.db.get_device(&zone.device_id)? else {
state.log("error", "zone.device_missing", &format!("Zone {} has no device", zone.name), json!({"zone_id": zone.id})); state.log(
"error",
"zone.device_missing",
&format!("Zone {} has no device", zone.name),
json!({"zone_id": zone.id}),
);
continue; continue;
}; };
@@ -360,7 +551,11 @@ async fn control_zones(state: &AppState) -> Result<()> {
let effective_mode = effective_mode_owned.as_str(); let effective_mode = effective_mode_owned.as_str();
let (previous_source, discrepancy) = refresh_zone_temperature( let (previous_source, discrepancy) = refresh_zone_temperature(
state, &settings, &mut zone, &device, &mut room_sensor_results, state,
&settings,
&mut zone,
&device,
&mut room_sensor_results,
); );
if handle_zone_pre_control_state( if handle_zone_pre_control_state(
@@ -373,18 +568,28 @@ async fn control_zones(state: &AppState) -> Result<()> {
effective_mode, effective_mode,
cycle_started_at.clone(), cycle_started_at.clone(),
outdoor_temperature, outdoor_temperature,
).await? { )
.await?
{
continue; continue;
} }
if discrepancy && previous_source != "device_discrepancy_fallback" { 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!({ state.log(
"zone_id": zone.id, "warn",
"device_temperature": zone.device_temperature, "zone.sensor_discrepancy",
"external_temperature": zone.external_temperature, &format!(
"max_difference": zone.max_sensor_difference, "Zone {} sensors differ by more than {:.1} C; using GREE sensor",
"entity_id": zone.ha_entity_id.as_deref(), zone.name, zone.max_sensor_difference
})); ),
json!({
"zone_id": zone.id,
"device_temperature": zone.device_temperature,
"external_temperature": zone.external_temperature,
"max_difference": zone.max_sensor_difference,
"entity_id": zone.ha_entity_id.as_deref(),
}),
);
} }
// House "off" is a no-control state, not a power-off command. Keep polling and // House "off" is a no-control state, not a power-off command. Keep polling and
@@ -396,7 +601,12 @@ async fn control_zones(state: &AppState) -> Result<()> {
zone.demand = false; zone.demand = false;
zone.demand_since = None; zone.demand_since = None;
zone.target_alerted_at = None; zone.target_alerted_at = None;
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); record_zone_history(
state,
&zone,
outdoor_temperature,
settings.poll_interval_seconds,
);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue; continue;
@@ -418,7 +628,8 @@ async fn control_zones(state: &AppState) -> Result<()> {
zone.demand_since = None; zone.demand_since = None;
zone.target_alerted_at = None; zone.target_alerted_at = None;
if zone.control_owner == "automation" { if zone.control_owner == "automation" {
zone.control_reason = "Automation idle: no active schedule or explicit thermostat request".into(); zone.control_reason =
"Automation idle: no active schedule or explicit thermostat request".into();
zone.control_resume_at = None; zone.control_resume_at = None;
} }
@@ -427,31 +638,54 @@ async fn control_zones(state: &AppState) -> Result<()> {
state, state,
&zone.id, &zone.id,
&zone.device_id, &zone.device_id,
DeviceCommand { power: Some(false), ..Default::default() }, DeviceCommand {
).await { power: Some(false),
..Default::default()
},
)
.await
{
Ok(Some(updated_device)) => { Ok(Some(updated_device)) => {
let transition_at = Utc::now(); let transition_at = Utc::now();
if device.power != updated_device.power { if device.power != updated_device.power {
zone.last_power_change_at = Some(transition_at); zone.last_power_change_at = Some(transition_at);
} }
zone.last_action_at = Some(transition_at); zone.last_action_at = Some(transition_at);
state.log("info", "zone.automation_idle_off", &format!("Zone {} remains OFF: no active thermostat intent", zone.name), json!({ state.log(
"zone_id": zone.id, "info",
"device_id": zone.device_id, "zone.automation_idle_off",
"active_schedule": false, &format!(
"manual_preset": zone.manual_preset, "Zone {} remains OFF: no active thermostat intent",
"manual_setpoint": zone.manual_setpoint, zone.name
"control_source": zone.control_source, ),
})); json!({
"zone_id": zone.id,
"device_id": zone.device_id,
"active_schedule": false,
"manual_preset": zone.manual_preset,
"manual_setpoint": zone.manual_setpoint,
"control_source": zone.control_source,
}),
);
} }
Ok(None) => {} Ok(None) => {}
Err(err) => state.log("error", "zone.automation_idle_off_error", &err.to_string(), json!({ Err(err) => state.log(
"zone_id": zone.id, "device_id": zone.device_id "error",
})), "zone.automation_idle_off_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); record_zone_history(
state,
&zone,
outdoor_temperature,
settings.poll_interval_seconds,
);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue; continue;
@@ -462,7 +696,12 @@ async fn control_zones(state: &AppState) -> Result<()> {
zone.effective_setpoint = Some(target); zone.effective_setpoint = Some(target);
let Some(temp) = zone.current_temperature else { let Some(temp) = zone.current_temperature else {
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); record_zone_history(
state,
&zone,
outdoor_temperature,
settings.poll_interval_seconds,
);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue; continue;
@@ -472,14 +711,22 @@ async fn control_zones(state: &AppState) -> Result<()> {
let previous_demand = zone.demand; let previous_demand = zone.demand;
zone.demand = match effective_mode { zone.demand = match effective_mode {
"heat" => { "heat" => {
if temp <= target - half { true } if temp <= target - half {
else if temp >= target + half { false } true
else { zone.demand } } else if temp >= target + half {
false
} else {
zone.demand
}
} }
_ => { _ => {
if temp >= target + half { true } if temp >= target + half {
else if temp <= target - half { false } true
else { zone.demand } } else if temp <= target - half {
false
} else {
zone.demand
}
} }
}; };
if zone.demand && !previous_demand { if zone.demand && !previous_demand {
@@ -503,13 +750,15 @@ async fn control_zones(state: &AppState) -> Result<()> {
// Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor // Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor
// stop naturally when we move the target to the satisfied side of room temperature. // stop naturally when we move the target to the satisfied side of room temperature.
let outdoor_assist = outdoor_assist_offset(effective_mode, outdoor_assist_temperature, temp, target); let outdoor_assist =
outdoor_assist_offset(effective_mode, outdoor_assist_temperature, temp, target);
// When an independent room sensor is actually driving cooling, the indoor unit's // When an independent room sensor is actually driving cooling, the indoor unit's
// own sensor can satisfy too early. Apply a full-degree pre-rounding bias: because // own sensor can satisfy too early. Apply a full-degree pre-rounding bias: because
// GREE setpoints are sent as whole degrees, this keeps the unit at least one full // GREE setpoints are sent as whole degrees, this keeps the unit at least one full
// degree below the room target, including half-degree thermostat setpoints. Do not // degree below the room target, including half-degree thermostat setpoints. Do not
// stack it with outdoor assist or use it during device/fallback control. // stack it with outdoor assist or use it during device/fallback control.
let room_sensor_assist = external_room_sensor_cooling_assist(effective_mode, &zone.control_temperature_source); let room_sensor_assist =
external_room_sensor_cooling_assist(effective_mode, &zone.control_temperature_source);
let demand_assist = outdoor_assist.max(room_sensor_assist); let demand_assist = outdoor_assist.max(room_sensor_assist);
let active_target = match effective_mode { let active_target = match effective_mode {
"heat" => target + outdoor_assist, "heat" => target + outdoor_assist,
@@ -519,17 +768,35 @@ async fn control_zones(state: &AppState) -> Result<()> {
"heat" => target - zone.standby_offset_c.max(0.5), "heat" => target - zone.standby_offset_c.max(0.5),
_ => target + zone.standby_offset_c.max(0.5), _ => target + zone.standby_offset_c.max(0.5),
}; };
let desired_device_target = round_device_setpoint(effective_mode, zone.demand, if zone.demand { active_target } else { standby_target }); let desired_device_target = round_device_setpoint(
effective_mode,
zone.demand,
if zone.demand {
active_target
} else {
standby_target
},
);
// Report only the last confirmed device state here. The desired target belongs to // Report only the last confirmed device state here. The desired target belongs to
// effective_setpoint/command planning until a device command succeeds. // effective_setpoint/command planning until a device command succeeds.
zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None }; zone.device_setpoint = if device.power {
Some(device.target_temperature)
} else {
None
};
let demand_changed = previous_demand != zone.demand; let demand_changed = previous_demand != zone.demand;
let desired_fan = if night_active { let desired_fan = if night_active {
let max_fan = settings.night_mode.max_fan_speed.clamp(1, 5); let max_fan = settings.night_mode.max_fan_speed.clamp(1, 5);
if zone.smart_fan { if zone.smart_fan {
Some(night_limited_fan_speed( Some(night_limited_fan_speed(
smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand), smart_fan_speed(
effective_mode,
temp,
target,
outdoor_assist_temperature,
zone.demand,
),
max_fan, max_fan,
)) ))
} else if device.fan_speed == 0 || device.fan_speed > max_fan { } else if device.fan_speed == 0 || device.fan_speed > max_fan {
@@ -538,7 +805,13 @@ async fn control_zones(state: &AppState) -> Result<()> {
Some(device.fan_speed) Some(device.fan_speed)
} }
} else if zone.smart_fan { } else if zone.smart_fan {
Some(smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand)) Some(smart_fan_speed(
effective_mode,
temp,
target,
outdoor_assist_temperature,
zone.demand,
))
} else { } else {
None None
}; };
@@ -572,7 +845,11 @@ async fn control_zones(state: &AppState) -> Result<()> {
let now = Utc::now(); let now = Utc::now();
if !settings.compressor_protection_enabled { if !settings.compressor_protection_enabled {
clear_compressor_pending(&mut zone, true); clear_compressor_pending(&mut zone, true);
} else if zone.lockout_until.map(|until| until <= now).unwrap_or(false) { } else if zone
.lockout_until
.map(|until| until <= now)
.unwrap_or(false)
{
zone.lockout_until = None; zone.lockout_until = None;
zone.lockout_reason = None; zone.lockout_reason = None;
zone.compressor_pending_until = None; zone.compressor_pending_until = None;
@@ -583,41 +860,90 @@ async fn control_zones(state: &AppState) -> Result<()> {
let action = compressor_action_id("mode_change", effective_mode, desired_device_target); let action = compressor_action_id("mode_change", effective_mode, desired_device_target);
if compressor_action_is_cancelled(&zone, &action) { if compressor_action_is_cancelled(&zone, &action) {
clear_compressor_pending(&mut zone, false); clear_compressor_pending(&mut zone, false);
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); record_zone_history(
state,
&zone,
outdoor_temperature,
settings.poll_interval_seconds,
);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue; continue;
} }
if let Some(last_change) = zone.last_power_change_at { if let Some(last_change) = zone.last_power_change_at {
if now.signed_duration_since(last_change) < protection { if now.signed_duration_since(last_change) < protection {
queue_compressor_action(&mut zone, action, last_change + protection, "minimum_on_before_mode_change"); queue_compressor_action(
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); &mut zone,
action,
last_change + protection,
"minimum_on_before_mode_change",
);
record_zone_history(
state,
&zone,
outdoor_temperature,
settings.poll_interval_seconds,
);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue; continue;
} }
} }
match send_zone_command_if_owned(state, &zone.id, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await { match send_zone_command_if_owned(
state,
&zone.id,
&zone.device_id,
DeviceCommand {
power: Some(false),
..Default::default()
},
)
.await
{
Ok(Some(_)) => { Ok(Some(_)) => {
zone.last_power_change_at = Some(now); zone.last_power_change_at = Some(now);
queue_compressor_action(&mut zone, action, now + protection, "mode_change_off_delay"); queue_compressor_action(
&mut zone,
action,
now + protection,
"mode_change_off_delay",
);
state.log("info", "zone.mode_change_lockout", &format!("Zone {} switched off before {} mode", zone.name, effective_mode), json!({"zone_id": zone.id, "resume_at": zone.lockout_until, "compressor_protection_enabled": settings.compressor_protection_enabled})); state.log("info", "zone.mode_change_lockout", &format!("Zone {} switched off before {} mode", zone.name, effective_mode), json!({"zone_id": zone.id, "resume_at": zone.lockout_until, "compressor_protection_enabled": settings.compressor_protection_enabled}));
} }
Ok(None) => {} Ok(None) => {}
Err(err) => state.log("error", "zone.mode_change_off_error", &err.to_string(), json!({"zone_id": zone.id})), Err(err) => state.log(
"error",
"zone.mode_change_off_error",
&err.to_string(),
json!({"zone_id": zone.id}),
),
} }
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); record_zone_history(
state,
&zone,
outdoor_temperature,
settings.poll_interval_seconds,
);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue; continue;
} }
if !device.power { if !device.power {
let action = zone.compressor_pending_action.clone() let action = zone
.compressor_pending_action
.clone()
.filter(|value| value.starts_with("mode_change:")) .filter(|value| value.starts_with("mode_change:"))
.unwrap_or_else(|| compressor_action_id("power_on", effective_mode, desired_device_target)); .unwrap_or_else(|| {
compressor_action_id("power_on", effective_mode, desired_device_target)
});
if compressor_action_is_cancelled(&zone, &action) { if compressor_action_is_cancelled(&zone, &action) {
clear_compressor_pending(&mut zone, false); clear_compressor_pending(&mut zone, false);
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); record_zone_history(
state,
&zone,
outdoor_temperature,
settings.poll_interval_seconds,
);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue; continue;
@@ -625,8 +951,18 @@ async fn control_zones(state: &AppState) -> Result<()> {
if settings.compressor_protection_enabled { if settings.compressor_protection_enabled {
if let Some(last_change) = zone.last_power_change_at { if let Some(last_change) = zone.last_power_change_at {
if now.signed_duration_since(last_change) < protection { if now.signed_duration_since(last_change) < protection {
queue_compressor_action(&mut zone, action, last_change + protection, "minimum_off_before_start"); queue_compressor_action(
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); &mut zone,
action,
last_change + protection,
"minimum_off_before_start",
);
record_zone_history(
state,
&zone,
outdoor_temperature,
settings.poll_interval_seconds,
);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue; continue;
@@ -658,8 +994,12 @@ async fn control_zones(state: &AppState) -> Result<()> {
&& (zone.demand || demand_changed || core_needs_command || night_active); && (zone.demand || demand_changed || core_needs_command || night_active);
let needs_command = core_needs_command let needs_command = core_needs_command
|| fan_needs_command || fan_needs_command
|| desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false) || desired_quiet
|| desired_sleep.map(|sleep| sleep != device.sleep).unwrap_or(false); .map(|quiet| quiet != device.quiet)
.unwrap_or(false)
|| desired_sleep
.map(|sleep| sleep != device.sleep)
.unwrap_or(false);
let urgent_start = !device.power; let urgent_start = !device.power;
if needs_command && (urgent_start || adjustment_allowed(&zone)) { if needs_command && (urgent_start || adjustment_allowed(&zone)) {
@@ -675,9 +1015,17 @@ async fn control_zones(state: &AppState) -> Result<()> {
match send_zone_command_if_owned(state, &zone.id, &zone.device_id, command).await { match send_zone_command_if_owned(state, &zone.id, &zone.device_id, command).await {
Ok(Some(updated_device)) => { Ok(Some(updated_device)) => {
let transition_at = Utc::now(); let transition_at = Utc::now();
if device.power != updated_device.power { zone.last_power_change_at = Some(transition_at); } if device.power != updated_device.power {
if device.mode != updated_device.mode { zone.last_mode_change_at = Some(transition_at); } zone.last_power_change_at = Some(transition_at);
zone.device_setpoint = if updated_device.power { Some(updated_device.target_temperature) } else { None }; }
if device.mode != updated_device.mode {
zone.last_mode_change_at = Some(transition_at);
}
zone.device_setpoint = if updated_device.power {
Some(updated_device.target_temperature)
} else {
None
};
zone.last_action_at = Some(transition_at); zone.last_action_at = Some(transition_at);
clear_compressor_pending(&mut zone, true); clear_compressor_pending(&mut zone, true);
state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({ state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({
@@ -698,16 +1046,31 @@ async fn control_zones(state: &AppState) -> Result<()> {
})); }));
} }
Ok(None) => { Ok(None) => {
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); record_zone_history(
state,
&zone,
outdoor_temperature,
settings.poll_interval_seconds,
);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue; continue;
} }
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})), Err(err) => state.log(
"error",
"zone.action_error",
&err.to_string(),
json!({"zone_id": zone.id}),
),
} }
} }
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); record_zone_history(
state,
&zone,
outdoor_temperature,
settings.poll_interval_seconds,
);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
} }
@@ -715,7 +1078,6 @@ async fn control_zones(state: &AppState) -> Result<()> {
Ok(()) Ok(())
} }
/// Run one thermostat arbitration cycle immediately and wait for all currently eligible zones. /// Run one thermostat arbitration cycle immediately and wait for all currently eligible zones.
/// The cycle lock prevents overlap with the background regulator. /// The cycle lock prevents overlap with the background regulator.
pub async fn run_zone_control_now(state: &AppState) -> Result<(), AppError> { pub async fn run_zone_control_now(state: &AppState) -> Result<(), AppError> {
+15 -4
View File
@@ -1,4 +1,8 @@
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json; use serde_json::json;
use thiserror::Error; use thiserror::Error;
@@ -28,7 +32,10 @@ impl IntoResponse for AppError {
Self::Device(v) => (StatusCode::BAD_GATEWAY, v.clone()), Self::Device(v) => (StatusCode::BAD_GATEWAY, v.clone()),
Self::Internal(v) => { Self::Internal(v) => {
tracing::error!(error = ?v, "internal error"); tracing::error!(error = ?v, "internal error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".into()) (
StatusCode::INTERNAL_SERVER_ERROR,
"internal server error".into(),
)
} }
}; };
(status, Json(json!({"error": message}))).into_response() (status, Json(json!({"error": message}))).into_response()
@@ -36,9 +43,13 @@ impl IntoResponse for AppError {
} }
impl From<rusqlite::Error> for AppError { impl From<rusqlite::Error> for AppError {
fn from(value: rusqlite::Error) -> Self { Self::Internal(value.into()) } fn from(value: rusqlite::Error) -> Self {
Self::Internal(value.into())
}
} }
impl From<serde_json::Error> for AppError { impl From<serde_json::Error> for AppError {
fn from(value: serde_json::Error) -> Self { Self::Internal(value.into()) } fn from(value: serde_json::Error) -> Self {
Self::Internal(value.into())
}
} }
+139 -43
View File
@@ -1,10 +1,13 @@
use std::time::Duration; use crate::models::HomeAssistantSettings;
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use serde_json::Value; use serde_json::Value;
use std::time::Duration;
use url::Url; use url::Url;
use crate::models::HomeAssistantSettings;
fn request_client(default_client: &reqwest::Client, settings: &HomeAssistantSettings) -> Result<reqwest::Client> { fn request_client(
default_client: &reqwest::Client,
settings: &HomeAssistantSettings,
) -> Result<reqwest::Client> {
if !settings.allow_invalid_tls { if !settings.allow_invalid_tls {
return Ok(default_client.clone()); return Ok(default_client.clone());
} }
@@ -17,11 +20,17 @@ fn request_client(default_client: &reqwest::Client, settings: &HomeAssistantSett
.context("cannot build Home Assistant HTTPS client") .context("cannot build Home Assistant HTTPS client")
} }
pub fn resolve_entity_id(settings: &HomeAssistantSettings, entity_override: Option<&str>) -> Option<String> { pub fn resolve_entity_id(
let requested = entity_override.filter(|value| !value.trim().is_empty()) settings: &HomeAssistantSettings,
entity_override: Option<&str>,
) -> Option<String> {
let requested = entity_override
.filter(|value| !value.trim().is_empty())
.map(str::trim) .map(str::trim)
.unwrap_or_else(|| settings.default_entity_id.trim()); .unwrap_or_else(|| settings.default_entity_id.trim());
if requested.is_empty() { return None; } if requested.is_empty() {
return None;
}
// Aliases are presentation-only. Accepting an alias here is a defensive // Aliases are presentation-only. Accepting an alias here is a defensive
// compatibility path for settings saved by older UI revisions or manual edits; // compatibility path for settings saved by older UI revisions or manual edits;
@@ -29,8 +38,11 @@ pub fn resolve_entity_id(settings: &HomeAssistantSettings, entity_override: Opti
if settings.sensor_aliases.contains_key(requested) { if settings.sensor_aliases.contains_key(requested) {
return Some(requested.to_string()); return Some(requested.to_string());
} }
if let Some((entity_id, _)) = settings.sensor_aliases.iter() if let Some((entity_id, _)) = settings
.find(|(_, alias)| alias.trim().eq_ignore_ascii_case(requested)) { .sensor_aliases
.iter()
.find(|(_, alias)| alias.trim().eq_ignore_ascii_case(requested))
{
return Some(entity_id.clone()); return Some(entity_id.clone());
} }
Some(requested.to_string()) Some(requested.to_string())
@@ -42,38 +54,71 @@ pub async fn read_temperature(
entity_override: Option<&str>, entity_override: Option<&str>,
stale_after_seconds: Option<u64>, stale_after_seconds: Option<u64>,
) -> Result<f64> { ) -> Result<f64> {
if settings.url.trim().is_empty() { bail!("Home Assistant URL is not configured") } if settings.url.trim().is_empty() {
if settings.token.trim().is_empty() { bail!("Home Assistant token is not configured") } bail!("Home Assistant URL is not configured")
}
if settings.token.trim().is_empty() {
bail!("Home Assistant token is not configured")
}
let entity = resolve_entity_id(settings, entity_override) let entity = resolve_entity_id(settings, entity_override)
.ok_or_else(|| anyhow!("Home Assistant entity_id is not configured"))?; .ok_or_else(|| anyhow!("Home Assistant entity_id is not configured"))?;
let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?; let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?;
if !matches!(base.scheme(), "http" | "https") { bail!("Home Assistant URL must use http or https") } if !matches!(base.scheme(), "http" | "https") {
bail!("Home Assistant URL must use http or https")
}
let path = format!("api/states/{}", entity.trim_start_matches('/')); let path = format!("api/states/{}", entity.trim_start_matches('/'));
base = base.join(&path).context("cannot build Home Assistant API URL")?; base = base
.join(&path)
.context("cannot build Home Assistant API URL")?;
let client = request_client(default_client, settings)?; let client = request_client(default_client, settings)?;
let response = client.get(base) let response = client
.get(base)
.bearer_auth(settings.token.trim()) .bearer_auth(settings.token.trim())
.header("Accept", "application/json") .header("Accept", "application/json")
.send().await.context("Home Assistant request failed")?; .send()
.await
.context("Home Assistant request failed")?;
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
bail!("Home Assistant returned {status}: {}", body.chars().take(200).collect::<String>()) bail!(
"Home Assistant returned {status}: {}",
body.chars().take(200).collect::<String>()
)
} }
let payload: Value = response.json().await.context("invalid Home Assistant JSON")?; let payload: Value = response
.json()
.await
.context("invalid Home Assistant JSON")?;
if let Some(limit) = stale_after_seconds.filter(|value| *value > 0) { if let Some(limit) = stale_after_seconds.filter(|value| *value > 0) {
let updated = payload.get("last_updated").and_then(Value::as_str) let updated = payload
.get("last_updated")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("Home Assistant last_updated is missing"))?; .ok_or_else(|| anyhow!("Home Assistant last_updated is missing"))?;
let updated = chrono::DateTime::parse_from_rfc3339(updated).context("invalid Home Assistant last_updated")?.with_timezone(&chrono::Utc); let updated = chrono::DateTime::parse_from_rfc3339(updated)
let age = chrono::Utc::now().signed_duration_since(updated).num_seconds().max(0) as u64; .context("invalid Home Assistant last_updated")?
if age > limit { bail!("Home Assistant sensor is stale: {age}s old (limit {limit}s)") } .with_timezone(&chrono::Utc);
let age = chrono::Utc::now()
.signed_duration_since(updated)
.num_seconds()
.max(0) as u64;
if age > limit {
bail!("Home Assistant sensor is stale: {age}s old (limit {limit}s)")
}
} }
let state = payload.get("state").and_then(Value::as_str) let state = payload
.get("state")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("Home Assistant state is missing"))?; .ok_or_else(|| anyhow!("Home Assistant state is missing"))?;
let mut temperature: f64 = state.parse().context("Home Assistant state is not a number")?; let mut temperature: f64 = state
let unit = payload.pointer("/attributes/unit_of_measurement").and_then(Value::as_str).unwrap_or("°C"); .parse()
.context("Home Assistant state is not a number")?;
let unit = payload
.pointer("/attributes/unit_of_measurement")
.and_then(Value::as_str)
.unwrap_or("°C");
if unit.eq_ignore_ascii_case("°F") || unit.eq_ignore_ascii_case("F") { if unit.eq_ignore_ascii_case("°F") || unit.eq_ignore_ascii_case("F") {
temperature = (temperature - 32.0) * 5.0 / 9.0; temperature = (temperature - 32.0) * 5.0 / 9.0;
} }
@@ -103,9 +148,18 @@ mod tests {
#[test] #[test]
fn aliases_never_replace_real_home_assistant_entity_ids() { fn aliases_never_replace_real_home_assistant_entity_ids() {
let settings = settings(); let settings = settings();
assert_eq!(resolve_entity_id(&settings, Some("sensor.gabinet_temperature")).as_deref(), Some("sensor.gabinet_temperature")); assert_eq!(
assert_eq!(resolve_entity_id(&settings, Some("Gabinet")).as_deref(), Some("sensor.gabinet_temperature")); resolve_entity_id(&settings, Some("sensor.gabinet_temperature")).as_deref(),
assert_eq!(resolve_entity_id(&settings, None).as_deref(), Some("sensor.salon_temperature")); Some("sensor.gabinet_temperature")
);
assert_eq!(
resolve_entity_id(&settings, Some("Gabinet")).as_deref(),
Some("sensor.gabinet_temperature")
);
assert_eq!(
resolve_entity_id(&settings, None).as_deref(),
Some("sensor.salon_temperature")
);
} }
} }
@@ -116,20 +170,36 @@ pub async fn read_entity(
settings: &HomeAssistantSettings, settings: &HomeAssistantSettings,
entity_override: Option<&str>, entity_override: Option<&str>,
) -> Result<Value> { ) -> Result<Value> {
if settings.url.trim().is_empty() { bail!("Home Assistant URL is not configured") } if settings.url.trim().is_empty() {
if settings.token.trim().is_empty() { bail!("Home Assistant token is not configured") } bail!("Home Assistant URL is not configured")
}
if settings.token.trim().is_empty() {
bail!("Home Assistant token is not configured")
}
let entity = resolve_entity_id(settings, entity_override) let entity = resolve_entity_id(settings, entity_override)
.ok_or_else(|| anyhow!("Home Assistant entity_id is not configured"))?; .ok_or_else(|| anyhow!("Home Assistant entity_id is not configured"))?;
let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?; let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?;
if !matches!(base.scheme(), "http" | "https") { bail!("Home Assistant URL must use http or https") } if !matches!(base.scheme(), "http" | "https") {
base = base.join(&format!("api/states/{}", entity.trim_start_matches('/'))).context("cannot build Home Assistant API URL")?; bail!("Home Assistant URL must use http or https")
}
base = base
.join(&format!("api/states/{}", entity.trim_start_matches('/')))
.context("cannot build Home Assistant API URL")?;
let client = request_client(default_client, settings)?; let client = request_client(default_client, settings)?;
let response = client.get(base).bearer_auth(settings.token.trim()).header("Accept", "application/json") let response = client
.send().await.context("Home Assistant request failed")?; .get(base)
.bearer_auth(settings.token.trim())
.header("Accept", "application/json")
.send()
.await
.context("Home Assistant request failed")?;
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
bail!("Home Assistant returned {status}: {}", body.chars().take(200).collect::<String>()) bail!(
"Home Assistant returned {status}: {}",
body.chars().take(200).collect::<String>()
)
} }
response.json().await.context("invalid Home Assistant JSON") response.json().await.context("invalid Home Assistant JSON")
} }
@@ -143,10 +213,13 @@ pub async fn read_state(
entity_override: Option<&str>, entity_override: Option<&str>,
) -> Result<String> { ) -> Result<String> {
let payload = read_entity(default_client, settings, entity_override).await?; let payload = read_entity(default_client, settings, entity_override).await?;
payload.get("state").and_then(Value::as_str).map(str::to_string).ok_or_else(|| anyhow!("Home Assistant state is missing")) payload
.get("state")
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| anyhow!("Home Assistant state is missing"))
} }
/// Call a Home Assistant service from a Flow action. The domain/service pair is explicit /// Call a Home Assistant service from a Flow action. The domain/service pair is explicit
/// and the payload is sent as JSON. Entity targeting is normalized through entity_id. /// and the payload is sent as JSON. Entity targeting is normalized through entity_id.
pub async fn call_service( pub async fn call_service(
@@ -157,23 +230,46 @@ pub async fn call_service(
entity_id: Option<&str>, entity_id: Option<&str>,
data: &Value, data: &Value,
) -> Result<Value> { ) -> Result<Value> {
if settings.url.trim().is_empty() { bail!("Home Assistant URL is not configured") } if settings.url.trim().is_empty() {
if settings.token.trim().is_empty() { bail!("Home Assistant token is not configured") } bail!("Home Assistant URL is not configured")
if domain.trim().is_empty() || service.trim().is_empty() { bail!("Home Assistant domain/service is required") } }
if settings.token.trim().is_empty() {
bail!("Home Assistant token is not configured")
}
if domain.trim().is_empty() || service.trim().is_empty() {
bail!("Home Assistant domain/service is required")
}
let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?; let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?;
if !matches!(base.scheme(), "http" | "https") { bail!("Home Assistant URL must use http or https") } if !matches!(base.scheme(), "http" | "https") {
base = base.join(&format!("api/services/{}/{}", domain.trim(), service.trim())).context("cannot build Home Assistant service URL")?; bail!("Home Assistant URL must use http or https")
}
base = base
.join(&format!(
"api/services/{}/{}",
domain.trim(),
service.trim()
))
.context("cannot build Home Assistant service URL")?;
let mut payload = data.as_object().cloned().unwrap_or_default(); let mut payload = data.as_object().cloned().unwrap_or_default();
if let Some(entity) = entity_id.map(str::trim).filter(|v| !v.is_empty()) { if let Some(entity) = entity_id.map(str::trim).filter(|v| !v.is_empty()) {
payload.insert("entity_id".into(), Value::String(entity.to_string())); payload.insert("entity_id".into(), Value::String(entity.to_string()));
} }
let client = request_client(default_client, settings)?; let client = request_client(default_client, settings)?;
let response = client.post(base).bearer_auth(settings.token.trim()).header("Accept", "application/json") let response = client
.json(&Value::Object(payload)).send().await.context("Home Assistant service request failed")?; .post(base)
.bearer_auth(settings.token.trim())
.header("Accept", "application/json")
.json(&Value::Object(payload))
.send()
.await
.context("Home Assistant service request failed")?;
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
bail!("Home Assistant returned {status}: {}", body.chars().take(200).collect::<String>()) bail!(
"Home Assistant returned {status}: {}",
body.chars().take(200).collect::<String>()
)
} }
match response.json::<Value>().await { match response.json::<Value>().await {
Ok(value) => Ok(value), Ok(value) => Ok(value),
-1
View File
@@ -10,7 +10,6 @@ const DEVICE_MEASUREMENT: &str = "gree_device";
const ZONE_MEASUREMENT: &str = "gree_zone"; const ZONE_MEASUREMENT: &str = "gree_zone";
const HA_MEASUREMENT: &str = "gree_ha"; const HA_MEASUREMENT: &str = "gree_ha";
// Functional source split intentionally keeps items in the existing module namespace. // Functional source split intentionally keeps items in the existing module namespace.
include!("influxdb/write.rs"); include!("influxdb/write.rs");
include!("influxdb/query.rs"); include!("influxdb/query.rs");
+89 -19
View File
@@ -20,30 +20,100 @@ fn parse_csv_line(line: &str) -> Vec<String> {
out out
} }
fn flux_query(settings: &InfluxDbSettings, measurement: &str, extra_filters: &str, group_tags: &[&str], start: DateTime<Utc>, stop: DateTime<Utc>, bucket_seconds: i64) -> String { fn flux_query(
let tags = group_tags.iter().map(|tag| format!("\"{tag}\"")).collect::<Vec<_>>().join(","); settings: &InfluxDbSettings,
measurement: &str,
extra_filters: &str,
group_tags: &[&str],
start: DateTime<Utc>,
stop: DateTime<Utc>,
bucket_seconds: i64,
) -> String {
let tags = group_tags
.iter()
.map(|tag| format!("\"{tag}\""))
.collect::<Vec<_>>()
.join(",");
format!( format!(
"from(bucket: {}) |> range(start: time(v: {}), stop: time(v: {})) |> filter(fn: (r) => r._measurement == {}){} |> aggregateWindow(every: {}s, fn: mean, createEmpty: false) |> group(columns: [{}]) |> pivot(rowKey:[\"_time\"], columnKey:[\"_field\"], valueColumn:\"_value\") |> sort(columns:[\"_time\"])", "from(bucket: {}) |> range(start: time(v: {}), stop: time(v: {})) |> filter(fn: (r) => r._measurement == {}){} |> aggregateWindow(every: {}s, fn: mean, createEmpty: false) |> group(columns: [{}]) |> pivot(rowKey:[\"_time\"], columnKey:[\"_field\"], valueColumn:\"_value\") |> sort(columns:[\"_time\"])",
flux_string(&settings.bucket), flux_string(&start.to_rfc3339()), flux_string(&stop.to_rfc3339()), flux_string(measurement), extra_filters, bucket_seconds.max(1), tags flux_string(&settings.bucket), flux_string(&start.to_rfc3339()), flux_string(&stop.to_rfc3339()), flux_string(measurement), extra_filters, bucket_seconds.max(1), tags
) )
} }
fn line_protocol(measurement: &str, tags: &[(&str, &str)], fields: Vec<String>, timestamp: DateTime<Utc>) -> Result<String> { fn line_protocol(
if fields.is_empty() { bail!("InfluxDB measurement has no fields"); } measurement: &str,
let tags = tags.iter().filter(|(_, value)| !value.is_empty()).map(|(key,value)| format!(",{}={}", escape_tag(key), escape_tag(value))).collect::<String>(); tags: &[(&str, &str)],
let nanos = timestamp.timestamp_nanos_opt().ok_or_else(|| anyhow!("timestamp outside nanosecond range"))?; fields: Vec<String>,
Ok(format!("{}{} {} {}", escape_measurement(measurement), tags, fields.join(","), nanos)) timestamp: DateTime<Utc>,
) -> Result<String> {
if fields.is_empty() {
bail!("InfluxDB measurement has no fields");
}
let tags = tags
.iter()
.filter(|(_, value)| !value.is_empty())
.map(|(key, value)| format!(",{}={}", escape_tag(key), escape_tag(value)))
.collect::<String>();
let nanos = timestamp
.timestamp_nanos_opt()
.ok_or_else(|| anyhow!("timestamp outside nanosecond range"))?;
Ok(format!(
"{}{} {} {}",
escape_measurement(measurement),
tags,
fields.join(","),
nanos
))
} }
fn push_float(fields: &mut Vec<String>, key: &str, value: Option<f64>) { if let Some(value) = value.filter(|v| v.is_finite()) { fields.push(format!("{}={value}", escape_field_key(key))); } } fn push_float(fields: &mut Vec<String>, key: &str, value: Option<f64>) {
fn push_int(fields: &mut Vec<String>, key: &str, value: i64) { fields.push(format!("{}={value}i", escape_field_key(key))); } if let Some(value) = value.filter(|v| v.is_finite()) {
fn escape_measurement(value: &str) -> String { value.replace('\\', "\\\\").replace(',', "\\,").replace(' ', "\\ ") } fields.push(format!("{}={value}", escape_field_key(key)));
fn escape_tag(value: &str) -> String { value.replace('\\', "\\\\").replace(',', "\\,").replace('=', "\\=").replace(' ', "\\ ") } }
fn escape_field_key(value: &str) -> String { escape_tag(value) } }
fn influxql_string(value: &str) -> String { value.replace('\\', "\\\\").replace('\'', "\\'") } fn push_int(fields: &mut Vec<String>, key: &str, value: i64) {
fn flux_string(value: &str) -> String { format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) } fields.push(format!("{}={value}i", escape_field_key(key)));
fn truncate(value: &str, max: usize) -> String { value.chars().take(max).collect() } }
fn row_f64(row: &HashMap<String,String>, key: &str) -> Option<f64> { row.get(key)?.parse().ok() } fn escape_measurement(value: &str) -> String {
fn parse_flux_time(row: &HashMap<String,String>) -> Option<DateTime<Utc>> { DateTime::parse_from_rfc3339(row.get("_time")?).ok().map(|v| v.with_timezone(&Utc)) } value
fn row_num(row: &HashMap<String,Value>, key: &str) -> Option<f64> { row.get(key)?.as_f64().or_else(|| row.get(key)?.as_i64().map(|v|v as f64)) } .replace('\\', "\\\\")
fn row_time(row: &HashMap<String,Value>) -> Option<DateTime<Utc>> { DateTime::parse_from_rfc3339(row.get("time")?.as_str()?).ok().map(|v|v.with_timezone(&Utc)) } .replace(',', "\\,")
.replace(' ', "\\ ")
}
fn escape_tag(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace(',', "\\,")
.replace('=', "\\=")
.replace(' ', "\\ ")
}
fn escape_field_key(value: &str) -> String {
escape_tag(value)
}
fn influxql_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('\'', "\\'")
}
fn flux_string(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
fn truncate(value: &str, max: usize) -> String {
value.chars().take(max).collect()
}
fn row_f64(row: &HashMap<String, String>, key: &str) -> Option<f64> {
row.get(key)?.parse().ok()
}
fn parse_flux_time(row: &HashMap<String, String>) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(row.get("_time")?)
.ok()
.map(|v| v.with_timezone(&Utc))
}
fn row_num(row: &HashMap<String, Value>, key: &str) -> Option<f64> {
row.get(key)?
.as_f64()
.or_else(|| row.get(key)?.as_i64().map(|v| v as f64))
}
fn row_time(row: &HashMap<String, Value>) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(row.get("time")?.as_str()?)
.ok()
.map(|v| v.with_timezone(&Utc))
}
+303 -55
View File
@@ -8,15 +8,43 @@ pub async fn query_devices(
limit: u32, limit: u32,
) -> Result<Vec<Reading>> { ) -> Result<Vec<Reading>> {
if settings.version == "1" { if settings.version == "1" {
query_devices_v1(client, settings, device_id, start, stop, bucket_seconds, limit).await query_devices_v1(
client,
settings,
device_id,
start,
stop,
bucket_seconds,
limit,
)
.await
} else { } else {
let tags = if let Some(value) = device_id { format!(" |> filter(fn: (r) => r.device_id == {})", flux_string(value)) } else { String::new() }; let tags = if let Some(value) = device_id {
let query = flux_query(settings, DEVICE_MEASUREMENT, &tags, &["device_id"], start, stop, bucket_seconds); format!(
" |> filter(fn: (r) => r.device_id == {})",
flux_string(value)
)
} else {
String::new()
};
let query = flux_query(
settings,
DEVICE_MEASUREMENT,
&tags,
&["device_id"],
start,
stop,
bucket_seconds,
);
let rows = query_v2(client, settings, &query).await?; let rows = query_v2(client, settings, &query).await?;
let mut out = Vec::new(); let mut out = Vec::new();
for row in rows.into_iter().take(limit as usize) { for row in rows.into_iter().take(limit as usize) {
let Some(timestamp) = parse_flux_time(&row) else { continue; }; let Some(timestamp) = parse_flux_time(&row) else {
let Some(id) = row.get("device_id").filter(|v| !v.is_empty()) else { continue; }; continue;
};
let Some(id) = row.get("device_id").filter(|v| !v.is_empty()) else {
continue;
};
out.push(Reading { out.push(Reading {
id: 0, id: 0,
device_id: id.clone(), device_id: id.clone(),
@@ -43,15 +71,40 @@ pub async fn query_zones(
limit: u32, limit: u32,
) -> Result<Vec<ZoneReading>> { ) -> Result<Vec<ZoneReading>> {
if settings.version == "1" { if settings.version == "1" {
query_zones_v1(client, settings, zone_id, start, stop, bucket_seconds, limit).await query_zones_v1(
client,
settings,
zone_id,
start,
stop,
bucket_seconds,
limit,
)
.await
} else { } else {
let tags = if let Some(value) = zone_id { format!(" |> filter(fn: (r) => r.zone_id == {})", flux_string(value)) } else { String::new() }; let tags = if let Some(value) = zone_id {
let query = flux_query(settings, ZONE_MEASUREMENT, &tags, &["zone_id", "device_id"], start, stop, bucket_seconds); format!(" |> filter(fn: (r) => r.zone_id == {})", flux_string(value))
} else {
String::new()
};
let query = flux_query(
settings,
ZONE_MEASUREMENT,
&tags,
&["zone_id", "device_id"],
start,
stop,
bucket_seconds,
);
let rows = query_v2(client, settings, &query).await?; let rows = query_v2(client, settings, &query).await?;
let mut out = Vec::new(); let mut out = Vec::new();
for row in rows.into_iter().take(limit as usize) { for row in rows.into_iter().take(limit as usize) {
let Some(timestamp) = parse_flux_time(&row) else { continue; }; let Some(timestamp) = parse_flux_time(&row) else {
let Some(zone) = row.get("zone_id").filter(|v| !v.is_empty()) else { continue; }; continue;
};
let Some(zone) = row.get("zone_id").filter(|v| !v.is_empty()) else {
continue;
};
out.push(ZoneReading { out.push(ZoneReading {
id: 0, id: 0,
zone_id: zone.clone(), zone_id: zone.clone(),
@@ -65,7 +118,10 @@ pub async fn query_zones(
outdoor_temperature: row_f64(&row, "outdoor_temperature"), outdoor_temperature: row_f64(&row, "outdoor_temperature"),
power: row_f64(&row, "power").unwrap_or(0.0) >= 0.5, power: row_f64(&row, "power").unwrap_or(0.0) >= 0.5,
mode: "history".into(), mode: "history".into(),
fan_speed: row_f64(&row, "fan_speed").unwrap_or(0.0).round().clamp(0.0, 5.0) as u8, fan_speed: row_f64(&row, "fan_speed")
.unwrap_or(0.0)
.round()
.clamp(0.0, 5.0) as u8,
demand: row_f64(&row, "demand").unwrap_or(0.0) >= 0.5, demand: row_f64(&row, "demand").unwrap_or(0.0) >= 0.5,
control_source: "influx".into(), control_source: "influx".into(),
active_preset: "history".into(), active_preset: "history".into(),
@@ -86,16 +142,46 @@ pub async fn query_ha(
limit: u32, limit: u32,
) -> Result<Vec<HaReading>> { ) -> Result<Vec<HaReading>> {
if settings.version == "1" { if settings.version == "1" {
query_ha_v1(client, settings, entity_id, start, stop, bucket_seconds, limit).await query_ha_v1(
client,
settings,
entity_id,
start,
stop,
bucket_seconds,
limit,
)
.await
} else { } else {
let tags = if let Some(value) = entity_id { format!(" |> filter(fn: (r) => r.entity_id == {})", flux_string(value)) } else { String::new() }; let tags = if let Some(value) = entity_id {
let query = flux_query(settings, HA_MEASUREMENT, &tags, &["entity_id", "zone_id", "kind"], start, stop, bucket_seconds); format!(
" |> filter(fn: (r) => r.entity_id == {})",
flux_string(value)
)
} else {
String::new()
};
let query = flux_query(
settings,
HA_MEASUREMENT,
&tags,
&["entity_id", "zone_id", "kind"],
start,
stop,
bucket_seconds,
);
let rows = query_v2(client, settings, &query).await?; let rows = query_v2(client, settings, &query).await?;
let mut out = Vec::new(); let mut out = Vec::new();
for row in rows.into_iter().take(limit as usize) { for row in rows.into_iter().take(limit as usize) {
let Some(timestamp) = parse_flux_time(&row) else { continue; }; let Some(timestamp) = parse_flux_time(&row) else {
let Some(entity) = row.get("entity_id").filter(|v| !v.is_empty()) else { continue; }; continue;
let Some(temperature) = row_f64(&row, "temperature") else { continue; }; };
let Some(entity) = row.get("entity_id").filter(|v| !v.is_empty()) else {
continue;
};
let Some(temperature) = row_f64(&row, "temperature") else {
continue;
};
out.push(HaReading { out.push(HaReading {
id: 0, id: 0,
entity_id: entity.clone(), entity_id: entity.clone(),
@@ -110,23 +196,56 @@ pub async fn query_ha(
} }
} }
async fn query_devices_v1(client: &Client, settings: &InfluxDbSettings, device_id: Option<&str>, start: DateTime<Utc>, stop: DateTime<Utc>, bucket: i64, limit: u32) -> Result<Vec<Reading>> { async fn query_devices_v1(
let filter = device_id.map(|id| format!(" AND \"device_id\"='{}'", influxql_string(id))).unwrap_or_default(); client: &Client,
settings: &InfluxDbSettings,
device_id: Option<&str>,
start: DateTime<Utc>,
stop: DateTime<Utc>,
bucket: i64,
limit: u32,
) -> Result<Vec<Reading>> {
let filter = device_id
.map(|id| format!(" AND \"device_id\"='{}'", influxql_string(id)))
.unwrap_or_default();
let q = format!("SELECT mean(\"indoor_temperature\") AS \"indoor_temperature\",mean(\"outdoor_temperature\") AS \"outdoor_temperature\",mean(\"target_temperature\") AS \"target_temperature\",max(\"power\") AS \"power\" FROM \"{DEVICE_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"device_id\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit); let q = format!("SELECT mean(\"indoor_temperature\") AS \"indoor_temperature\",mean(\"outdoor_temperature\") AS \"outdoor_temperature\",mean(\"target_temperature\") AS \"target_temperature\",max(\"power\") AS \"power\" FROM \"{DEVICE_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"device_id\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit);
let series = query_v1(client, settings, &q).await?; let series = query_v1(client, settings, &q).await?;
let mut out = Vec::new(); let mut out = Vec::new();
for item in series { for item in series {
let device = item.tags.get("device_id").cloned().unwrap_or_default(); let device = item.tags.get("device_id").cloned().unwrap_or_default();
for row in item.rows { for row in item.rows {
let Some(timestamp) = row_time(&row) else { continue; }; let Some(timestamp) = row_time(&row) else {
out.push(Reading { id:0, device_id:device.clone(), timestamp, indoor_temperature:row_num(&row,"indoor_temperature"), outdoor_temperature:row_num(&row,"outdoor_temperature"), target_temperature:row_num(&row,"target_temperature").unwrap_or(0.0), power:row_num(&row,"power").unwrap_or(0.0)>=0.5, source:"influx".into() }); continue;
};
out.push(Reading {
id: 0,
device_id: device.clone(),
timestamp,
indoor_temperature: row_num(&row, "indoor_temperature"),
outdoor_temperature: row_num(&row, "outdoor_temperature"),
target_temperature: row_num(&row, "target_temperature").unwrap_or(0.0),
power: row_num(&row, "power").unwrap_or(0.0) >= 0.5,
source: "influx".into(),
});
} }
} }
out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out) out.sort_by_key(|row| row.timestamp);
out.truncate(limit as usize);
Ok(out)
} }
async fn query_zones_v1(client: &Client, settings: &InfluxDbSettings, zone_id: Option<&str>, start: DateTime<Utc>, stop: DateTime<Utc>, bucket: i64, limit: u32) -> Result<Vec<ZoneReading>> { async fn query_zones_v1(
let filter = zone_id.map(|id| format!(" AND \"zone_id\"='{}'", influxql_string(id))).unwrap_or_default(); client: &Client,
settings: &InfluxDbSettings,
zone_id: Option<&str>,
start: DateTime<Utc>,
stop: DateTime<Utc>,
bucket: i64,
limit: u32,
) -> Result<Vec<ZoneReading>> {
let filter = zone_id
.map(|id| format!(" AND \"zone_id\"='{}'", influxql_string(id)))
.unwrap_or_default();
let q = format!("SELECT mean(\"gree_temperature\") AS \"gree_temperature\",mean(\"external_temperature\") AS \"external_temperature\",mean(\"control_temperature\") AS \"control_temperature\",mean(\"target_temperature\") AS \"target_temperature\",mean(\"device_setpoint\") AS \"device_setpoint\",mean(\"outdoor_temperature\") AS \"outdoor_temperature\",max(\"power\") AS \"power\",mean(\"fan_speed\") AS \"fan_speed\",max(\"demand\") AS \"demand\" FROM \"{ZONE_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"zone_id\",\"device_id\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit); let q = format!("SELECT mean(\"gree_temperature\") AS \"gree_temperature\",mean(\"external_temperature\") AS \"external_temperature\",mean(\"control_temperature\") AS \"control_temperature\",mean(\"target_temperature\") AS \"target_temperature\",mean(\"device_setpoint\") AS \"device_setpoint\",mean(\"outdoor_temperature\") AS \"outdoor_temperature\",max(\"power\") AS \"power\",mean(\"fan_speed\") AS \"fan_speed\",max(\"demand\") AS \"demand\" FROM \"{ZONE_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"zone_id\",\"device_id\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit);
let series = query_v1(client, settings, &q).await?; let series = query_v1(client, settings, &q).await?;
let mut out = Vec::new(); let mut out = Vec::new();
@@ -134,81 +253,210 @@ async fn query_zones_v1(client: &Client, settings: &InfluxDbSettings, zone_id: O
let zone = item.tags.get("zone_id").cloned().unwrap_or_default(); let zone = item.tags.get("zone_id").cloned().unwrap_or_default();
let device = item.tags.get("device_id").cloned().unwrap_or_default(); let device = item.tags.get("device_id").cloned().unwrap_or_default();
for row in item.rows { for row in item.rows {
let Some(timestamp) = row_time(&row) else { continue; }; let Some(timestamp) = row_time(&row) else {
out.push(ZoneReading { id:0, zone_id:zone.clone(), device_id:device.clone(), timestamp, gree_temperature:row_num(&row,"gree_temperature"), external_temperature:row_num(&row,"external_temperature"), control_temperature:row_num(&row,"control_temperature"), target_temperature:row_num(&row,"target_temperature"), device_setpoint:row_num(&row,"device_setpoint"), outdoor_temperature:row_num(&row,"outdoor_temperature"), power:row_num(&row,"power").unwrap_or(0.0)>=0.5, mode:"history".into(), fan_speed:row_num(&row,"fan_speed").unwrap_or(0.0).round().clamp(0.0,5.0) as u8, demand:row_num(&row,"demand").unwrap_or(0.0)>=0.5, control_source:"influx".into(), active_preset:"history".into() }); continue;
};
out.push(ZoneReading {
id: 0,
zone_id: zone.clone(),
device_id: device.clone(),
timestamp,
gree_temperature: row_num(&row, "gree_temperature"),
external_temperature: row_num(&row, "external_temperature"),
control_temperature: row_num(&row, "control_temperature"),
target_temperature: row_num(&row, "target_temperature"),
device_setpoint: row_num(&row, "device_setpoint"),
outdoor_temperature: row_num(&row, "outdoor_temperature"),
power: row_num(&row, "power").unwrap_or(0.0) >= 0.5,
mode: "history".into(),
fan_speed: row_num(&row, "fan_speed")
.unwrap_or(0.0)
.round()
.clamp(0.0, 5.0) as u8,
demand: row_num(&row, "demand").unwrap_or(0.0) >= 0.5,
control_source: "influx".into(),
active_preset: "history".into(),
});
} }
} }
out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out) out.sort_by_key(|row| row.timestamp);
out.truncate(limit as usize);
Ok(out)
} }
async fn query_ha_v1(client: &Client, settings: &InfluxDbSettings, entity_id: Option<&str>, start: DateTime<Utc>, stop: DateTime<Utc>, bucket: i64, limit: u32) -> Result<Vec<HaReading>> { async fn query_ha_v1(
let filter = entity_id.map(|id| format!(" AND \"entity_id\"='{}'", influxql_string(id))).unwrap_or_default(); client: &Client,
settings: &InfluxDbSettings,
entity_id: Option<&str>,
start: DateTime<Utc>,
stop: DateTime<Utc>,
bucket: i64,
limit: u32,
) -> Result<Vec<HaReading>> {
let filter = entity_id
.map(|id| format!(" AND \"entity_id\"='{}'", influxql_string(id)))
.unwrap_or_default();
let q = format!("SELECT mean(\"temperature\") AS \"temperature\" FROM \"{HA_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"entity_id\",\"zone_id\",\"kind\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit); let q = format!("SELECT mean(\"temperature\") AS \"temperature\" FROM \"{HA_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"entity_id\",\"zone_id\",\"kind\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit);
let series = query_v1(client, settings, &q).await?; let series = query_v1(client, settings, &q).await?;
let mut out = Vec::new(); let mut out = Vec::new();
for item in series { for item in series {
let entity = item.tags.get("entity_id").cloned().unwrap_or_default(); let entity = item.tags.get("entity_id").cloned().unwrap_or_default();
let zone = item.tags.get("zone_id").filter(|v| !v.is_empty()).cloned(); let zone = item.tags.get("zone_id").filter(|v| !v.is_empty()).cloned();
let kind = item.tags.get("kind").cloned().unwrap_or_else(|| "room".into()); let kind = item
.tags
.get("kind")
.cloned()
.unwrap_or_else(|| "room".into());
for row in item.rows { for row in item.rows {
let Some(timestamp) = row_time(&row) else { continue; }; let Some(timestamp) = row_time(&row) else {
let Some(temperature) = row_num(&row,"temperature") else { continue; }; continue;
out.push(HaReading { id:0, entity_id:entity.clone(), zone_id:zone.clone(), kind:kind.clone(), timestamp, temperature }); };
let Some(temperature) = row_num(&row, "temperature") else {
continue;
};
out.push(HaReading {
id: 0,
entity_id: entity.clone(),
zone_id: zone.clone(),
kind: kind.clone(),
timestamp,
temperature,
});
} }
} }
out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out) out.sort_by_key(|row| row.timestamp);
out.truncate(limit as usize);
Ok(out)
} }
struct V1Series { tags: HashMap<String,String>, rows: Vec<HashMap<String,Value>> } struct V1Series {
tags: HashMap<String, String>,
rows: Vec<HashMap<String, Value>>,
}
async fn query_v1(client: &Client, settings: &InfluxDbSettings, q: &str) -> Result<Vec<V1Series>> { async fn query_v1(client: &Client, settings: &InfluxDbSettings, q: &str) -> Result<Vec<V1Series>> {
validate(settings)?; validate(settings)?;
let base = settings.url.trim_end_matches('/'); let base = settings.url.trim_end_matches('/');
let request = client.get(format!("{base}/query")).query(&[("db", settings.database.as_str()), ("q", q)]); let request = client
let request = if settings.username.trim().is_empty() { request } else { request.basic_auth(&settings.username, Some(&settings.password)) }; .get(format!("{base}/query"))
.query(&[("db", settings.database.as_str()), ("q", q)]);
let request = if settings.username.trim().is_empty() {
request
} else {
request.basic_auth(&settings.username, Some(&settings.password))
};
let response = request.send().await.context("InfluxDB 1.x query failed")?; let response = request.send().await.context("InfluxDB 1.x query failed")?;
let status = response.status(); let status = response.status();
let body: Value = response.json().await.context("invalid InfluxDB 1.x JSON response")?; let body: Value = response
if !status.is_success() { bail!("InfluxDB 1.x query failed ({status}): {body}"); } .json()
if let Some(error) = body.pointer("/results/0/error").and_then(Value::as_str) { bail!("InfluxDB 1.x query error: {error}"); } .await
.context("invalid InfluxDB 1.x JSON response")?;
if !status.is_success() {
bail!("InfluxDB 1.x query failed ({status}): {body}");
}
if let Some(error) = body.pointer("/results/0/error").and_then(Value::as_str) {
bail!("InfluxDB 1.x query error: {error}");
}
let mut out = Vec::new(); let mut out = Vec::new();
for series in body.pointer("/results/0/series").and_then(Value::as_array).into_iter().flatten() { for series in body
let columns: Vec<String> = series.get("columns").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(); .pointer("/results/0/series")
let tags = series.get("tags").and_then(Value::as_object).map(|map| map.iter().map(|(k,v)|(k.clone(),v.as_str().unwrap_or_default().to_string())).collect()).unwrap_or_default(); .and_then(Value::as_array)
.into_iter()
.flatten()
{
let columns: Vec<String> = series
.get("columns")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect();
let tags = series
.get("tags")
.and_then(Value::as_object)
.map(|map| {
map.iter()
.map(|(k, v)| (k.clone(), v.as_str().unwrap_or_default().to_string()))
.collect()
})
.unwrap_or_default();
let mut rows = Vec::new(); let mut rows = Vec::new();
for values in series.get("values").and_then(Value::as_array).into_iter().flatten() { for values in series
let Some(values) = values.as_array() else { continue; }; .get("values")
rows.push(columns.iter().cloned().zip(values.iter().cloned()).collect()); .and_then(Value::as_array)
.into_iter()
.flatten()
{
let Some(values) = values.as_array() else {
continue;
};
rows.push(
columns
.iter()
.cloned()
.zip(values.iter().cloned())
.collect(),
);
} }
out.push(V1Series { tags, rows }); out.push(V1Series { tags, rows });
} }
Ok(out) Ok(out)
} }
async fn query_v2(client: &Client, settings: &InfluxDbSettings, query: &str) -> Result<Vec<HashMap<String,String>>> { async fn query_v2(
client: &Client,
settings: &InfluxDbSettings,
query: &str,
) -> Result<Vec<HashMap<String, String>>> {
validate(settings)?; validate(settings)?;
let base = settings.url.trim_end_matches('/'); let base = settings.url.trim_end_matches('/');
let response = client.post(format!("{base}/api/v2/query")) let response = client
.post(format!("{base}/api/v2/query"))
.query(&[("org", settings.org.as_str())]) .query(&[("org", settings.org.as_str())])
.bearer_auth(settings.token.trim()) .bearer_auth(settings.token.trim())
.header(reqwest::header::ACCEPT, "application/csv") .header(reqwest::header::ACCEPT, "application/csv")
.header(reqwest::header::CONTENT_TYPE, "application/vnd.flux") .header(reqwest::header::CONTENT_TYPE, "application/vnd.flux")
.body(query.to_string()) .body(query.to_string())
.send().await.context("InfluxDB 2.x query failed")?; .send()
.await
.context("InfluxDB 2.x query failed")?;
let status = response.status(); let status = response.status();
let body = response.text().await.context("cannot read InfluxDB 2.x response")?; let body = response
if !status.is_success() { bail!("InfluxDB 2.x query failed ({status}): {}", truncate(&body, 500)); } .text()
.await
.context("cannot read InfluxDB 2.x response")?;
if !status.is_success() {
bail!(
"InfluxDB 2.x query failed ({status}): {}",
truncate(&body, 500)
);
}
let mut headers: Option<Vec<String>> = None; let mut headers: Option<Vec<String>> = None;
let mut rows = Vec::new(); let mut rows = Vec::new();
for line in body.lines().filter(|line| !line.starts_with('#') && !line.trim().is_empty()) { for line in body
.lines()
.filter(|line| !line.starts_with('#') && !line.trim().is_empty())
{
let record = parse_csv_line(line); let record = parse_csv_line(line);
if headers.is_none() { if headers.is_none() {
headers = Some(record); headers = Some(record);
continue; continue;
} }
let row: HashMap<String,String> = headers.as_ref().unwrap().iter().cloned().zip(record.into_iter()).collect(); let row: HashMap<String, String> = headers
if row.get("_time").map(|value| !value.is_empty()).unwrap_or(false) { rows.push(row); } .as_ref()
.unwrap()
.iter()
.cloned()
.zip(record.into_iter())
.collect();
if row
.get("_time")
.map(|value| !value.is_empty())
.unwrap_or(false)
{
rows.push(row);
}
} }
Ok(rows) Ok(rows)
} }
+189 -51
View File
@@ -1,57 +1,129 @@
pub fn validate(settings: &InfluxDbSettings) -> Result<()> { pub fn validate(settings: &InfluxDbSettings) -> Result<()> {
if !settings.enabled { return Ok(()); } if !settings.enabled {
if !matches!(settings.version.as_str(), "1" | "2") { bail!("InfluxDB version must be 1 or 2"); } return Ok(());
}
if !matches!(settings.version.as_str(), "1" | "2") {
bail!("InfluxDB version must be 1 or 2");
}
let parsed = url::Url::parse(settings.url.trim()).context("invalid InfluxDB URL")?; let parsed = url::Url::parse(settings.url.trim()).context("invalid InfluxDB URL")?;
if !matches!(parsed.scheme(), "http" | "https") { bail!("InfluxDB URL must use http or https"); } if !matches!(parsed.scheme(), "http" | "https") {
if settings.version == "1" && settings.database.trim().is_empty() { bail!("InfluxDB 1.x database is required"); } bail!("InfluxDB URL must use http or https");
}
if settings.version == "1" && settings.database.trim().is_empty() {
bail!("InfluxDB 1.x database is required");
}
if settings.version == "2" { if settings.version == "2" {
if settings.org.trim().is_empty() { bail!("InfluxDB 2.x organization is required"); } if settings.org.trim().is_empty() {
if settings.bucket.trim().is_empty() { bail!("InfluxDB 2.x bucket is required"); } bail!("InfluxDB 2.x organization is required");
if settings.token.trim().is_empty() { bail!("InfluxDB 2.x token is required"); } }
if settings.bucket.trim().is_empty() {
bail!("InfluxDB 2.x bucket is required");
}
if settings.token.trim().is_empty() {
bail!("InfluxDB 2.x token is required");
}
} }
Ok(()) Ok(())
} }
pub async fn write_device(client: &Client, settings: &InfluxDbSettings, reading: &Reading) -> Result<()> { pub async fn write_device(
if !settings.enabled { return Ok(()); } client: &Client,
settings: &InfluxDbSettings,
reading: &Reading,
) -> Result<()> {
if !settings.enabled {
return Ok(());
}
let mut fields = Vec::new(); let mut fields = Vec::new();
push_float(&mut fields, "indoor_temperature", reading.indoor_temperature); push_float(
push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature); &mut fields,
push_float(&mut fields, "target_temperature", Some(reading.target_temperature)); "indoor_temperature",
reading.indoor_temperature,
);
push_float(
&mut fields,
"outdoor_temperature",
reading.outdoor_temperature,
);
push_float(
&mut fields,
"target_temperature",
Some(reading.target_temperature),
);
push_int(&mut fields, "power", reading.power as i64); push_int(&mut fields, "power", reading.power as i64);
let line = line_protocol(DEVICE_MEASUREMENT, &[("device_id", &reading.device_id)], fields, reading.timestamp)?;
write_line(client, settings, line).await
}
pub async fn write_zone(client: &Client, settings: &InfluxDbSettings, reading: &ZoneReading) -> Result<()> {
if !settings.enabled { return Ok(()); }
let mut fields = Vec::new();
push_float(&mut fields, "gree_temperature", reading.gree_temperature);
push_float(&mut fields, "external_temperature", reading.external_temperature);
push_float(&mut fields, "control_temperature", reading.control_temperature);
push_float(&mut fields, "target_temperature", reading.target_temperature);
push_float(&mut fields, "device_setpoint", reading.device_setpoint);
push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature);
push_int(&mut fields, "power", reading.power as i64);
push_int(&mut fields, "fan_speed", reading.fan_speed as i64);
push_int(&mut fields, "demand", reading.demand as i64);
let line = line_protocol( let line = line_protocol(
ZONE_MEASUREMENT, DEVICE_MEASUREMENT,
&[("zone_id", &reading.zone_id), ("device_id", &reading.device_id)], &[("device_id", &reading.device_id)],
fields, fields,
reading.timestamp, reading.timestamp,
)?; )?;
write_line(client, settings, line).await write_line(client, settings, line).await
} }
pub async fn write_ha(client: &Client, settings: &InfluxDbSettings, reading: &HaReading) -> Result<()> { pub async fn write_zone(
if !settings.enabled { return Ok(()); } client: &Client,
settings: &InfluxDbSettings,
reading: &ZoneReading,
) -> Result<()> {
if !settings.enabled {
return Ok(());
}
let mut fields = Vec::new();
push_float(&mut fields, "gree_temperature", reading.gree_temperature);
push_float(
&mut fields,
"external_temperature",
reading.external_temperature,
);
push_float(
&mut fields,
"control_temperature",
reading.control_temperature,
);
push_float(
&mut fields,
"target_temperature",
reading.target_temperature,
);
push_float(&mut fields, "device_setpoint", reading.device_setpoint);
push_float(
&mut fields,
"outdoor_temperature",
reading.outdoor_temperature,
);
push_int(&mut fields, "power", reading.power as i64);
push_int(&mut fields, "fan_speed", reading.fan_speed as i64);
push_int(&mut fields, "demand", reading.demand as i64);
let line = line_protocol(
ZONE_MEASUREMENT,
&[
("zone_id", &reading.zone_id),
("device_id", &reading.device_id),
],
fields,
reading.timestamp,
)?;
write_line(client, settings, line).await
}
pub async fn write_ha(
client: &Client,
settings: &InfluxDbSettings,
reading: &HaReading,
) -> Result<()> {
if !settings.enabled {
return Ok(());
}
let zone = reading.zone_id.as_deref().unwrap_or(""); let zone = reading.zone_id.as_deref().unwrap_or("");
let mut fields = Vec::new(); let mut fields = Vec::new();
push_float(&mut fields, "temperature", Some(reading.temperature)); push_float(&mut fields, "temperature", Some(reading.temperature));
let line = line_protocol( let line = line_protocol(
HA_MEASUREMENT, HA_MEASUREMENT,
&[("entity_id", &reading.entity_id), ("zone_id", zone), ("kind", &reading.kind)], &[
("entity_id", &reading.entity_id),
("zone_id", zone),
("kind", &reading.kind),
],
fields, fields,
reading.timestamp, reading.timestamp,
)?; )?;
@@ -65,35 +137,89 @@ pub async fn write_batch(
zones: &[ZoneReading], zones: &[ZoneReading],
ha: &[HaReading], ha: &[HaReading],
) -> Result<()> { ) -> Result<()> {
if !settings.enabled { return Ok(()); } if !settings.enabled {
return Ok(());
}
let mut lines = Vec::with_capacity(devices.len() + zones.len() + ha.len()); let mut lines = Vec::with_capacity(devices.len() + zones.len() + ha.len());
for reading in devices { for reading in devices {
let mut fields = Vec::new(); let mut fields = Vec::new();
push_float(&mut fields, "indoor_temperature", reading.indoor_temperature); push_float(
push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature); &mut fields,
push_float(&mut fields, "target_temperature", Some(reading.target_temperature)); "indoor_temperature",
reading.indoor_temperature,
);
push_float(
&mut fields,
"outdoor_temperature",
reading.outdoor_temperature,
);
push_float(
&mut fields,
"target_temperature",
Some(reading.target_temperature),
);
push_int(&mut fields, "power", reading.power as i64); push_int(&mut fields, "power", reading.power as i64);
lines.push(line_protocol(DEVICE_MEASUREMENT, &[("device_id", &reading.device_id)], fields, reading.timestamp)?); lines.push(line_protocol(
DEVICE_MEASUREMENT,
&[("device_id", &reading.device_id)],
fields,
reading.timestamp,
)?);
} }
for reading in zones { for reading in zones {
let mut fields = Vec::new(); let mut fields = Vec::new();
push_float(&mut fields, "gree_temperature", reading.gree_temperature); push_float(&mut fields, "gree_temperature", reading.gree_temperature);
push_float(&mut fields, "external_temperature", reading.external_temperature); push_float(
push_float(&mut fields, "control_temperature", reading.control_temperature); &mut fields,
push_float(&mut fields, "target_temperature", reading.target_temperature); "external_temperature",
reading.external_temperature,
);
push_float(
&mut fields,
"control_temperature",
reading.control_temperature,
);
push_float(
&mut fields,
"target_temperature",
reading.target_temperature,
);
push_float(&mut fields, "device_setpoint", reading.device_setpoint); push_float(&mut fields, "device_setpoint", reading.device_setpoint);
push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature); push_float(
&mut fields,
"outdoor_temperature",
reading.outdoor_temperature,
);
push_int(&mut fields, "power", reading.power as i64); push_int(&mut fields, "power", reading.power as i64);
push_int(&mut fields, "fan_speed", reading.fan_speed as i64); push_int(&mut fields, "fan_speed", reading.fan_speed as i64);
push_int(&mut fields, "demand", reading.demand as i64); push_int(&mut fields, "demand", reading.demand as i64);
lines.push(line_protocol(ZONE_MEASUREMENT, &[("zone_id", &reading.zone_id), ("device_id", &reading.device_id)], fields, reading.timestamp)?); lines.push(line_protocol(
ZONE_MEASUREMENT,
&[
("zone_id", &reading.zone_id),
("device_id", &reading.device_id),
],
fields,
reading.timestamp,
)?);
} }
for reading in ha { for reading in ha {
let mut fields = Vec::new(); let mut fields = Vec::new();
push_float(&mut fields, "temperature", Some(reading.temperature)); push_float(&mut fields, "temperature", Some(reading.temperature));
lines.push(line_protocol(HA_MEASUREMENT, &[("entity_id", &reading.entity_id), ("zone_id", reading.zone_id.as_deref().unwrap_or("")), ("kind", &reading.kind)], fields, reading.timestamp)?); lines.push(line_protocol(
HA_MEASUREMENT,
&[
("entity_id", &reading.entity_id),
("zone_id", reading.zone_id.as_deref().unwrap_or("")),
("kind", &reading.kind),
],
fields,
reading.timestamp,
)?);
}
if lines.is_empty() {
return Ok(());
} }
if lines.is_empty() { return Ok(()); }
write_lines(client, settings, lines.join("\n")).await write_lines(client, settings, lines.join("\n")).await
} }
@@ -105,19 +231,32 @@ async fn write_lines(client: &Client, settings: &InfluxDbSettings, body: String)
validate(settings)?; validate(settings)?;
let base = settings.url.trim_end_matches('/'); let base = settings.url.trim_end_matches('/');
let request = if settings.version == "1" { let request = if settings.version == "1" {
let request = client.post(format!("{base}/write")) let request = client
.post(format!("{base}/write"))
.query(&[("db", settings.database.as_str()), ("precision", "ns")]) .query(&[("db", settings.database.as_str()), ("precision", "ns")])
.header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8") .header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(body.clone()); .body(body.clone());
if settings.username.trim().is_empty() { request } else { request.basic_auth(&settings.username, Some(&settings.password)) } if settings.username.trim().is_empty() {
request
} else {
request.basic_auth(&settings.username, Some(&settings.password))
}
} else { } else {
client.post(format!("{base}/api/v2/write")) client
.query(&[("org", settings.org.as_str()), ("bucket", settings.bucket.as_str()), ("precision", "ns")]) .post(format!("{base}/api/v2/write"))
.query(&[
("org", settings.org.as_str()),
("bucket", settings.bucket.as_str()),
("precision", "ns"),
])
.bearer_auth(settings.token.trim()) .bearer_auth(settings.token.trim())
.header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8") .header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(body) .body(body)
}; };
let response = request.send().await.context("InfluxDB write request failed")?; let response = request
.send()
.await
.context("InfluxDB write request failed")?;
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
@@ -125,4 +264,3 @@ async fn write_lines(client: &Client, settings: &InfluxDbSettings, body: String)
} }
Ok(()) Ok(())
} }
+31 -9
View File
@@ -11,14 +11,21 @@ mod protocol;
mod queries; mod queries;
mod state; mod state;
use std::{sync::{Arc, atomic::AtomicBool}, time::{Duration, Instant}};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use config::Config; use config::Config;
use db::Db; use db::Db;
use models::Device; use models::Device;
use protocol::GreeClient; use protocol::GreeClient;
use state::AppState; use state::AppState;
use tokio::{net::TcpListener, signal, sync::{broadcast, Notify, RwLock}}; use std::{
sync::{atomic::AtomicBool, Arc},
time::{Duration, Instant},
};
use tokio::{
net::TcpListener,
signal,
sync::{broadcast, Notify, RwLock},
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main] #[tokio::main]
@@ -27,7 +34,9 @@ async fn main() -> Result<()> {
init_tracing(); init_tracing();
let db = Db::open(&config.database)?; let db = Db::open(&config.database)?;
let mut runtime_settings = db.load_runtime_settings()?.unwrap_or_else(|| config.runtime_defaults()); let mut runtime_settings = db
.load_runtime_settings()?
.unwrap_or_else(|| config.runtime_defaults());
// Network deployment settings explicitly provided by the service environment are authoritative. // Network deployment settings explicitly provided by the service environment are authoritative.
// This makes /etc/gree-controller.env useful even after runtime settings were persisted in SQLite. // This makes /etc/gree-controller.env useful even after runtime settings were persisted in SQLite.
if std::env::var_os("GREE_CONTROLLER_DISCOVERY_BROADCAST").is_some() { if std::env::var_os("GREE_CONTROLLER_DISCOVERY_BROADCAST").is_some() {
@@ -58,7 +67,8 @@ async fn main() -> Result<()> {
config: Arc::new(config.clone()), config: Arc::new(config.clone()),
gree: GreeClient::new( gree: GreeClient::new(
runtime_settings.controller_id.clone(), runtime_settings.controller_id.clone(),
(!config.gree_interface.trim().is_empty()).then(|| config.gree_interface.trim().to_string()), (!config.gree_interface.trim().is_empty())
.then(|| config.gree_interface.trim().to_string()),
Some(events.clone()), Some(events.clone()),
debug_gree_frames.clone(), debug_gree_frames.clone(),
), ),
@@ -76,16 +86,23 @@ async fn main() -> Result<()> {
house_operation_lock: Arc::new(tokio::sync::Mutex::new(())), house_operation_lock: Arc::new(tokio::sync::Mutex::new(())),
configuration_operation_lock: Arc::new(tokio::sync::Mutex::new(())), configuration_operation_lock: Arc::new(tokio::sync::Mutex::new(())),
zone_control_cycle_lock: Arc::new(tokio::sync::Mutex::new(())), zone_control_cycle_lock: Arc::new(tokio::sync::Mutex::new(())),
pending_controller_commands: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), pending_controller_commands: Arc::new(tokio::sync::Mutex::new(
std::collections::HashMap::new(),
)),
started: Instant::now(), started: Instant::now(),
}; };
engine::start(state.clone()); engine::start(state.clone());
let app = api::router(state.clone()); let app = api::router(state.clone());
let listener = TcpListener::bind(config.bind).await let listener = TcpListener::bind(config.bind)
.await
.with_context(|| format!("cannot bind HTTP server to {}", config.bind))?; .with_context(|| format!("cannot bind HTTP server to {}", config.bind))?;
let gree_interface_log = if config.gree_interface.trim().is_empty() { "auto" } else { config.gree_interface.trim() }; let gree_interface_log = if config.gree_interface.trim().is_empty() {
"auto"
} else {
config.gree_interface.trim()
};
tracing::info!( tracing::info!(
address = %config.bind, address = %config.bind,
database = %config.database.display(), database = %config.database.display(),
@@ -113,12 +130,17 @@ fn init_tracing() {
} }
async fn shutdown_signal() { async fn shutdown_signal() {
let ctrl_c = async { signal::ctrl_c().await.expect("cannot install Ctrl+C handler"); }; let ctrl_c = async {
signal::ctrl_c()
.await
.expect("cannot install Ctrl+C handler");
};
#[cfg(unix)] #[cfg(unix)]
let terminate = async { let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate()) signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("cannot install SIGTERM handler") .expect("cannot install SIGTERM handler")
.recv().await; .recv()
.await;
}; };
#[cfg(not(unix))] #[cfg(not(unix))]
let terminate = std::future::pending::<()>(); let terminate = std::future::pending::<()>();
+1 -1
View File
@@ -1,7 +1,7 @@
use std::collections::BTreeMap;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::BTreeMap;
// Functional source split intentionally keeps items in the existing module namespace. // Functional source split intentionally keeps items in the existing module namespace.
include!("models/defaults.rs"); include!("models/defaults.rs");
-2
View File
@@ -25,7 +25,6 @@ pub struct Schedule {
pub flow_node_id: Option<String>, pub flow_node_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct FlowRuntimeNodeState { pub struct FlowRuntimeNodeState {
#[serde(default)] #[serde(default)]
@@ -98,4 +97,3 @@ pub struct Automation {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
-1
View File
@@ -109,4 +109,3 @@ pub struct ControlPlan {
pub zones: Vec<ZoneControlPlan>, pub zones: Vec<ZoneControlPlan>,
pub rules: Vec<AutomationPlanRule>, pub rules: Vec<AutomationPlanRule>,
} }
+114 -39
View File
@@ -1,39 +1,114 @@
fn default_true() -> bool { true } fn default_true() -> bool {
fn default_port() -> u16 { 7000 } true
fn default_protocol() -> u8 { 0 } }
fn default_mode() -> String { "cool".into() } fn default_port() -> u16 {
fn default_fan() -> u8 { 0 } 7000
fn default_target() -> f64 { 24.0 } }
fn default_hysteresis() -> f64 { 0.6 } fn default_protocol() -> u8 {
fn default_external_sensor_weight() -> f64 { 0.4 } 0
fn default_max_sensor_difference() -> f64 { 3.0 } }
fn default_control_temperature_source() -> String { "device".into() } fn default_mode() -> String {
fn default_min_cycle() -> u64 { 180 } "cool".into()
fn default_compressor_protection_seconds() -> u64 { 180 } }
fn default_sensor_stale_after() -> u64 { 300 } fn default_fan() -> u8 {
fn default_cooldown() -> u64 { 300 } 0
fn default_house_mode() -> String { "cool".into() } }
fn default_control_strategy() -> String { "setpoint".into() } fn default_target() -> f64 {
fn default_standby_offset() -> f64 { 2.0 } 24.0
fn default_min_adjust() -> u64 { 120 } }
fn default_schedule_preset() -> String { "custom".into() } fn default_hysteresis() -> f64 {
fn default_active_preset() -> String { "comfort".into() } 0.6
fn default_cool_comfort() -> f64 { 23.0 } }
fn default_cool_sleep() -> f64 { 24.5 } fn default_external_sensor_weight() -> f64 {
fn default_cool_away() -> f64 { 27.0 } 0.4
fn default_heat_comfort() -> f64 { 21.0 } }
fn default_heat_sleep() -> f64 { 19.0 } fn default_max_sensor_difference() -> f64 {
fn default_heat_away() -> f64 { 17.0 } 3.0
fn default_history_retention_days() -> u32 { 30 } }
fn default_event_log_retention_days() -> u32 { 30 } fn default_control_temperature_source() -> String {
fn default_influx_version() -> String { "2".into() } "device".into()
fn default_influx_database() -> String { "gree_controller".into() } }
fn default_influx_threshold_days() -> u32 { 30 } fn default_min_cycle() -> u64 {
fn default_night_start() -> String { "22:00".into() } 180
fn default_night_end() -> String { "06:00".into() } }
fn default_night_max_fan_speed() -> u8 { 1 } fn default_compressor_protection_seconds() -> u64 {
fn default_group_power_enabled() -> bool { true } 180
fn default_temporary_tolerance() -> f64 { 0.3 } }
fn default_temporary_start_kind() -> String { "now".into() } fn default_sensor_stale_after() -> u64 {
fn default_temporary_state() -> String { "scheduled".into() } 300
}
fn default_cooldown() -> u64 {
300
}
fn default_house_mode() -> String {
"cool".into()
}
fn default_control_strategy() -> String {
"setpoint".into()
}
fn default_standby_offset() -> f64 {
2.0
}
fn default_min_adjust() -> u64 {
120
}
fn default_schedule_preset() -> String {
"custom".into()
}
fn default_active_preset() -> String {
"comfort".into()
}
fn default_cool_comfort() -> f64 {
23.0
}
fn default_cool_sleep() -> f64 {
24.5
}
fn default_cool_away() -> f64 {
27.0
}
fn default_heat_comfort() -> f64 {
21.0
}
fn default_heat_sleep() -> f64 {
19.0
}
fn default_heat_away() -> f64 {
17.0
}
fn default_history_retention_days() -> u32 {
30
}
fn default_event_log_retention_days() -> u32 {
30
}
fn default_influx_version() -> String {
"2".into()
}
fn default_influx_database() -> String {
"gree_controller".into()
}
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
}
fn default_temporary_tolerance() -> f64 {
0.3
}
fn default_temporary_start_kind() -> String {
"now".into()
}
fn default_temporary_state() -> String {
"scheduled".into()
}
+69 -23
View File
@@ -162,21 +162,42 @@ pub struct DeviceCommand {
impl DeviceCommand { impl DeviceCommand {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.power.is_none() && self.mode.is_none() && self.target_temperature.is_none() self.power.is_none()
&& self.fan_speed.is_none() && self.swing_vertical.is_none() && self.swing_horizontal.is_none() && self.mode.is_none()
&& self.quiet.is_none() && self.turbo.is_none() && self.light.is_none() && self.target_temperature.is_none()
&& self.air.is_none() && self.xfan.is_none() && self.health.is_none() && self.sleep.is_none() && self.fan_speed.is_none()
&& self.swing_vertical.is_none()
&& self.swing_horizontal.is_none()
&& self.quiet.is_none()
&& self.turbo.is_none()
&& self.light.is_none()
&& self.air.is_none()
&& self.xfan.is_none()
&& self.health.is_none()
&& self.sleep.is_none()
} }
/// Return only fields that differ from the last known device state. /// Return only fields that differ from the last known device state.
pub fn changed_from(&self, device: &Device) -> Self { pub fn changed_from(&self, device: &Device) -> Self {
Self { Self {
power: self.power.filter(|value| *value != device.power), power: self.power.filter(|value| *value != device.power),
mode: self.mode.as_ref().filter(|value| value.as_str() != device.mode.as_str()).cloned(), mode: self
target_temperature: self.target_temperature.filter(|value| value.clamp(8.0, 30.0).round() != device.target_temperature.clamp(8.0, 30.0).round()), .mode
fan_speed: self.fan_speed.filter(|value| (*value).min(5) != device.fan_speed), .as_ref()
swing_vertical: self.swing_vertical.filter(|value| *value != device.swing_vertical), .filter(|value| value.as_str() != device.mode.as_str())
swing_horizontal: self.swing_horizontal.filter(|value| *value != device.swing_horizontal), .cloned(),
target_temperature: self.target_temperature.filter(|value| {
value.clamp(8.0, 30.0).round() != device.target_temperature.clamp(8.0, 30.0).round()
}),
fan_speed: self
.fan_speed
.filter(|value| (*value).min(5) != device.fan_speed),
swing_vertical: self
.swing_vertical
.filter(|value| *value != device.swing_vertical),
swing_horizontal: self
.swing_horizontal
.filter(|value| *value != device.swing_horizontal),
quiet: self.quiet.filter(|value| *value != device.quiet), quiet: self.quiet.filter(|value| *value != device.quiet),
turbo: self.turbo.filter(|value| *value != device.turbo), turbo: self.turbo.filter(|value| *value != device.turbo),
light: self.light.filter(|value| *value != device.light), light: self.light.filter(|value| *value != device.light),
@@ -188,19 +209,45 @@ impl DeviceCommand {
} }
pub fn apply(&self, device: &mut Device) { pub fn apply(&self, device: &mut Device) {
if let Some(v) = self.power { device.power = v; } if let Some(v) = self.power {
if let Some(v) = &self.mode { device.mode = v.clone(); } device.power = v;
if let Some(v) = self.target_temperature { device.target_temperature = v.clamp(8.0, 30.0).round(); } }
if let Some(v) = self.fan_speed { device.fan_speed = v.min(5); } if let Some(v) = &self.mode {
if let Some(v) = self.swing_vertical { device.swing_vertical = v; } device.mode = v.clone();
if let Some(v) = self.swing_horizontal { device.swing_horizontal = v; } }
if let Some(v) = self.quiet { device.quiet = v; } if let Some(v) = self.target_temperature {
if let Some(v) = self.turbo { device.turbo = v; } device.target_temperature = v.clamp(8.0, 30.0).round();
if let Some(v) = self.light { device.light = v; } }
if let Some(v) = self.air { device.air = v; } if let Some(v) = self.fan_speed {
if let Some(v) = self.xfan { device.xfan = v; } device.fan_speed = v.min(5);
if let Some(v) = self.health { device.health = v; } }
if let Some(v) = self.sleep { device.sleep = v; } if let Some(v) = self.swing_vertical {
device.swing_vertical = v;
}
if let Some(v) = self.swing_horizontal {
device.swing_horizontal = v;
}
if let Some(v) = self.quiet {
device.quiet = v;
}
if let Some(v) = self.turbo {
device.turbo = v;
}
if let Some(v) = self.light {
device.light = v;
}
if let Some(v) = self.air {
device.air = v;
}
if let Some(v) = self.xfan {
device.xfan = v;
}
if let Some(v) = self.health {
device.health = v;
}
if let Some(v) = self.sleep {
device.sleep = v;
}
device.updated_at = Utc::now(); device.updated_at = Utc::now();
} }
} }
@@ -227,4 +274,3 @@ impl From<&Device> for ManualDeviceBaseline {
} }
} }
} }
-1
View File
@@ -49,4 +49,3 @@ pub struct EventLog {
pub message: String, pub message: String,
pub metadata: Value, pub metadata: Value,
} }
+30 -12
View File
@@ -78,7 +78,6 @@ impl Default for InfluxDbSettings {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationAlertTypes { pub struct NotificationAlertTypes {
/// Home Assistant sensor exceeded the configured freshness window. /// Home Assistant sensor exceeded the configured freshness window.
@@ -110,8 +109,14 @@ pub struct NotificationAlertTypes {
impl Default for NotificationAlertTypes { impl Default for NotificationAlertTypes {
fn default() -> Self { fn default() -> Self {
Self { Self {
stale_sensor: true, sensor_errors: true, communication: true, target_timeout: true, stale_sensor: true,
automation: true, control_errors: true, important_events: true, other: true, sensor_errors: true,
communication: true,
target_timeout: true,
automation: true,
control_errors: true,
important_events: true,
other: true,
} }
} }
} }
@@ -145,18 +150,32 @@ pub struct NotificationSettings {
pub alert_types: NotificationAlertTypes, pub alert_types: NotificationAlertTypes,
} }
fn default_notification_mode() -> String { "problems".into() } fn default_notification_mode() -> String {
fn default_notification_provider() -> String { "pushover".into() } "problems".into()
fn default_notification_cooldown() -> u64 { 300 } }
fn default_notification_failure_threshold() -> u32 { 3 } fn default_notification_provider() -> String {
fn default_notification_target_timeout() -> u32 { 60 } "pushover".into()
}
fn default_notification_cooldown() -> u64 {
300
}
fn default_notification_failure_threshold() -> u32 {
3
}
fn default_notification_target_timeout() -> u32 {
60
}
impl Default for NotificationSettings { impl Default for NotificationSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: false, mode: default_notification_mode(), provider: default_notification_provider(), enabled: false,
pushover_app_token: String::new(), pushover_user_key: String::new(), mode: default_notification_mode(),
slack_webhook_url: String::new(), discord_webhook_url: String::new(), provider: default_notification_provider(),
pushover_app_token: String::new(),
pushover_user_key: String::new(),
slack_webhook_url: String::new(),
discord_webhook_url: String::new(),
cooldown_seconds: default_notification_cooldown(), cooldown_seconds: default_notification_cooldown(),
communication_failure_threshold: default_notification_failure_threshold(), communication_failure_threshold: default_notification_failure_threshold(),
target_timeout_minutes: default_notification_target_timeout(), target_timeout_minutes: default_notification_target_timeout(),
@@ -204,4 +223,3 @@ impl Default for NightModeSettings {
} }
} }
} }
-1
View File
@@ -106,4 +106,3 @@ pub struct TemporaryQuickThermostatRequest {
#[serde(default)] #[serde(default)]
pub max_duration_minutes: Option<u64>, pub max_duration_minutes: Option<u64>,
} }
+8 -4
View File
@@ -175,7 +175,11 @@ pub struct Zone {
impl Zone { impl Zone {
pub fn hysteresis_for_mode(&self, mode: &str) -> f64 { pub fn hysteresis_for_mode(&self, mode: &str) -> f64 {
let value = if self.separate_hysteresis { let value = if self.separate_hysteresis {
if mode == "heat" { self.heat_hysteresis } else { self.cool_hysteresis } if mode == "heat" {
self.heat_hysteresis
} else {
self.cool_hysteresis
}
} else { } else {
self.hysteresis self.hysteresis
}; };
@@ -183,8 +187,9 @@ impl Zone {
} }
} }
fn default_sensor_source() -> String { "device".into() } fn default_sensor_source() -> String {
"device".into()
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClimateGroup { pub struct ClimateGroup {
@@ -244,4 +249,3 @@ pub struct ZoneControlPatch {
#[serde(default)] #[serde(default)]
pub clear_temporary_quick_thermostat: Option<bool>, pub clear_temporary_quick_thermostat: Option<bool>,
} }
+162 -42
View File
@@ -1,43 +1,84 @@
use std::{collections::HashMap, sync::{Mutex, OnceLock}}; use crate::{models::NotificationSettings, state::AppState};
use chrono::Utc; use chrono::Utc;
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::{models::NotificationSettings, state::AppState}; use std::{
collections::HashMap,
sync::{Mutex, OnceLock},
};
static LAST_SENT: OnceLock<Mutex<HashMap<String, i64>>> = OnceLock::new(); static LAST_SENT: OnceLock<Mutex<HashMap<String, i64>>> = OnceLock::new();
fn important_kind(kind: &str) -> bool { fn important_kind(kind: &str) -> bool {
matches!(kind, matches!(
"device.offline" | "device.recovered" | "automation.fired" | "automation.error" | kind,
"zone.target_timeout" | "zone.action_error" | "house.mode" | "house.preset" | "device.offline"
"device.communication_error") | "device.recovered"
| "automation.fired"
| "automation.error"
| "zone.target_timeout"
| "zone.action_error"
| "house.mode"
| "house.preset"
| "device.communication_error"
)
} }
fn alert_type_enabled(cfg: &NotificationSettings, kind: &str) -> bool { fn alert_type_enabled(cfg: &NotificationSettings, kind: &str) -> bool {
let types = &cfg.alert_types; let types = &cfg.alert_types;
if kind == "ha.sensor_stale" { return types.stale_sensor; } if kind == "ha.sensor_stale" {
if kind.starts_with("ha.sensor_") { return types.sensor_errors; } return types.stale_sensor;
if matches!(kind, "device.offline" | "device.communication_error" | "device.command_unconfirmed") { return types.communication; } }
if kind == "zone.target_timeout" { return types.target_timeout; } if kind.starts_with("ha.sensor_") {
if kind.starts_with("automation.") { return types.automation; } return types.sensor_errors;
if matches!(kind, }
"zone.action_error" | "zone.mode_change_off_error" | "zone.local_power_error" | if matches!(
"zone.device_missing" | "zone.sensor_discrepancy" | kind,
"zone.temporary_quick_thermostat_cancelled" | "zone.temporary_quick_thermostat_poweroff_error" | "device.offline" | "device.communication_error" | "device.command_unconfirmed"
"group.power_error" ) {
) { return types.control_errors; } return types.communication;
if important_kind(kind) { return types.important_events; } }
if kind == "zone.target_timeout" {
return types.target_timeout;
}
if kind.starts_with("automation.") {
return types.automation;
}
if matches!(
kind,
"zone.action_error"
| "zone.mode_change_off_error"
| "zone.local_power_error"
| "zone.device_missing"
| "zone.sensor_discrepancy"
| "zone.temporary_quick_thermostat_cancelled"
| "zone.temporary_quick_thermostat_poweroff_error"
| "group.power_error"
) {
return types.control_errors;
}
if important_kind(kind) {
return types.important_events;
}
types.other types.other
} }
fn should_send(cfg: &NotificationSettings, level: &str, kind: &str) -> bool { fn should_send(cfg: &NotificationSettings, level: &str, kind: &str) -> bool {
if !cfg.enabled || !alert_type_enabled(cfg, kind) { return false; } if !cfg.enabled || !alert_type_enabled(cfg, kind) {
if level == "error" || level == "warn" { return true; } return false;
}
if level == "error" || level == "warn" {
return true;
}
cfg.mode == "important" && important_kind(kind) cfg.mode == "important" && important_kind(kind)
} }
fn cooldown_key(cfg: &NotificationSettings, kind: &str, metadata: &Value) -> String { fn cooldown_key(cfg: &NotificationSettings, kind: &str, metadata: &Value) -> String {
let entity = metadata.get("device_id").or_else(|| metadata.get("zone_id")).or_else(|| metadata.get("automation_id")) let entity = metadata
.and_then(Value::as_str).unwrap_or("global"); .get("device_id")
.or_else(|| metadata.get("zone_id"))
.or_else(|| metadata.get("automation_id"))
.and_then(Value::as_str)
.unwrap_or("global");
format!("{}:{}:{}", cfg.provider, kind, entity) format!("{}:{}:{}", cfg.provider, kind, entity)
} }
@@ -45,22 +86,55 @@ fn take_cooldown(cfg: &NotificationSettings, kind: &str, metadata: &Value) -> bo
let now = Utc::now().timestamp(); let now = Utc::now().timestamp();
let key = cooldown_key(cfg, kind, metadata); let key = cooldown_key(cfg, kind, metadata);
let map = LAST_SENT.get_or_init(|| Mutex::new(HashMap::new())); let map = LAST_SENT.get_or_init(|| Mutex::new(HashMap::new()));
let Ok(mut map) = map.lock() else { return false; }; let Ok(mut map) = map.lock() else {
return false;
};
if let Some(last) = map.get(&key) { if let Some(last) = map.get(&key) {
if now - *last < cfg.cooldown_seconds.max(30) as i64 { return false; } if now - *last < cfg.cooldown_seconds.max(30) as i64 {
return false;
}
} }
map.insert(key, now); map.insert(key, now);
true true
} }
pub async fn dispatch(state: AppState, level: String, kind: String, message: String, metadata: Value) { pub async fn dispatch(
state: AppState,
level: String,
kind: String,
message: String,
metadata: Value,
) {
let cfg = state.settings.read().await.notifications.clone(); let cfg = state.settings.read().await.notifications.clone();
if !should_send(&cfg, &level, &kind) || !take_cooldown(&cfg, &kind, &metadata) { return; } if !should_send(&cfg, &level, &kind) || !take_cooldown(&cfg, &kind, &metadata) {
let title = format!("GREE Controller - {}", if level == "error" { "error" } else if level == "warn" { "warning" } else { "event" }); return;
}
let title = format!(
"GREE Controller - {}",
if level == "error" {
"error"
} else if level == "warn" {
"warning"
} else {
"event"
}
);
let result = match cfg.provider.as_str() { let result = match cfg.provider.as_str() {
"pushover" => send_pushover(&state, &cfg, &title, &message).await, "pushover" => send_pushover(&state, &cfg, &title, &message).await,
"slack" => send_webhook(&cfg.slack_webhook_url, json!({"text": format!("*{}*\\n{}", title, message)})).await, "slack" => {
"discord" => send_webhook(&cfg.discord_webhook_url, json!({"content": format!("**{}**\\n{}", title, message)})).await, send_webhook(
&cfg.slack_webhook_url,
json!({"text": format!("*{}*\\n{}", title, message)}),
)
.await
}
"discord" => {
send_webhook(
&cfg.discord_webhook_url,
json!({"content": format!("**{}**\\n{}", title, message)}),
)
.await
}
_ => Err("unsupported notification provider".into()), _ => Err("unsupported notification provider".into()),
}; };
if let Err(err) = result { if let Err(err) = result {
@@ -68,33 +142,79 @@ pub async fn dispatch(state: AppState, level: String, kind: String, message: Str
} }
} }
async fn send_pushover(state: &AppState, cfg: &NotificationSettings, title: &str, message: &str) -> Result<(), String> { async fn send_pushover(
if cfg.pushover_app_token.trim().is_empty() || cfg.pushover_user_key.trim().is_empty() { return Err("Pushover credentials are incomplete".into()); } state: &AppState,
let response = state.http.post("https://api.pushover.net/1/messages.json") cfg: &NotificationSettings,
.form(&[("token", cfg.pushover_app_token.as_str()), ("user", cfg.pushover_user_key.as_str()), ("title", title), ("message", message)]) title: &str,
.send().await.map_err(|e| e.to_string())?; message: &str,
if response.status().is_success() { Ok(()) } else { Err(format!("Pushover HTTP {}", response.status())) } ) -> Result<(), String> {
if cfg.pushover_app_token.trim().is_empty() || cfg.pushover_user_key.trim().is_empty() {
return Err("Pushover credentials are incomplete".into());
}
let response = state
.http
.post("https://api.pushover.net/1/messages.json")
.form(&[
("token", cfg.pushover_app_token.as_str()),
("user", cfg.pushover_user_key.as_str()),
("title", title),
("message", message),
])
.send()
.await
.map_err(|e| e.to_string())?;
if response.status().is_success() {
Ok(())
} else {
Err(format!("Pushover HTTP {}", response.status()))
}
} }
async fn send_webhook(url: &str, body: Value) -> Result<(), String> { async fn send_webhook(url: &str, body: Value) -> Result<(), String> {
let parsed = url::Url::parse(url).map_err(|_| "invalid webhook URL".to_string())?; let parsed = url::Url::parse(url).map_err(|_| "invalid webhook URL".to_string())?;
if parsed.scheme() != "https" { return Err("webhook URL must use HTTPS".into()); } if parsed.scheme() != "https" {
return Err("webhook URL must use HTTPS".into());
}
let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase(); let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
let allowed = host == "hooks.slack.com" || host == "discord.com" || host == "discordapp.com"; let allowed = host == "hooks.slack.com" || host == "discord.com" || host == "discordapp.com";
if !allowed { return Err("webhook host is not supported".into()); } if !allowed {
return Err("webhook host is not supported".into());
}
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none()) .redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10))
.build().map_err(|e| e.to_string())?; .build()
let response = client.post(parsed).json(&body).send().await.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if response.status().is_success() { Ok(()) } else { Err(format!("webhook HTTP {}", response.status())) } let response = client
.post(parsed)
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?;
if response.status().is_success() {
Ok(())
} else {
Err(format!("webhook HTTP {}", response.status()))
}
} }
pub async fn test(state: &AppState, cfg: NotificationSettings) -> Result<(), String> { pub async fn test(state: &AppState, cfg: NotificationSettings) -> Result<(), String> {
match cfg.provider.as_str() { match cfg.provider.as_str() {
"pushover" => send_pushover(state, &cfg, "GREE Controller", "Test notification").await, "pushover" => send_pushover(state, &cfg, "GREE Controller", "Test notification").await,
"slack" => send_webhook(&cfg.slack_webhook_url, json!({"text":"GREE Controller - test notification"})).await, "slack" => {
"discord" => send_webhook(&cfg.discord_webhook_url, json!({"content":"GREE Controller - test notification"})).await, send_webhook(
&cfg.slack_webhook_url,
json!({"text":"GREE Controller - test notification"}),
)
.await
}
"discord" => {
send_webhook(
&cfg.discord_webhook_url,
json!({"content":"GREE Controller - test notification"}),
)
.await
}
_ => Err("unsupported notification provider".into()), _ => Err("unsupported notification provider".into()),
} }
} }
+38 -13
View File
@@ -1,5 +1,11 @@
use aes::{Aes128, cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray}}; use aes::{
use aes_gcm::{Aes128Gcm, Nonce, aead::{AeadInPlace, KeyInit as AeadKeyInit}}; cipher::{generic_array::GenericArray, BlockDecrypt, BlockEncrypt, KeyInit},
Aes128,
};
use aes_gcm::{
aead::{AeadInPlace, KeyInit as AeadKeyInit},
Aes128Gcm, Nonce,
};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use base64::{ use base64::{
alphabet, alphabet,
@@ -15,7 +21,9 @@ pub const GENERIC_GREE_V1_KEY: &str = "a3K8Bx%2r8Y7#xDh";
/// Shared discovery/bind key used by AES-128-GCM capable Wi-Fi modules. /// Shared discovery/bind key used by AES-128-GCM capable Wi-Fi modules.
pub const GENERIC_GREE_V2_KEY: &str = "{yxAHAY_Lm6pbC/<"; pub const GENERIC_GREE_V2_KEY: &str = "{yxAHAY_Lm6pbC/<";
/// GREE protocol v2 uses a fixed nonce and AAD, matching the EWPE/GREE LAN protocol. /// GREE protocol v2 uses a fixed nonce and AAD, matching the EWPE/GREE LAN protocol.
const GCM_NONCE: [u8; 12] = [0x54, 0x40, 0x78, 0x44, 0x49, 0x67, 0x5a, 0x51, 0x6c, 0x5e, 0x63, 0x13]; const GCM_NONCE: [u8; 12] = [
0x54, 0x40, 0x78, 0x44, 0x49, 0x67, 0x5a, 0x51, 0x6c, 0x5e, 0x63, 0x13,
];
const GCM_AAD: &[u8] = b"qualcomm-test"; const GCM_AAD: &[u8] = b"qualcomm-test";
// Some GREE Wi-Fi modules emit technically non-canonical Base64: padding may // Some GREE Wi-Fi modules emit technically non-canonical Base64: padding may
@@ -62,7 +70,9 @@ pub fn encrypt_v1(key: &str, plaintext: &[u8]) -> Result<String> {
pub fn decrypt_v1(key: &str, ciphertext_b64: &str) -> Result<Vec<u8>> { pub fn decrypt_v1(key: &str, ciphertext_b64: &str) -> Result<Vec<u8>> {
let key = normalize_key(key)?; let key = normalize_key(key)?;
let cipher = Aes128::new_from_slice(&key).map_err(|_| anyhow!("invalid AES key"))?; let cipher = Aes128::new_from_slice(&key).map_err(|_| anyhow!("invalid AES key"))?;
let mut data = GREE_BASE64_DECODE.decode(ciphertext_b64).context("invalid base64 packet")?; let mut data = GREE_BASE64_DECODE
.decode(ciphertext_b64)
.context("invalid base64 packet")?;
if data.is_empty() || data.len() % 16 != 0 { if data.is_empty() || data.len() % 16 != 0 {
bail!("invalid AES-ECB ciphertext length") bail!("invalid AES-ECB ciphertext length")
} }
@@ -94,10 +104,12 @@ pub struct V2Encrypted {
pub fn encrypt_v2(key: &str, plaintext: &[u8]) -> Result<V2Encrypted> { pub fn encrypt_v2(key: &str, plaintext: &[u8]) -> Result<V2Encrypted> {
let key = normalize_key(key)?; let key = normalize_key(key)?;
let cipher = <Aes128Gcm as AeadKeyInit>::new_from_slice(&key).map_err(|_| anyhow!("invalid AES-GCM key"))?; let cipher = <Aes128Gcm as AeadKeyInit>::new_from_slice(&key)
.map_err(|_| anyhow!("invalid AES-GCM key"))?;
let nonce = Nonce::from_slice(&GCM_NONCE); let nonce = Nonce::from_slice(&GCM_NONCE);
let mut buffer = plaintext.to_vec(); let mut buffer = plaintext.to_vec();
let tag = cipher.encrypt_in_place_detached(nonce, GCM_AAD, &mut buffer) let tag = cipher
.encrypt_in_place_detached(nonce, GCM_AAD, &mut buffer)
.map_err(|_| anyhow!("AES-GCM encryption failed"))?; .map_err(|_| anyhow!("AES-GCM encryption failed"))?;
Ok(V2Encrypted { Ok(V2Encrypted {
ciphertext: STANDARD.encode(buffer), ciphertext: STANDARD.encode(buffer),
@@ -107,13 +119,21 @@ pub fn encrypt_v2(key: &str, plaintext: &[u8]) -> Result<V2Encrypted> {
pub fn decrypt_v2(key: &str, ciphertext_b64: &str, tag_b64: &str) -> Result<Vec<u8>> { pub fn decrypt_v2(key: &str, ciphertext_b64: &str, tag_b64: &str) -> Result<Vec<u8>> {
let key = normalize_key(key)?; let key = normalize_key(key)?;
let cipher = <Aes128Gcm as AeadKeyInit>::new_from_slice(&key).map_err(|_| anyhow!("invalid AES-GCM key"))?; let cipher = <Aes128Gcm as AeadKeyInit>::new_from_slice(&key)
let tag_bytes = GREE_BASE64_DECODE.decode(tag_b64).context("invalid GCM tag")?; .map_err(|_| anyhow!("invalid AES-GCM key"))?;
if tag_bytes.len() != 16 { bail!("invalid GCM tag length: {} bytes", tag_bytes.len()) } let tag_bytes = GREE_BASE64_DECODE
let mut data = GREE_BASE64_DECODE.decode(ciphertext_b64).context("invalid GCM ciphertext")?; .decode(tag_b64)
.context("invalid GCM tag")?;
if tag_bytes.len() != 16 {
bail!("invalid GCM tag length: {} bytes", tag_bytes.len())
}
let mut data = GREE_BASE64_DECODE
.decode(ciphertext_b64)
.context("invalid GCM ciphertext")?;
let nonce = Nonce::from_slice(&GCM_NONCE); let nonce = Nonce::from_slice(&GCM_NONCE);
let tag = GenericArray::from_slice(&tag_bytes); let tag = GenericArray::from_slice(&tag_bytes);
cipher.decrypt_in_place_detached(nonce, GCM_AAD, &mut data, tag) cipher
.decrypt_in_place_detached(nonce, GCM_AAD, &mut data, tag)
.map_err(|_| anyhow!("AES-GCM authentication failed"))?; .map_err(|_| anyhow!("AES-GCM authentication failed"))?;
// A few modules append 0xff filler bytes to decrypted JSON. // A few modules append 0xff filler bytes to decrypted JSON.
data.retain(|byte| *byte != 0xff); data.retain(|byte| *byte != 0xff);
@@ -135,7 +155,10 @@ mod tests {
fn v2_round_trip() { fn v2_round_trip() {
let value = b"gree-gcm-test"; let value = b"gree-gcm-test";
let encrypted = encrypt_v2(GENERIC_GREE_V2_KEY, value).unwrap(); let encrypted = encrypt_v2(GENERIC_GREE_V2_KEY, value).unwrap();
assert_eq!(decrypt_v2(GENERIC_GREE_V2_KEY, &encrypted.ciphertext, &encrypted.tag).unwrap(), value); assert_eq!(
decrypt_v2(GENERIC_GREE_V2_KEY, &encrypted.ciphertext, &encrypted.tag).unwrap(),
value
);
} }
#[test] #[test]
@@ -143,7 +166,9 @@ mod tests {
// 16 zero bytes canonically end with `A==`. `B==` carries the same // 16 zero bytes canonically end with `A==`. `B==` carries the same
// useful two bits but has non-zero unused trailing bits. Python's // useful two bits but has non-zero unused trailing bits. Python's
// base64.b64decode accepts it and real GREE modules emit this form. // base64.b64decode accepts it and real GREE modules emit this form.
let decoded = GREE_BASE64_DECODE.decode("AAAAAAAAAAAAAAAAAAAAAB==").unwrap(); let decoded = GREE_BASE64_DECODE
.decode("AAAAAAAAAAAAAAAAAAAAAB==")
.unwrap();
assert_eq!(decoded, vec![0_u8; 16]); assert_eq!(decoded, vec![0_u8; 16]);
} }
} }
+18 -8
View File
@@ -1,14 +1,25 @@
use std::{collections::{HashMap, HashSet}, net::{Ipv4Addr, SocketAddr, SocketAddrV4}, sync::{Arc, Mutex, atomic::{AtomicBool, AtomicU64, Ordering}}, time::Duration}; use super::crypto::{
decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2, GENERIC_GREE_V1_KEY, GENERIC_GREE_V2_KEY,
};
use crate::models::{ApiEvent, Device, DeviceCommand};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use chrono::Utc; use chrono::Utc;
use serde_json::{json, Value}; use serde_json::{json, Value};
use tokio::{net::UdpSocket, sync::broadcast, time::{timeout, Instant}}; use std::{
use uuid::Uuid; collections::{HashMap, HashSet},
use crate::models::{ApiEvent, Device, DeviceCommand}; net::{Ipv4Addr, SocketAddr, SocketAddrV4},
use super::crypto::{ sync::{
decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2, atomic::{AtomicBool, AtomicU64, Ordering},
GENERIC_GREE_V1_KEY, GENERIC_GREE_V2_KEY, Arc, Mutex,
},
time::Duration,
}; };
use tokio::{
net::UdpSocket,
sync::broadcast,
time::{timeout, Instant},
};
use uuid::Uuid;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct BindResult { pub struct BindResult {
@@ -29,7 +40,6 @@ pub struct GreeClient {
sleep_unsupported: Arc<Mutex<HashSet<String>>>, sleep_unsupported: Arc<Mutex<HashSet<String>>>,
} }
// Functional source split intentionally keeps items in the existing module namespace. // Functional source split intentionally keeps items in the existing module namespace.
include!("gree/core.rs"); include!("gree/core.rs");
include!("gree/discovery.rs"); include!("gree/discovery.rs");
+28 -8
View File
@@ -7,7 +7,12 @@ impl GreeClient {
let mut errors = Vec::new(); let mut errors = Vec::new();
for &version in versions { for &version in versions {
match self.bind_attempt(device, version).await { match self.bind_attempt(device, version).await {
Ok(key) => return Ok(BindResult { key, protocol_version: version }), Ok(key) => {
return Ok(BindResult {
key,
protocol_version: version,
})
}
Err(err) => { Err(err) => {
tracing::warn!(device=%device.id, ip=%device.ip, protocol=version, error=?err, "GREE bind attempt failed"); tracing::warn!(device=%device.id, ip=%device.ip, protocol=version, error=?err, "GREE bind attempt failed");
errors.push(format!("V{version}: {err}")); errors.push(format!("V{version}: {err}"));
@@ -28,7 +33,10 @@ impl GreeClient {
async fn bind_attempt(&self, device: &Device, version: u8) -> Result<String> { async fn bind_attempt(&self, device: &Device, version: u8) -> Result<String> {
let target = self.device_target(device)?; let target = self.device_target(device)?;
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None }; let target_hint = match target {
SocketAddr::V4(addr) => Some(*addr.ip()),
SocketAddr::V6(_) => None,
};
let socket = self.udp_socket(true, target_hint).await?; let socket = self.udp_socket(true, target_hint).await?;
// Binding is time-sensitive on older GREE Wi-Fi modules. Refresh the // Binding is time-sensitive on older GREE Wi-Fi modules. Refresh the
@@ -56,16 +64,28 @@ impl GreeClient {
let wire_mac = Self::wire_mac(device); let wire_mac = Self::wire_mac(device);
let inner = json!({"mac": wire_mac, "t": "bind", "uid": 0}); let inner = json!({"mac": wire_mac, "t": "bind", "uid": 0});
let generic_key = if version == 2 { GENERIC_GREE_V2_KEY } else { GENERIC_GREE_V1_KEY }; let generic_key = if version == 2 {
let response = self.request_on_socket(device, &inner, generic_key, true, version, &socket).await?; GENERIC_GREE_V2_KEY
let kind = response.get("t").and_then(Value::as_str).unwrap_or_default(); } else {
GENERIC_GREE_V1_KEY
};
let response = self
.request_on_socket(device, &inner, generic_key, true, version, &socket)
.await?;
let kind = response
.get("t")
.and_then(Value::as_str)
.unwrap_or_default();
if !kind.eq_ignore_ascii_case("bindok") { if !kind.eq_ignore_ascii_case("bindok") {
bail!("unexpected bind response type: {kind}") bail!("unexpected bind response type: {kind}")
} }
let key = response.get("key").and_then(Value::as_str) let key = response
.get("key")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("bind response does not contain device key"))?; .ok_or_else(|| anyhow!("bind response does not contain device key"))?;
if key.is_empty() { bail!("device returned an empty key") } if key.is_empty() {
bail!("device returned an empty key")
}
Ok(key.to_string()) Ok(key.to_string())
} }
} }
+137 -33
View File
@@ -1,10 +1,16 @@
impl GreeClient { impl GreeClient {
pub fn quiet_command_supported(&self, device_id: &str) -> bool { pub fn quiet_command_supported(&self, device_id: &str) -> bool {
self.quiet_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true) self.quiet_unsupported
.lock()
.map(|items| !items.contains(device_id))
.unwrap_or(true)
} }
pub fn sleep_command_supported(&self, device_id: &str) -> bool { pub fn sleep_command_supported(&self, device_id: &str) -> bool {
self.sleep_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true) self.sleep_unsupported
.lock()
.map(|items| !items.contains(device_id))
.unwrap_or(true)
} }
async fn request_command_with_buzzer_fallback( async fn request_command_with_buzzer_fallback(
@@ -15,17 +21,29 @@ impl GreeClient {
suppress_beep: bool, suppress_beep: bool,
) -> Result<Value> { ) -> Result<Value> {
let try_buzzer_suppression = suppress_beep let try_buzzer_suppression = suppress_beep
&& self.buzzer_unsupported.lock().map(|items| !items.contains(&device.id)).unwrap_or(true); && self
.buzzer_unsupported
.lock()
.map(|items| !items.contains(&device.id))
.unwrap_or(true);
let inner = Self::command_payload(command, try_buzzer_suppression)?; let inner = Self::command_payload(command, try_buzzer_suppression)?;
match self.request(device, &inner, key, false, device.protocol_version).await { match self
.request(device, &inner, key, false, device.protocol_version)
.await
{
Ok(value) => Ok(value), Ok(value) => Ok(value),
Err(first_err) if try_buzzer_suppression => { Err(first_err) if try_buzzer_suppression => {
// Some firmwares reject unknown buzzer properties instead of ignoring them. // Some firmwares reject unknown buzzer properties instead of ignoring them.
// Retry the exact state change without buzzer fields and remember the fallback. // Retry the exact state change without buzzer fields and remember the fallback.
let fallback = Self::command_payload(command, false)?; let fallback = Self::command_payload(command, false)?;
match self.request(device, &fallback, key, false, device.protocol_version).await { match self
.request(device, &fallback, key, false, device.protocol_version)
.await
{
Ok(value) => { Ok(value) => {
if let Ok(mut items) = self.buzzer_unsupported.lock() { items.insert(device.id.clone()); } if let Ok(mut items) = self.buzzer_unsupported.lock() {
items.insert(device.id.clone());
}
tracing::warn!(device=%device.id, "GREE buzzer suppression is unsupported; using normal command frames for this device"); tracing::warn!(device=%device.id, "GREE buzzer suppression is unsupported; using normal command frames for this device");
Ok(value) Ok(value)
} }
@@ -36,8 +54,16 @@ impl GreeClient {
} }
} }
pub async fn command(&self, device: &Device, command: &DeviceCommand, suppress_beep: bool) -> Result<DeviceCommand> { pub async fn command(
let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?; &self,
device: &Device,
command: &DeviceCommand,
suppress_beep: bool,
) -> Result<DeviceCommand> {
let key = device
.key
.as_deref()
.ok_or_else(|| anyhow!("device is not bound"))?;
let mut effective = command.clone(); let mut effective = command.clone();
if effective.quiet.is_some() && !self.quiet_command_supported(&device.id) { if effective.quiet.is_some() && !self.quiet_command_supported(&device.id) {
effective.quiet = None; effective.quiet = None;
@@ -49,7 +75,10 @@ impl GreeClient {
return Ok(effective); return Ok(effective);
} }
match self.request_command_with_buzzer_fallback(device, key, &effective, suppress_beep).await { match self
.request_command_with_buzzer_fallback(device, key, &effective, suppress_beep)
.await
{
Ok(_) => Ok(effective), Ok(_) => Ok(effective),
Err(first_err) => { Err(first_err) => {
// Quiet and native Sleep are optional GREE features. A unit may report a // Quiet and native Sleep are optional GREE features. A unit may report a
@@ -59,8 +88,19 @@ impl GreeClient {
let mut fallback = effective.clone(); let mut fallback = effective.clone();
fallback.sleep = None; fallback.sleep = None;
if !fallback.is_empty() { if !fallback.is_empty() {
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() { if self
if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); } .request_command_with_buzzer_fallback(
device,
key,
&fallback,
suppress_beep,
)
.await
.is_ok()
{
if let Ok(mut items) = self.sleep_unsupported.lock() {
items.insert(device.id.clone());
}
tracing::warn!(device=%device.id, "GREE native Sleep command is unsupported; night mode will continue without Sleep for this device"); tracing::warn!(device=%device.id, "GREE native Sleep command is unsupported; night mode will continue without Sleep for this device");
return Ok(fallback); return Ok(fallback);
} }
@@ -70,8 +110,19 @@ impl GreeClient {
let mut fallback = effective.clone(); let mut fallback = effective.clone();
fallback.quiet = None; fallback.quiet = None;
if !fallback.is_empty() { if !fallback.is_empty() {
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() { if self
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); } .request_command_with_buzzer_fallback(
device,
key,
&fallback,
suppress_beep,
)
.await
.is_ok()
{
if let Ok(mut items) = self.quiet_unsupported.lock() {
items.insert(device.id.clone());
}
tracing::warn!(device=%device.id, "GREE Quiet command is unsupported; thermostat will continue without Quiet for this device"); tracing::warn!(device=%device.id, "GREE Quiet command is unsupported; thermostat will continue without Quiet for this device");
return Ok(fallback); return Ok(fallback);
} }
@@ -82,9 +133,22 @@ impl GreeClient {
fallback.sleep = None; fallback.sleep = None;
fallback.quiet = None; fallback.quiet = None;
if !fallback.is_empty() { if !fallback.is_empty() {
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() { if self
if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); } .request_command_with_buzzer_fallback(
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); } device,
key,
&fallback,
suppress_beep,
)
.await
.is_ok()
{
if let Ok(mut items) = self.sleep_unsupported.lock() {
items.insert(device.id.clone());
}
if let Ok(mut items) = self.quiet_unsupported.lock() {
items.insert(device.id.clone());
}
tracing::warn!(device=%device.id, "GREE Quiet/Sleep optional command fields are unsupported; using the core thermostat command"); tracing::warn!(device=%device.id, "GREE Quiet/Sleep optional command fields are unsupported; using the core thermostat command");
return Ok(fallback); return Ok(fallback);
} }
@@ -98,30 +162,70 @@ impl GreeClient {
fn command_payload(command: &DeviceCommand, suppress_beep: bool) -> Result<Value> { fn command_payload(command: &DeviceCommand, suppress_beep: bool) -> Result<Value> {
let mut opt = Vec::<&str>::new(); let mut opt = Vec::<&str>::new();
let mut values = Vec::<Value>::new(); let mut values = Vec::<Value>::new();
if let Some(v) = command.power { opt.push("Pow"); values.push(json!(if v { 1 } else { 0 })); } if let Some(v) = command.power {
if let Some(v) = &command.mode { opt.push("Mod"); values.push(json!(mode_value(v)?)); } opt.push("Pow");
values.push(json!(if v { 1 } else { 0 }));
}
if let Some(v) = &command.mode {
opt.push("Mod");
values.push(json!(mode_value(v)?));
}
if let Some(v) = command.target_temperature { if let Some(v) = command.target_temperature {
// GREE's Celsius setpoint is whole-degree. TemRec is used by the // GREE's Celsius setpoint is whole-degree. TemRec is used by the
// Fahrenheit conversion path and should not be abused as a 0.5 C bit. // Fahrenheit conversion path and should not be abused as a 0.5 C bit.
let whole = v.clamp(8.0, 30.0).round() as i64; let whole = v.clamp(8.0, 30.0).round() as i64;
opt.push("SetTem"); values.push(json!(whole)); opt.push("SetTem");
values.push(json!(whole));
}
if let Some(v) = command.fan_speed {
opt.push("WdSpd");
values.push(json!(v.min(5)));
}
if let Some(v) = command.swing_vertical {
opt.push("SwUpDn");
values.push(json!(if v { 1 } else { 0 }));
}
if let Some(v) = command.swing_horizontal {
opt.push("SwingLfRig");
values.push(json!(if v { 1 } else { 0 }));
}
if let Some(v) = command.quiet {
opt.push("Quiet");
values.push(json!(if v { 1 } else { 0 }));
}
if let Some(v) = command.turbo {
opt.push("Tur");
values.push(json!(if v { 1 } else { 0 }));
}
if let Some(v) = command.light {
opt.push("Lig");
values.push(json!(if v { 1 } else { 0 }));
}
if let Some(v) = command.air {
opt.push("Air");
values.push(json!(if v { 1 } else { 0 }));
}
if let Some(v) = command.xfan {
opt.push("Blo");
values.push(json!(if v { 1 } else { 0 }));
}
if let Some(v) = command.health {
opt.push("Health");
values.push(json!(if v { 1 } else { 0 }));
}
if let Some(v) = command.sleep {
opt.push("SwhSlp");
values.push(json!(if v { 1 } else { 0 }));
}
if opt.is_empty() {
bail!("empty device command")
} }
if let Some(v) = command.fan_speed { opt.push("WdSpd"); values.push(json!(v.min(5))); }
if let Some(v) = command.swing_vertical { opt.push("SwUpDn"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.swing_horizontal { opt.push("SwingLfRig"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.quiet { opt.push("Quiet"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.turbo { opt.push("Tur"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.light { opt.push("Lig"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.air { opt.push("Air"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.xfan { opt.push("Blo"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.health { opt.push("Health"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.sleep { opt.push("SwhSlp"); values.push(json!(if v { 1 } else { 0 })); }
if opt.is_empty() { bail!("empty device command") }
if suppress_beep { if suppress_beep {
opt.push("Buzzer_ON_OFF"); values.push(json!(1)); opt.push("Buzzer_ON_OFF");
opt.push("BuzzerCtrl"); values.push(json!(0)); values.push(json!(1));
opt.push("BuzzerCtrl");
values.push(json!(0));
} }
Ok(json!({"opt": opt, "p": values, "t": "cmd"})) Ok(json!({"opt": opt, "p": values, "t": "cmd"}))
} }
} }
+64 -22
View File
@@ -20,19 +20,29 @@ impl GreeClient {
pub fn received_frame_stats(&self) -> (u64, HashMap<String, u64>) { pub fn received_frame_stats(&self) -> (u64, HashMap<String, u64>) {
let total = self.received_frames_total.load(Ordering::Relaxed); let total = self.received_frames_total.load(Ordering::Relaxed);
let by_device = self.received_frames_by_device.lock() let by_device = self
.received_frames_by_device
.lock()
.map(|counts| counts.clone()) .map(|counts| counts.clone())
.unwrap_or_default(); .unwrap_or_default();
(total, by_device) (total, by_device)
} }
fn record_received_frame(&self, device: &Device) { fn record_received_frame(&self, device: &Device) {
let total = self.received_frames_total.fetch_add(1, Ordering::Relaxed).saturating_add(1); let total = self
let device_count = self.received_frames_by_device.lock().ok().map(|mut counts| { .received_frames_total
let count = counts.entry(device.id.clone()).or_insert(0); .fetch_add(1, Ordering::Relaxed)
*count = (*count).saturating_add(1); .saturating_add(1);
*count let device_count = self
}).unwrap_or(0); .received_frames_by_device
.lock()
.ok()
.map(|mut counts| {
let count = counts.entry(device.id.clone()).or_insert(0);
*count = (*count).saturating_add(1);
*count
})
.unwrap_or(0);
if let Some(events) = &self.debug_events { if let Some(events) = &self.debug_events {
let _ = events.send(ApiEvent { let _ = events.send(ApiEvent {
event: "gree.frame_received".into(), event: "gree.frame_received".into(),
@@ -47,12 +57,25 @@ impl GreeClient {
} }
} }
fn debug_frame(&self, direction: &str, device: &Device, target: SocketAddr, protocol: u8, payload: &Value) { fn debug_frame(
if !self.debug_gree_frames.load(Ordering::Relaxed) { return; } &self,
let Some(events) = &self.debug_events else { return; }; direction: &str,
device: &Device,
target: SocketAddr,
protocol: u8,
payload: &Value,
) {
if !self.debug_gree_frames.load(Ordering::Relaxed) {
return;
}
let Some(events) = &self.debug_events else {
return;
};
let mut safe = payload.clone(); let mut safe = payload.clone();
if let Some(object) = safe.as_object_mut() { if let Some(object) = safe.as_object_mut() {
if object.contains_key("key") { object.insert("key".into(), json!("***")); } if object.contains_key("key") {
object.insert("key".into(), json!("***"));
}
} }
let _ = events.send(ApiEvent { let _ = events.send(ApiEvent {
event: "gree.frame".into(), event: "gree.frame".into(),
@@ -68,11 +91,18 @@ impl GreeClient {
}); });
} }
async fn udp_socket(&self, broadcast: bool, target_hint: Option<Ipv4Addr>) -> Result<UdpSocket> { async fn udp_socket(
&self,
broadcast: bool,
target_hint: Option<Ipv4Addr>,
) -> Result<UdpSocket> {
let socket = if let Some(interface) = self.interface.as_deref() { let socket = if let Some(interface) = self.interface.as_deref() {
let ip = interface_ipv4(interface)?; let ip = interface_ipv4(interface)?;
UdpSocket::bind(SocketAddrV4::new(ip, 0)).await UdpSocket::bind(SocketAddrV4::new(ip, 0))
.with_context(|| format!("cannot bind GREE UDP socket to {ip} from interface {interface}"))? .await
.with_context(|| {
format!("cannot bind GREE UDP socket to {ip} from interface {interface}")
})?
} else if let Some(target) = target_hint { } else if let Some(target) = target_hint {
if let Some(config) = local_ipv4_config_for_target(target)? { if let Some(config) = local_ipv4_config_for_target(target)? {
tracing::debug!( tracing::debug!(
@@ -81,8 +111,14 @@ impl GreeClient {
local_ip = %config.ip, local_ip = %config.ip,
"Automatically selected local interface for GREE UDP" "Automatically selected local interface for GREE UDP"
); );
UdpSocket::bind(SocketAddrV4::new(config.ip, 0)).await UdpSocket::bind(SocketAddrV4::new(config.ip, 0))
.with_context(|| format!("cannot bind GREE UDP socket to {} on {}", config.ip, config.interface))? .await
.with_context(|| {
format!(
"cannot bind GREE UDP socket to {} on {}",
config.ip, config.interface
)
})?
} else { } else {
UdpSocket::bind("0.0.0.0:0").await? UdpSocket::bind("0.0.0.0:0").await?
} }
@@ -94,7 +130,9 @@ impl GreeClient {
} }
fn bind_scan_target(&self, target: SocketAddr) -> Result<SocketAddr> { fn bind_scan_target(&self, target: SocketAddr) -> Result<SocketAddr> {
let SocketAddr::V4(target_v4) = target else { return Ok(target); }; let SocketAddr::V4(target_v4) = target else {
return Ok(target);
};
let broadcast = if let Some(interface) = self.interface.as_deref() { let broadcast = if let Some(interface) = self.interface.as_deref() {
let (_, broadcast) = interface_ipv4_config(interface)?; let (_, broadcast) = interface_ipv4_config(interface)?;
Some(broadcast) Some(broadcast)
@@ -109,16 +147,20 @@ impl GreeClient {
fn discovery_target(&self, configured: &str) -> Result<SocketAddr> { fn discovery_target(&self, configured: &str) -> Result<SocketAddr> {
let value = configured.trim(); let value = configured.trim();
if value.eq_ignore_ascii_case("auto") || value.to_ascii_lowercase().starts_with("auto:") { if value.eq_ignore_ascii_case("auto") || value.to_ascii_lowercase().starts_with("auto:") {
let port = value.split_once(':') let port = value
.map(|(_, port)| port.parse::<u16>().context("invalid automatic discovery port")) .split_once(':')
.map(|(_, port)| {
port.parse::<u16>()
.context("invalid automatic discovery port")
})
.transpose()? .transpose()?
.unwrap_or(7000); .unwrap_or(7000);
let interface = self.interface.as_deref() let interface = self.interface.as_deref().ok_or_else(|| {
.ok_or_else(|| anyhow!("automatic discovery broadcast requires GREE_CONTROLLER_GREE_INTERFACE"))?; anyhow!("automatic discovery broadcast requires GREE_CONTROLLER_GREE_INTERFACE")
})?;
let (_, broadcast) = interface_ipv4_config(interface)?; let (_, broadcast) = interface_ipv4_config(interface)?;
return Ok(SocketAddr::V4(SocketAddrV4::new(broadcast, port))); return Ok(SocketAddr::V4(SocketAddrV4::new(broadcast, port)));
} }
value.parse().context("invalid discovery broadcast address") value.parse().context("invalid discovery broadcast address")
} }
} }
+90 -24
View File
@@ -1,8 +1,17 @@
impl GreeClient { impl GreeClient {
/// protocol_filter: 0=auto/both, 1=ECB only, 2=GCM only. /// protocol_filter: 0=auto/both, 1=ECB only, 2=GCM only.
pub async fn discover(&self, broadcast: &str, duration: Duration, protocol_filter: u8, passes: u8) -> Result<Vec<Device>> { pub async fn discover(
&self,
broadcast: &str,
duration: Duration,
protocol_filter: u8,
passes: u8,
) -> Result<Vec<Device>> {
let target = self.discovery_target(broadcast)?; let target = self.discovery_target(broadcast)?;
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None }; let target_hint = match target {
SocketAddr::V4(addr) => Some(*addr.ip()),
SocketAddr::V6(_) => None,
};
let socket = self.udp_socket(true, target_hint).await?; let socket = self.udp_socket(true, target_hint).await?;
let local = socket.local_addr()?; let local = socket.local_addr()?;
let passes = passes.clamp(1, 10); let passes = passes.clamp(1, 10);
@@ -17,7 +26,11 @@ impl GreeClient {
); );
let deadline = Instant::now() + duration; let deadline = Instant::now() + duration;
let interval = if passes > 1 { duration / passes as u32 } else { duration }; let interval = if passes > 1 {
duration / passes as u32
} else {
duration
};
let mut next_scan = Instant::now(); let mut next_scan = Instant::now();
let mut sent = 0_u8; let mut sent = 0_u8;
let mut result = Vec::new(); let mut result = Vec::new();
@@ -36,10 +49,14 @@ impl GreeClient {
let wait = remaining.min(Duration::from_millis(250)); let wait = remaining.min(Duration::from_millis(250));
match timeout(wait, socket.recv_from(&mut buffer)).await { match timeout(wait, socket.recv_from(&mut buffer)).await {
Ok(Ok((size, source))) => { Ok(Ok((size, source))) => {
let Ok(value) = serde_json::from_slice::<Value>(&buffer[..size]) else { continue; }; let Ok(value) = serde_json::from_slice::<Value>(&buffer[..size]) else {
continue;
};
match self.parse_discovery(value, source) { match self.parse_discovery(value, source) {
Ok(Some(mut device)) => { Ok(Some(mut device)) => {
if protocol_filter != 0 && device.protocol_version != protocol_filter { continue; } if protocol_filter != 0 && device.protocol_version != protocol_filter {
continue;
}
let key = device.mac.to_ascii_lowercase(); let key = device.mac.to_ascii_lowercase();
if seen.insert(key) { if seen.insert(key) {
device.last_seen = Some(Utc::now()); device.last_seen = Some(Utc::now());
@@ -48,7 +65,9 @@ impl GreeClient {
} }
} }
Ok(None) => {} Ok(None) => {}
Err(err) => tracing::debug!(source=%source, error=?err, "Ignoring undecodable discovery response"), Err(err) => {
tracing::debug!(source=%source, error=?err, "Ignoring undecodable discovery response")
}
} }
} }
Ok(Err(err)) => return Err(err.into()), Ok(Err(err)) => return Err(err.into()),
@@ -69,46 +88,90 @@ impl GreeClient {
} else { } else {
decrypt_v1(GENERIC_GREE_V1_KEY, pack)? decrypt_v1(GENERIC_GREE_V1_KEY, pack)?
}; };
value = serde_json::from_slice::<Value>(&clear).context("invalid decrypted discovery JSON")?; value = serde_json::from_slice::<Value>(&clear)
.context("invalid decrypted discovery JSON")?;
} else if pack_value.is_object() { } else if pack_value.is_object() {
value = pack_value.clone(); value = pack_value.clone();
} }
} }
} }
let kind = value.get("t").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase(); let kind = value
if kind != "dev" && kind != "scan" && value.get("mac").is_none() && value.get("cid").is_none() { .get("t")
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase();
if kind != "dev"
&& kind != "scan"
&& value.get("mac").is_none()
&& value.get("cid").is_none()
{
return Ok(None); return Ok(None);
} }
let mac = value.get("mac") let mac = value
.get("mac")
.or_else(|| value.get("cid")) .or_else(|| value.get("cid"))
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or_default() .unwrap_or_default()
.replace([':', '-'], "").to_ascii_uppercase(); .replace([':', '-'], "")
if mac.is_empty() { return Ok(None); } .to_ascii_uppercase();
if mac.is_empty() {
return Ok(None);
}
let raw_model = value.get("model").or_else(|| value.get("series")) let raw_model = value
.and_then(Value::as_str).unwrap_or_default().trim().to_string(); .get("model")
let model_type = value.get("ModelType") .or_else(|| value.get("series"))
.and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_i64().map(|n| n.to_string()))) .and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string();
let model_type = value
.get("ModelType")
.and_then(|v| {
v.as_str()
.map(str::to_string)
.or_else(|| v.as_i64().map(|n| n.to_string()))
})
.unwrap_or_default(); .unwrap_or_default();
let model = if !model_type.is_empty() && (raw_model.is_empty() || raw_model.eq_ignore_ascii_case("gree")) { let model = if !model_type.is_empty()
&& (raw_model.is_empty() || raw_model.eq_ignore_ascii_case("gree"))
{
format!("GREE {model_type}") format!("GREE {model_type}")
} else if raw_model.is_empty() { } else if raw_model.is_empty() {
"GREE".to_string() "GREE".to_string()
} else { } else {
raw_model raw_model
}; };
let ver = value.get("ver").and_then(Value::as_str).unwrap_or_default().trim(); let ver = value
let hid = value.get("hid").and_then(Value::as_str).unwrap_or_default().trim(); .get("ver")
.and_then(Value::as_str)
.unwrap_or_default()
.trim();
let hid = value
.get("hid")
.and_then(Value::as_str)
.unwrap_or_default()
.trim();
let firmware = match (ver.is_empty(), hid.is_empty()) { let firmware = match (ver.is_empty(), hid.is_empty()) {
(false, false) => format!("{ver} · {hid}"), (false, false) => format!("{ver} · {hid}"),
(false, true) => ver.to_string(), (false, true) => ver.to_string(),
(true, false) => hid.to_string(), (true, false) => hid.to_string(),
(true, true) => String::new(), (true, true) => String::new(),
}; };
let suffix = mac.chars().rev().take(4).collect::<String>().chars().rev().collect::<String>().to_ascii_uppercase(); let suffix = mac
let name = value.get("name").and_then(Value::as_str) .chars()
.map(str::trim).filter(|v| !v.is_empty()) .rev()
.take(4)
.collect::<String>()
.chars()
.rev()
.collect::<String>()
.to_ascii_uppercase();
let name = value
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string) .map(str::to_string)
.unwrap_or_else(|| format!("{model} {suffix}")); .unwrap_or_else(|| format!("{model} {suffix}"));
let now = Utc::now(); let now = Utc::now();
@@ -117,7 +180,11 @@ impl GreeClient {
mac, mac,
name, name,
ip: source.ip().to_string(), ip: source.ip().to_string(),
port: if source.port() == 0 { 7000 } else { source.port() }, port: if source.port() == 0 {
7000
} else {
source.port()
},
protocol_version: detected_protocol, protocol_version: detected_protocol,
model, model,
firmware, firmware,
@@ -157,5 +224,4 @@ impl GreeClient {
updated_at: now, updated_at: now,
})) }))
} }
} }
+15 -4
View File
@@ -2,9 +2,18 @@ pub fn merge_discovered(existing: Option<Device>, discovered: Device) -> Device
if let Some(mut old) = existing { if let Some(mut old) = existing {
old.ip = discovered.ip; old.ip = discovered.ip;
old.port = discovered.port; old.port = discovered.port;
if old.name.trim().is_empty() || old.name == "Klimatyzator GREE" || old.name == "GREE air conditioner" { old.name = discovered.name; } if old.name.trim().is_empty()
if !discovered.model.is_empty() { old.model = discovered.model; } || old.name == "Klimatyzator GREE"
if !discovered.firmware.is_empty() { old.firmware = discovered.firmware; } || old.name == "GREE air conditioner"
{
old.name = discovered.name;
}
if !discovered.model.is_empty() {
old.model = discovered.model;
}
if !discovered.firmware.is_empty() {
old.firmware = discovered.firmware;
}
if old.protocol_version != discovered.protocol_version { if old.protocol_version != discovered.protocol_version {
old.protocol_version = discovered.protocol_version; old.protocol_version = discovered.protocol_version;
old.key = None; old.key = None;
@@ -17,7 +26,9 @@ pub fn merge_discovered(existing: Option<Device>, discovered: Device) -> Device
old old
} else { } else {
let mut new = discovered; let mut new = discovered;
if new.id.is_empty() { new.id = Uuid::new_v4().to_string(); } if new.id.is_empty() {
new.id = Uuid::new_v4().to_string();
}
new new
} }
} }
+120 -30
View File
@@ -2,8 +2,13 @@ impl GreeClient {
/// Measure a minimal GREE round-trip without mutating persisted/live device state. /// Measure a minimal GREE round-trip without mutating persisted/live device state.
/// Diagnostics must not alter online/error counters, ownership, readings or capabilities. /// Diagnostics must not alter online/error counters, ownership, readings or capabilities.
pub async fn probe(&self, device: &Device) -> Result<u64> { pub async fn probe(&self, device: &Device) -> Result<u64> {
if device.simulated { return Ok(0); } if device.simulated {
let key = device.key.as_deref().filter(|value| !value.is_empty()) return Ok(0);
}
let key = device
.key
.as_deref()
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow!("device is not bound"))?; .ok_or_else(|| anyhow!("device is not bound"))?;
let started = Instant::now(); let started = Instant::now();
let response = self.status_request(device, key, &["Pow"]).await?; let response = self.status_request(device, key, &["Pow"]).await?;
@@ -13,14 +18,52 @@ impl GreeClient {
} }
pub async fn poll(&self, device: &mut Device) -> Result<()> { pub async fn poll(&self, device: &mut Device) -> Result<()> {
let key = device.key.clone().ok_or_else(|| anyhow!("device is not bound"))?; let key = device
.key
.clone()
.ok_or_else(|| anyhow!("device is not bound"))?;
let full_cols = [ let full_cols = [
"Pow","Mod","SetTem","WdSpd","Air","Blo","Health","SwhSlp","Lig", "Pow",
"SwingLfRig","SwUpDn","Quiet","Tur","StHt","TemUn","HeatCoolType", "Mod",
"TemRec","SvSt","TemSen","CoolSvTem","HeatSvTem","OutEnvTem" "SetTem",
"WdSpd",
"Air",
"Blo",
"Health",
"SwhSlp",
"Lig",
"SwingLfRig",
"SwUpDn",
"Quiet",
"Tur",
"StHt",
"TemUn",
"HeatCoolType",
"TemRec",
"SvSt",
"TemSen",
"CoolSvTem",
"HeatSvTem",
"OutEnvTem",
]; ];
let core_cols = ["Pow","Mod","SetTem","TemRec","TemUn","TemSen","WdSpd","Lig","SwingLfRig","SwUpDn","Quiet","Tur"]; let core_cols = [
let (response, used_core_fallback) = match self.status_request(device, &key, &full_cols).await { "Pow",
"Mod",
"SetTem",
"TemRec",
"TemUn",
"TemSen",
"WdSpd",
"Lig",
"SwingLfRig",
"SwUpDn",
"Quiet",
"Tur",
];
let (response, used_core_fallback) = match self
.status_request(device, &key, &full_cols)
.await
{
Ok(value) => (value, false), Ok(value) => (value, false),
Err(first) => { Err(first) => {
tracing::debug!(device=%device.id, error=?first, "Full GREE status request failed; retrying core properties"); tracing::debug!(device=%device.id, error=?first, "Full GREE status request failed; retrying core properties");
@@ -33,8 +76,12 @@ impl GreeClient {
// their outdoor sensor to history without making the main poll fail. // their outdoor sensor to history without making the main poll fail.
if used_core_fallback { if used_core_fallback {
match self.status_request(device, &key, &["OutEnvTem"]).await { match self.status_request(device, &key, &["OutEnvTem"]).await {
Ok(optional) => { let _ = self.apply_status(device, &optional); } Ok(optional) => {
Err(err) => tracing::trace!(device=%device.id, error=?err, "GREE outdoor temperature is not available"), let _ = self.apply_status(device, &optional);
}
Err(err) => {
tracing::trace!(device=%device.id, error=?err, "GREE outdoor temperature is not available")
}
} }
} }
// Capability discovery is deliberately lazy. Existing installations start with // Capability discovery is deliberately lazy. Existing installations start with
@@ -51,7 +98,8 @@ impl GreeClient {
async fn status_request(&self, device: &Device, key: &str, cols: &[&str]) -> Result<Value> { async fn status_request(&self, device: &Device, key: &str, cols: &[&str]) -> Result<Value> {
let inner = json!({"cols": cols, "mac": Self::wire_mac(device), "t": "status"}); let inner = json!({"cols": cols, "mac": Self::wire_mac(device), "t": "status"});
self.request(device, &inner, key, false, device.protocol_version).await self.request(device, &inner, key, false, device.protocol_version)
.await
} }
async fn probe_optional_features(&self, device: &mut Device, key: &str) { async fn probe_optional_features(&self, device: &mut Device, key: &str) {
@@ -65,10 +113,14 @@ impl GreeClient {
("SwhSlp", device.supports_sleep.is_none()), ("SwhSlp", device.supports_sleep.is_none()),
]; ];
for (property, needed) in probes { for (property, needed) in probes {
if !needed { continue; } if !needed {
continue;
}
match self.status_request(device, key, &[property]).await { match self.status_request(device, key, &[property]).await {
Ok(value) => { Ok(value) => {
let returned = value.get("cols").and_then(Value::as_array) let returned = value
.get("cols")
.and_then(Value::as_array)
.map(|cols| cols.iter().any(|name| name.as_str() == Some(property))) .map(|cols| cols.iter().any(|name| name.as_str() == Some(property)))
.unwrap_or(false); .unwrap_or(false);
if !returned || self.apply_status(device, &value).is_err() { if !returned || self.apply_status(device, &value).is_err() {
@@ -98,9 +150,13 @@ impl GreeClient {
} }
fn apply_status(&self, device: &mut Device, response: &Value) -> Result<()> { fn apply_status(&self, device: &mut Device, response: &Value) -> Result<()> {
let response_cols = response.get("cols").and_then(Value::as_array) let response_cols = response
.get("cols")
.and_then(Value::as_array)
.ok_or_else(|| anyhow!("status response has no cols"))?; .ok_or_else(|| anyhow!("status response has no cols"))?;
let data = response.get("dat").and_then(Value::as_array) let data = response
.get("dat")
.and_then(Value::as_array)
.ok_or_else(|| anyhow!("status response has no dat"))?; .ok_or_else(|| anyhow!("status response has no dat"))?;
if data.len() < response_cols.len() { if data.len() < response_cols.len() {
bail!("status response contains fewer values than columns") bail!("status response contains fewer values than columns")
@@ -112,38 +168,69 @@ impl GreeClient {
let mut next = device.clone(); let mut next = device.clone();
let mut set_temp = None; let mut set_temp = None;
for (name, value) in response_cols.iter().zip(data.iter()) { for (name, value) in response_cols.iter().zip(data.iter()) {
let Some(name) = name.as_str() else { continue; }; let Some(name) = name.as_str() else {
continue;
};
match name { match name {
"Pow" => next.power = status_flag(name, value)?, "Pow" => next.power = status_flag(name, value)?,
"Mod" => { "Mod" => {
let raw = status_i64(name, value)?; let raw = status_i64(name, value)?;
next.mode = mode_name_checked(raw).ok_or_else(|| anyhow!("invalid GREE mode value for {name}: {raw}"))?.into(); next.mode = mode_name_checked(raw)
.ok_or_else(|| anyhow!("invalid GREE mode value for {name}: {raw}"))?
.into();
} }
"SetTem" => { "SetTem" => {
let raw = status_f64(name, value)?; let raw = status_f64(name, value)?;
if !(8.0..=30.0).contains(&raw) { bail!("invalid GREE setpoint for {name}: {raw}") } if !(8.0..=30.0).contains(&raw) {
bail!("invalid GREE setpoint for {name}: {raw}")
}
set_temp = Some(raw.round()); set_temp = Some(raw.round());
} }
"WdSpd" => { "WdSpd" => {
let raw = status_i64(name, value)?; let raw = status_i64(name, value)?;
if !(0..=5).contains(&raw) { bail!("invalid GREE fan value for {name}: {raw}") } if !(0..=5).contains(&raw) {
bail!("invalid GREE fan value for {name}: {raw}")
}
next.fan_speed = raw as u8; next.fan_speed = raw as u8;
} }
"SwUpDn" => next.swing_vertical = status_i64(name, value)? != 0, "SwUpDn" => next.swing_vertical = status_i64(name, value)? != 0,
"SwingLfRig" => next.swing_horizontal = status_i64(name, value)? != 0, "SwingLfRig" => next.swing_horizontal = status_i64(name, value)? != 0,
"Quiet" => { next.quiet = status_flag(name, value)?; next.supports_quiet = Some(true); }, "Quiet" => {
"Tur" => { next.turbo = status_flag(name, value)?; next.supports_turbo = Some(true); }, next.quiet = status_flag(name, value)?;
"Lig" => { next.light = status_flag(name, value)?; next.supports_light = Some(true); }, next.supports_quiet = Some(true);
"Air" => { next.air = status_flag(name, value)?; next.supports_air = Some(true); }, }
"Blo" => { next.xfan = status_flag(name, value)?; next.supports_xfan = Some(true); }, "Tur" => {
"Health" => { next.health = status_flag(name, value)?; next.supports_health = Some(true); }, next.turbo = status_flag(name, value)?;
"SwhSlp" => { next.sleep = status_flag(name, value)?; next.supports_sleep = Some(true); }, next.supports_turbo = Some(true);
}
"Lig" => {
next.light = status_flag(name, value)?;
next.supports_light = Some(true);
}
"Air" => {
next.air = status_flag(name, value)?;
next.supports_air = Some(true);
}
"Blo" => {
next.xfan = status_flag(name, value)?;
next.supports_xfan = Some(true);
}
"Health" => {
next.health = status_flag(name, value)?;
next.supports_health = Some(true);
}
"SwhSlp" => {
next.sleep = status_flag(name, value)?;
next.supports_sleep = Some(true);
}
"TemSen" => { "TemSen" => {
let raw = status_f64(name, value)?; let raw = status_f64(name, value)?;
if raw != 0.0 { if raw != 0.0 {
let offset = raw > 40.0; let offset = raw > 40.0;
let temperature = if offset { raw - 40.0 } else { raw }; let temperature = if offset { raw - 40.0 } else { raw };
if !(-40.0..=80.0).contains(&temperature) { bail!("invalid GREE indoor temperature: {temperature}") } if !(-40.0..=80.0).contains(&temperature) {
bail!("invalid GREE indoor temperature: {temperature}")
}
next.temperature_sensor_offset = Some(offset); next.temperature_sensor_offset = Some(offset);
next.current_temperature = Some(temperature); next.current_temperature = Some(temperature);
} }
@@ -153,16 +240,19 @@ impl GreeClient {
if raw != 0.0 { if raw != 0.0 {
let offset = next.temperature_sensor_offset.unwrap_or(raw > 50.0); let offset = next.temperature_sensor_offset.unwrap_or(raw > 50.0);
let temperature = if offset { raw - 40.0 } else { raw }; let temperature = if offset { raw - 40.0 } else { raw };
if !(-60.0..=80.0).contains(&temperature) { bail!("invalid GREE outdoor temperature: {temperature}") } if !(-60.0..=80.0).contains(&temperature) {
bail!("invalid GREE outdoor temperature: {temperature}")
}
next.outdoor_temperature = Some(temperature); next.outdoor_temperature = Some(temperature);
} }
} }
_ => {} _ => {}
} }
} }
if let Some(base) = set_temp { next.target_temperature = base; } if let Some(base) = set_temp {
next.target_temperature = base;
}
*device = next; *device = next;
Ok(()) Ok(())
} }
} }
+19 -9
View File
@@ -28,15 +28,25 @@ mod tests {
#[test] #[test]
fn thermostat_standby_setpoint_low_fan_quiet_and_sleep_share_one_frame() { fn thermostat_standby_setpoint_low_fan_quiet_and_sleep_share_one_frame() {
let payload = GreeClient::command_payload(&DeviceCommand { let payload = GreeClient::command_payload(
target_temperature: Some(19.0), &DeviceCommand {
fan_speed: Some(1), target_temperature: Some(19.0),
quiet: Some(true), fan_speed: Some(1),
sleep: Some(true), quiet: Some(true),
..DeviceCommand::default() sleep: Some(true),
}, false).expect("thermostat command payload"); ..DeviceCommand::default()
},
false,
)
.expect("thermostat command payload");
assert_eq!(payload.get("opt").cloned(), Some(serde_json::json!(["SetTem", "WdSpd", "Quiet", "SwhSlp"]))); assert_eq!(
assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([19, 1, 1, 1]))); payload.get("opt").cloned(),
Some(serde_json::json!(["SetTem", "WdSpd", "Quiet", "SwhSlp"]))
);
assert_eq!(
payload.get("p").cloned(),
Some(serde_json::json!([19, 1, 1, 1]))
);
} }
} }
+62 -16
View File
@@ -1,12 +1,31 @@
impl GreeClient { impl GreeClient {
async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8) -> Result<Value> { async fn request(
&self,
device: &Device,
inner: &Value,
key: &str,
binding: bool,
protocol_version: u8,
) -> Result<Value> {
let target = self.device_target(device)?; let target = self.device_target(device)?;
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None }; let target_hint = match target {
SocketAddr::V4(addr) => Some(*addr.ip()),
SocketAddr::V6(_) => None,
};
let socket = self.udp_socket(false, target_hint).await?; let socket = self.udp_socket(false, target_hint).await?;
self.request_on_socket(device, inner, key, binding, protocol_version, &socket).await self.request_on_socket(device, inner, key, binding, protocol_version, &socket)
.await
} }
async fn request_on_socket(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8, socket: &UdpSocket) -> Result<Value> { async fn request_on_socket(
&self,
device: &Device,
inner: &Value,
key: &str,
binding: bool,
protocol_version: u8,
socket: &UdpSocket,
) -> Result<Value> {
let target = self.device_target(device)?; let target = self.device_target(device)?;
let version = if protocol_version == 2 { 2 } else { 1 }; let version = if protocol_version == 2 { 2 } else { 1 };
let inner_bytes = serde_json::to_vec(inner)?; let inner_bytes = serde_json::to_vec(inner)?;
@@ -41,26 +60,36 @@ impl GreeClient {
Ok(Err(err)) => return Err(err.into()), Ok(Err(err)) => return Err(err.into()),
Err(_) => break, Err(_) => break,
}; };
if source.ip() != target.ip() { continue; } if source.ip() != target.ip() {
continue;
}
self.record_received_frame(device); self.record_received_frame(device);
let response: Value = match serde_json::from_slice(&buffer[..size]) { let response: Value = match serde_json::from_slice(&buffer[..size]) {
Ok(value) => value, Ok(value) => value,
Err(err) => { last_decode_error = Some(anyhow!("invalid GREE JSON response: {err}")); continue; } Err(err) => {
last_decode_error = Some(anyhow!("invalid GREE JSON response: {err}"));
continue;
}
}; };
if let Some(pack) = response.get("pack").and_then(Value::as_object) { if let Some(pack) = response.get("pack").and_then(Value::as_object) {
let decoded = Value::Object(pack.clone()); let decoded = Value::Object(pack.clone());
if binding { if binding {
let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default(); let response_type =
decoded.get("t").and_then(Value::as_str).unwrap_or_default();
if !response_type.eq_ignore_ascii_case("bindok") { if !response_type.eq_ignore_ascii_case("bindok") {
tracing::debug!(source=%source, response_type=%response_type, "Ignoring non-bind packet while waiting for GREE bind response"); tracing::debug!(source=%source, response_type=%response_type, "Ignoring non-bind packet while waiting for GREE bind response");
continue; continue;
} }
} }
if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") } if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) {
bail!("GREE device error: {err}")
}
self.debug_frame("rx", device, target, version, &decoded); self.debug_frame("rx", device, target, version, &decoded);
return Ok(decoded); return Ok(decoded);
} }
let Some(pack) = response.get("pack").and_then(Value::as_str) else { continue; }; let Some(pack) = response.get("pack").and_then(Value::as_str) else {
continue;
};
let clear = if version == 2 { let clear = if version == 2 {
let Some(tag) = response.get("tag").and_then(Value::as_str) else { let Some(tag) = response.get("tag").and_then(Value::as_str) else {
last_decode_error = Some(anyhow!("AES-GCM response is missing tag")); last_decode_error = Some(anyhow!("AES-GCM response is missing tag"));
@@ -68,31 +97,48 @@ impl GreeClient {
}; };
match decrypt_v2(key, pack, tag) { match decrypt_v2(key, pack, tag) {
Ok(v) => v, Ok(v) => v,
Err(err) => { last_decode_error = Some(err); continue; } Err(err) => {
last_decode_error = Some(err);
continue;
}
} }
} else { } else {
match decrypt_v1(key, pack) { match decrypt_v1(key, pack) {
Ok(v) => v, Ok(v) => v,
Err(err) => { last_decode_error = Some(err); continue; } Err(err) => {
last_decode_error = Some(err);
continue;
}
} }
}; };
let decoded: Value = match serde_json::from_slice(&clear) { let decoded: Value = match serde_json::from_slice(&clear) {
Ok(value) => value, Ok(value) => value,
Err(err) => { last_decode_error = Some(anyhow!("invalid decrypted GREE response: {err}")); continue; } Err(err) => {
last_decode_error = Some(anyhow!("invalid decrypted GREE response: {err}"));
continue;
}
}; };
if binding { if binding {
let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default(); let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default();
if !response_type.eq_ignore_ascii_case("bindok") { continue; } if !response_type.eq_ignore_ascii_case("bindok") {
continue;
}
}
if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) {
bail!("GREE device error: {err}")
} }
if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") }
self.debug_frame("rx", device, target, version, &decoded); self.debug_frame("rx", device, target, version, &decoded);
return Ok(decoded); return Ok(decoded);
} }
if let Some(err) = last_decode_error { return Err(err); } if let Some(err) = last_decode_error {
return Err(err);
}
bail!("GREE response timeout after 4 seconds") bail!("GREE response timeout after 4 seconds")
} }
fn device_target(&self, device: &Device) -> Result<SocketAddr> { fn device_target(&self, device: &Device) -> Result<SocketAddr> {
format!("{}:{}", device.ip, device.port).parse().context("invalid device address") format!("{}:{}", device.ip, device.port)
.parse()
.context("invalid device address")
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
pub mod crypto; pub mod crypto;
pub mod gree; pub mod gree;
pub use gree::{GreeClient, merge_discovered}; pub use gree::{merge_discovered, GreeClient};
+35 -12
View File
@@ -1,8 +1,17 @@
use std::{collections::HashMap, sync::{Arc, atomic::AtomicBool}, time::Instant}; use crate::{
config::Config,
db::Db,
models::{ApiEvent, DeviceCommand, RuntimeSettings},
protocol::GreeClient,
};
use chrono::Utc; use chrono::Utc;
use serde_json::Value; use serde_json::Value;
use std::{
collections::HashMap,
sync::{atomic::AtomicBool, Arc},
time::Instant,
};
use tokio::sync::{broadcast, Mutex, Notify, OwnedMutexGuard, RwLock}; use tokio::sync::{broadcast, Mutex, Notify, OwnedMutexGuard, RwLock};
use crate::{config::Config, db::Db, models::{ApiEvent, DeviceCommand, RuntimeSettings}, protocol::GreeClient};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct PendingControllerCommand { pub(crate) struct PendingControllerCommand {
@@ -49,7 +58,10 @@ impl AppState {
pub async fn lock_device_operation(&self, device_id: &str) -> OwnedMutexGuard<()> { pub async fn lock_device_operation(&self, device_id: &str) -> OwnedMutexGuard<()> {
let lock = { let lock = {
let mut locks = self.device_operation_locks.lock().await; let mut locks = self.device_operation_locks.lock().await;
locks.entry(device_id.to_string()).or_insert_with(|| Arc::new(Mutex::new(()))).clone() locks
.entry(device_id.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}; };
lock.lock_owned().await lock.lock_owned().await
} }
@@ -57,7 +69,10 @@ impl AppState {
pub async fn lock_zone_operation(&self, zone_id: &str) -> OwnedMutexGuard<()> { pub async fn lock_zone_operation(&self, zone_id: &str) -> OwnedMutexGuard<()> {
let lock = { let lock = {
let mut locks = self.zone_operation_locks.lock().await; let mut locks = self.zone_operation_locks.lock().await;
locks.entry(zone_id.to_string()).or_insert_with(|| Arc::new(Mutex::new(()))).clone() locks
.entry(zone_id.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}; };
lock.lock_owned().await lock.lock_owned().await
} }
@@ -65,7 +80,10 @@ impl AppState {
pub async fn lock_group_operation(&self, group_id: &str) -> OwnedMutexGuard<()> { pub async fn lock_group_operation(&self, group_id: &str) -> OwnedMutexGuard<()> {
let lock = { let lock = {
let mut locks = self.group_operation_locks.lock().await; let mut locks = self.group_operation_locks.lock().await;
locks.entry(group_id.to_string()).or_insert_with(|| Arc::new(Mutex::new(()))).clone() locks
.entry(group_id.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}; };
lock.lock_owned().await lock.lock_owned().await
} }
@@ -106,18 +124,23 @@ impl AppState {
if let Err(err) = self.db.log_event(level, kind, message, &metadata) { if let Err(err) = self.db.log_event(level, kind, message, &metadata) {
tracing::warn!(error=?err, "cannot persist event log"); tracing::warn!(error=?err, "cannot persist event log");
} }
self.broadcast("log.created", serde_json::json!({ self.broadcast(
"level": level, "log.created",
"kind": kind, serde_json::json!({
"message": message, "level": level,
"metadata": metadata, "kind": kind,
})); "message": message,
"metadata": metadata,
}),
);
if let Ok(handle) = tokio::runtime::Handle::try_current() { if let Ok(handle) = tokio::runtime::Handle::try_current() {
let state = self.clone(); let state = self.clone();
let level = level.to_string(); let level = level.to_string();
let kind = kind.to_string(); let kind = kind.to_string();
let message = message.to_string(); let message = message.to_string();
handle.spawn(async move { crate::notifications::dispatch(state, level, kind, message, metadata).await; }); handle.spawn(async move {
crate::notifications::dispatch(state, level, kind, message, metadata).await;
});
} }
} }
} }
+295 -90
View File
@@ -130,7 +130,8 @@
<div class="dashboard-section-head compact-head"> <div class="dashboard-section-head compact-head">
<div><span class="eyebrow" data-i18n="nav.devices">Devices</span> <div><span class="eyebrow" data-i18n="nav.devices">Devices</span>
<h2 data-i18n="dashboard.quickControl">Manual control</h2> <h2 data-i18n="dashboard.quickControl">Manual control</h2>
<p data-i18n="dashboard.manualControlHint">Direct GREE control: power, temperature, mode, fan and supported unit functions.</p> <p data-i18n="dashboard.manualControlHint">Direct GREE control: power, temperature, mode, fan and
supported unit functions.</p>
</div> </div>
<span class="badge" id="dashboardDeviceCount">0</span> <span class="badge" id="dashboardDeviceCount">0</span>
</div> </div>
@@ -148,7 +149,8 @@
data-i18n="actions.discover">Discover</button><button class="secondary" data-open="deviceDialog" data-i18n="actions.discover">Discover</button><button class="secondary" data-open="deviceDialog"
data-i18n="devices.addManual">Add manually</button></div> data-i18n="devices.addManual">Add manually</button></div>
</div> </div>
<p class="lead" data-i18n="devices.technicalDescription">Technical configuration and diagnostics only. Current operating settings are available in Manual control.</p> <p class="lead" data-i18n="devices.technicalDescription">Technical configuration and diagnostics only. Current
operating settings are available in Manual control.</p>
<div class="device-grid" id="deviceList"></div> <div class="device-grid" id="deviceList"></div>
</section> </section>
@@ -182,7 +184,10 @@
<div class="section-heading"> <div class="section-heading">
<div><span class="eyebrow" data-i18n="flow.eyebrow">Visual logic</span> <div><span class="eyebrow" data-i18n="flow.eyebrow">Visual logic</span>
<h1 data-i18n="nav.flows">Flow</h1> <h1 data-i18n="nav.flows">Flow</h1>
</div><div class="section-actions flow-page-actions"><button class="secondary" type="button" data-action="import-flow" data-i18n="flow.import">Import Flow</button><button class="primary" type="button" data-action="new-flow" data-i18n="flow.new">New Flow</button></div> </div>
<div class="section-actions flow-page-actions"><button class="secondary" type="button" data-action="import-flow"
data-i18n="flow.import">Import Flow</button><button class="primary" type="button" data-action="new-flow"
data-i18n="flow.new">New Flow</button></div>
</div> </div>
<nav class="automation-tabs" data-i18n-aria="automationHub.navigation" aria-label="Automation navigation"> <nav class="automation-tabs" data-i18n-aria="automationHub.navigation" aria-label="Automation navigation">
<button type="button" class="active" data-go="flows" data-i18n="nav.flows">Flow</button> <button type="button" class="active" data-go="flows" data-i18n="nav.flows">Flow</button>
@@ -191,8 +196,11 @@
</nav> </nav>
<div class="panel flow-info-panel"> <div class="panel flow-info-panel">
<div><strong data-i18n="flow.infoTitle">One place for schedules and automations</strong> <div><strong data-i18n="flow.infoTitle">One place for schedules and automations</strong>
<p data-i18n="flow.infoText">Build the logic from blocks. GREE Controller translates Flow into generated schedules and automations. Generated entries are read-only outside this editor.</p> <p data-i18n="flow.infoText">Build the logic from blocks. GREE Controller translates Flow into generated
<button type="button" class="secondary flow-shared-settings-link" data-action="flow-shared-input-settings" data-i18n="flow.sharedInputsSettingsLink">Wspólne wejścia Flow w Home Assistant →</button></div> schedules and automations. Generated entries are read-only outside this editor.</p>
<button type="button" class="secondary flow-shared-settings-link" data-action="flow-shared-input-settings"
data-i18n="flow.sharedInputsSettingsLink">Wspólne wejścia Flow w Home Assistant →</button>
</div>
<span class="badge" data-i18n="flow.sourceOfTruth">Flow = source of truth</span> <span class="badge" data-i18n="flow.sourceOfTruth">Flow = source of truth</span>
</div> </div>
<section class="flow-list-section"> <section class="flow-list-section">
@@ -390,46 +398,99 @@
tokens for the Home Assistant integration.</p> tokens for the Home Assistant integration.</p>
<form class="settings-form standalone-settings-form" id="homeAssistantForm"> <form class="settings-form standalone-settings-form" id="homeAssistantForm">
<section class="panel settings-block"> <section class="panel settings-block">
<div class="settings-block-head"><div><h3 data-i18n="settings.haConnection">Połączenie z Home Assistant</h3><p data-i18n="settings.haConnectionHint">Adres serwera i dane dostępu używane przez wszystkie funkcje Home Assistant.</p></div></div> <div class="settings-block-head">
<div>
<h3 data-i18n="settings.haConnection">Połączenie z Home Assistant</h3>
<p data-i18n="settings.haConnectionHint">Adres serwera i dane dostępu używane przez wszystkie funkcje Home
Assistant.</p>
</div>
</div>
<div class="settings-grid"> <div class="settings-grid">
<label class="wide"><span>URL</span><input type="url" name="ha_url" placeholder="http://homeassistant.local:8123"></label> <label class="wide"><span>URL</span><input type="url" name="ha_url"
<label class="wide"><span data-i18n="settings.haLongLivedToken">Long-Lived Access Token</span><input type="password" name="ha_token" autocomplete="new-password" data-i18n-placeholder="settings.haTokenKeep" placeholder="Leave empty to keep the saved token"></label> placeholder="http://homeassistant.local:8123"></label>
<label class="check wide"><input type="checkbox" name="ha_allow_invalid_tls"> <span data-i18n="settings.allowInvalidTls">Allow invalid/self-signed HTTPS certificate</span></label> <label class="wide"><span data-i18n="settings.haLongLivedToken">Long-Lived Access Token</span><input
<p class="field-note wide warning-note" data-i18n="settings.allowInvalidTlsHint">Use only for a trusted local Home Assistant server.</p> type="password" name="ha_token" autocomplete="new-password" data-i18n-placeholder="settings.haTokenKeep"
<div class="form-actions wide"><button type="button" class="secondary" id="haTest" data-i18n="settings.testHa">Test HA</button></div> placeholder="Leave empty to keep the saved token"></label>
<label class="check wide"><input type="checkbox" name="ha_allow_invalid_tls"> <span
data-i18n="settings.allowInvalidTls">Allow invalid/self-signed HTTPS certificate</span></label>
<p class="field-note wide warning-note" data-i18n="settings.allowInvalidTlsHint">Use only for a trusted
local Home Assistant server.</p>
<div class="form-actions wide"><button type="button" class="secondary" id="haTest"
data-i18n="settings.testHa">Test HA</button></div>
</div> </div>
</section> </section>
<section class="panel settings-block"> <section class="panel settings-block">
<div class="settings-block-head"><div><h3 data-i18n="settings.haThermostatSources">Źródła temperatury sterowania</h3><p data-i18n="settings.haThermostatSourcesHint">Te ustawienia należą do logiki termostatów i źródeł temperatury, nie do wspólnych wejść Flow.</p></div></div> <div class="settings-block-head">
<div>
<h3 data-i18n="settings.haThermostatSources">Źródła temperatury sterowania</h3>
<p data-i18n="settings.haThermostatSourcesHint">Te ustawienia należą do logiki termostatów i źródeł
temperatury, nie do wspólnych wejść Flow.</p>
</div>
</div>
<div class="settings-grid"> <div class="settings-grid">
<label class="wide"><span data-i18n="settings.defaultEntity">Connection test entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label> <label class="wide"><span data-i18n="settings.defaultEntity">Connection test entity_id</span><input
<p class="field-note wide" data-i18n="settings.defaultEntityHint">This entity is used by the Home Assistant connection test when no other entity is specified. It is not automatically used as a zone temperature source.</p> name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
<label class="wide"><span data-i18n="settings.outdoorEntity">Outdoor temperature entity_id</span><input name="ha_outdoor_entity_id" placeholder="sensor.outdoor_temperature"></label> <p class="field-note wide" data-i18n="settings.defaultEntityHint">This entity is used by the Home Assistant
<label class="wide"><span data-i18n="settings.haSensorStaleAfterMinutes">Maximum HA sensor reading age (min)</span><input type="number" name="ha_sensor_stale_after_minutes" min="1" max="1440" step="1" value="5"></label> connection test when no other entity is specified. It is not automatically used as a zone temperature
<p class="field-note wide" data-i18n="settings.haSensorStaleAfterHint">If the HA sensor is not updated within this time, the reading is treated as stale.</p> source.</p>
<label class="check wide"><input type="checkbox" name="outdoor_assist_enabled"> <span data-i18n="settings.outdoorAssist">Use outdoor temperature as smart-control assist</span></label> <label class="wide"><span data-i18n="settings.outdoorEntity">Outdoor temperature entity_id</span><input
name="ha_outdoor_entity_id" placeholder="sensor.outdoor_temperature"></label>
<label class="wide"><span data-i18n="settings.haSensorStaleAfterMinutes">Maximum HA sensor reading age
(min)</span><input type="number" name="ha_sensor_stale_after_minutes" min="1" max="1440" step="1"
value="5"></label>
<p class="field-note wide" data-i18n="settings.haSensorStaleAfterHint">If the HA sensor is not updated
within this time, the reading is treated as stale.</p>
<label class="check wide"><input type="checkbox" name="outdoor_assist_enabled"> <span
data-i18n="settings.outdoorAssist">Use outdoor temperature as smart-control assist</span></label>
</div> </div>
</section> </section>
<section class="panel settings-block flow-shared-settings" id="flowSharedInputsSettings"> <section class="panel settings-block flow-shared-settings" id="flowSharedInputsSettings">
<div class="settings-block-head flow-shared-settings-head"><div><h3 data-i18n="flow.sharedInputsTitle">Wspólne wejścia Flow</h3><p data-i18n="flow.sharedInputsHint">Zdefiniuj wspólne źródła wartości raz i używaj ich w wielu Flow. Czujniki HA dodane tylko tutaj nie są zapisywane do metryk.</p></div><button type="button" class="primary flow-shared-add-button" id="addFlowSharedInput" data-i18n="flow.sharedInputAdd">Dodaj wejście</button></div> <div class="settings-block-head flow-shared-settings-head">
<input type="hidden" id="flowSharedInputsRevision" name="flow_inputs_revision" value="[]"><div id="flowSharedInputList" class="flow-shared-input-list"></div> <div>
<h3 data-i18n="flow.sharedInputsTitle">Wspólne wejścia Flow</h3>
<p data-i18n="flow.sharedInputsHint">Zdefiniuj wspólne źródła wartości raz i używaj ich w wielu Flow.
Czujniki HA dodane tylko tutaj nie są zapisywane do metryk.</p>
</div><button type="button" class="primary flow-shared-add-button" id="addFlowSharedInput"
data-i18n="flow.sharedInputAdd">Dodaj wejście</button>
</div>
<input type="hidden" id="flowSharedInputsRevision" name="flow_inputs_revision" value="[]">
<div id="flowSharedInputList" class="flow-shared-input-list"></div>
</section> </section>
<section class="panel settings-block"> <section class="panel settings-block">
<div class="settings-block-head"><div><h3 data-i18n="settings.sensorAliases">Sensor aliases</h3><p data-i18n="settings.sensorAliasesHint">Badges show whether an entity is collected as metrics, used by Flow, or both.</p></div></div> <div class="settings-block-head">
<div>
<h3 data-i18n="settings.sensorAliases">Sensor aliases</h3>
<p data-i18n="settings.sensorAliasesHint">Badges show whether an entity is collected as metrics, used by
Flow, or both.</p>
</div>
</div>
<div class="sensor-alias-manager"> <div class="sensor-alias-manager">
<div id="sensorAliasList" class="sensor-alias-list"></div> <div id="sensorAliasList" class="sensor-alias-list"></div>
<div class="sensor-alias-add"><input id="sensorAliasEntity" placeholder="sensor.gabinet_temperature"><input id="sensorAliasName" data-i18n-placeholder="settings.aliasPlaceholder" placeholder="Gabinet"><button type="button" class="secondary" id="addSensorAlias" data-i18n="actions.add">Add</button></div> <div class="sensor-alias-add"><input id="sensorAliasEntity" placeholder="sensor.gabinet_temperature"><input
id="sensorAliasName" data-i18n-placeholder="settings.aliasPlaceholder" placeholder="Gabinet"><button
type="button" class="secondary" id="addSensorAlias" data-i18n="actions.add">Add</button></div>
</div> </div>
</section> </section>
<section class="panel settings-block"> <section class="panel settings-block">
<div class="settings-block-head"><div><h3 data-i18n="settings.haIntegrationAccess">Home Assistant integration access</h3><p data-i18n="settings.haIntegrationHint">Create a controller token and paste it into the GREE Controller integration in Home Assistant.</p></div></div> <div class="settings-block-head">
<div class="token-manager"><div id="accessTokenList" class="token-list"></div><div class="form-actions"><button type="button" class="primary" id="createAccessToken" data-i18n="settings.newToken">Create new token</button></div></div> <div>
<h3 data-i18n="settings.haIntegrationAccess">Home Assistant integration access</h3>
<p data-i18n="settings.haIntegrationHint">Create a controller token and paste it into the GREE Controller
integration in Home Assistant.</p>
</div>
</div>
<div class="token-manager">
<div id="accessTokenList" class="token-list"></div>
<div class="form-actions"><button type="button" class="primary" id="createAccessToken"
data-i18n="settings.newToken">Create new token</button></div>
</div>
</section> </section>
<div class="settings-save-bar"><span data-i18n="homeAssistant.saveHint">Save Home Assistant and sensor changes.</span><button class="primary" type="submit" data-i18n="actions.save">Save</button></div> <div class="settings-save-bar"><span data-i18n="homeAssistant.saveHint">Save Home Assistant and sensor
changes.</span><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
</form> </form>
</section> </section>
@@ -710,58 +771,155 @@
<section id="flowEditor" class="flow-editor" hidden aria-label="Flow editor"> <section id="flowEditor" class="flow-editor" hidden aria-label="Flow editor">
<header class="flow-editor-bar"> <header class="flow-editor-bar">
<div class="flow-editor-title"><button type="button" class="icon-button" data-action="close-flow-editor" aria-label="Back"></button><div><span class="eyebrow" data-i18n="flow.editorEyebrow">Flow editor</span><div class="flow-name-wrap"><div id="flowNameView" class="flow-name-view" hidden><button id="flowNameText" class="flow-name-text" type="button" data-action="edit-flow-name" data-i18n-title="flow.editName" title="Edit Flow name"></button><button class="flow-name-edit" type="button" data-action="edit-flow-name" data-i18n-title="flow.editName" data-i18n-aria="flow.editName" title="Edit Flow name" aria-label="Edit Flow name"></button></div><input id="flowName" maxlength="100" data-i18n-placeholder="flow.namePlaceholder" data-i18n-title="flow.nameAliasHint" placeholder="My automation"></div></div></div> <div class="flow-editor-title"><button type="button" class="icon-button" data-action="close-flow-editor"
<div class="flow-editor-actions"><button class="secondary flow-desktop-action" type="button" data-action="flow-templates" data-i18n="flow.templates">Templates</button><button class="secondary flow-desktop-action" type="button" data-action="import-flow" data-i18n="flow.import">Import</button><button class="secondary flow-desktop-action" type="button" data-action="export-flow" data-i18n="flow.export">Export</button><button class="secondary flow-desktop-action" type="button" data-action="flow-dry-run" data-i18n="flow.dryRun">Dry-run</button><button class="secondary flow-desktop-action" type="button" data-action="flow-logs" data-i18n="flow.logs">Logs</button><label class="flow-enabled"><input type="checkbox" id="flowEnabled" checked><span id="flowEnabledLabel" data-i18n="common.enabled">Enabled</span></label><span id="flowSaveStatus" class="flow-save-status" aria-live="polite"></span><span id="flowCompileStatus" class="badge flow-compile-status"></span><button class="secondary flow-mobile-actions-button" type="button" data-action="flow-mobile-actions" data-i18n="flow.actions">Actions</button></div> aria-label="Back">←</button>
<button class="primary flow-save-button" type="button" data-action="save-flow" data-i18n="actions.save">Save</button> <div><span class="eyebrow" data-i18n="flow.editorEyebrow">Flow editor</span>
</header> <div class="flow-name-wrap">
<div class="flow-editor-body"> <div id="flowNameView" class="flow-name-view" hidden><button id="flowNameText" class="flow-name-text"
<aside class="flow-palette"> type="button" data-action="edit-flow-name" data-i18n-title="flow.editName"
<div class="flow-palette-head"><strong data-i18n="flow.blocks">Blocks</strong><small data-i18n="flow.paletteHint">Add a block, then connect its output to the next block.</small></div> title="Edit Flow name"></button><button class="flow-name-edit" type="button"
<div class="flow-palette-group flow-palette-trigger"><span data-i18n="flow.triggers">Wyzwalacze</span> data-action="edit-flow-name" data-i18n-title="flow.editName" data-i18n-aria="flow.editName"
<button type="button" data-flow-add="cron_trigger" data-i18n="flow.node.cronTrigger">CRON</button></div> title="Edit Flow name" aria-label="Edit Flow name">✎</button></div><input id="flowName" maxlength="100"
<div class="flow-palette-group flow-palette-time"><span data-i18n="flow.time">Czas</span> data-i18n-placeholder="flow.namePlaceholder" data-i18n-title="flow.nameAliasHint"
<button type="button" data-flow-add="weekday" data-i18n="flow.node.weekday">Dni tygodnia</button><button type="button" data-flow-add="time_range" data-i18n="flow.node.timeRange">Przedział godzin</button><button type="button" data-flow-add="date_range" data-i18n="flow.node.dateRange">Zakres dat</button><button type="button" data-flow-add="night_mode" data-i18n="flow.node.nightMode">Tryb nocny</button></div> placeholder="My automation">
<div class="flow-palette-group flow-palette-timeop"><span data-i18n="flow.timeOps">Operacje czasu</span>
<button type="button" data-flow-add="stable_for" data-i18n="flow.node.stableFor">Utrzymuje się przez…</button><button type="button" data-flow-add="state_duration" data-i18n="flow.node.stateDuration">Czas trwania stanu</button><button type="button" data-flow-add="on_change" data-i18n="flow.node.onChange">Tylko przy zmianie</button><button type="button" data-flow-add="rate_limit" data-i18n="flow.node.rateLimit">Maks. X razy / okres</button><button type="button" data-flow-add="delay" data-i18n="flow.node.delay">Odczekaj…</button></div>
<div class="flow-palette-group flow-palette-sensor"><span data-i18n="flow.sensors">Sensory / wartości</span>
<button type="button" data-flow-add="outdoor_temperature" data-i18n="flow.node.outdoorTemperature">Temperatura zewn.</button><button type="button" data-flow-add="device_temperature" data-i18n="flow.node.deviceTemperature">Temperatura urządzenia</button><button type="button" data-flow-add="zone_temperature" data-i18n="flow.node.zoneTemperature">Temperatura strefy</button><button type="button" data-flow-add="house_mode" data-i18n="flow.node.houseMode">Tryb domu</button><button type="button" data-flow-add="device_state" data-i18n="flow.node.deviceState">Stan urządzenia</button><button type="button" data-flow-add="zone_state" data-i18n="flow.node.zoneState">Stan termostatu</button><button type="button" data-flow-add="group_state" data-i18n="flow.node.groupState">Stan grupy</button><button type="button" data-flow-add="ha_state" data-i18n="flow.node.haState">HA: stan</button><button type="button" data-flow-add="ha_numeric" data-i18n="flow.node.haNumeric">HA: wartość</button><button type="button" data-flow-add="ha_attribute" data-i18n="flow.node.haAttribute">HA: atrybut</button><button type="button" data-flow-add="ha_available" data-i18n="flow.node.haAvailable">HA: dostępność</button><button type="button" data-flow-add="constant" data-i18n="flow.node.constant">Stała testowa</button><button type="button" data-flow-add="shared_input" data-i18n="flow.node.sharedInput">Wspólne wejście</button><button type="button" data-flow-add="rolling_stat" data-i18n="flow.node.rollingStat">Średnia / mediana</button><button type="button" data-flow-add="oscillates" data-i18n="flow.node.oscillates">Wartość oscyluje</button></div>
<div class="flow-palette-group flow-palette-logic"><span data-i18n="flow.logic">Logika</span><button type="button" data-flow-add="logic_and" data-i18n="flow.node.and">AND</button><button type="button" data-flow-add="logic_or" data-i18n="flow.node.or">OR</button><button type="button" data-flow-add="logic_not" data-i18n="flow.node.not">NOT</button></div>
<div class="flow-palette-group flow-palette-action"><span data-i18n="flow.actions">Akcje</span>
<button type="button" data-flow-add="zone_thermostat" data-i18n="flow.node.thermostat">Termostat</button><button type="button" data-flow-add="device_action" data-i18n="flow.node.greeDevice">Urządzenie GREE</button><button type="button" data-flow-add="group_action" data-i18n="flow.node.group">Grupa</button></div>
<div class="flow-palette-group flow-palette-haaction"><span data-i18n="flow.haActions">Home Assistant</span>
<button type="button" data-flow-add="ha_service_action" data-i18n="flow.node.haServiceAction">Home Assistant: usługa</button></div>
</aside>
<div class="flow-workspace-wrap">
<div class="flow-workspace-toolbar"><div class="flow-workspace-primary-actions"><button type="button" class="primary flow-add-block-button" data-action="flow-add-block"><span aria-hidden="true"></span><span data-i18n="flow.addBlock">Add block</span></button><button type="button" class="secondary flow-selection-action" data-action="flow-select-all" data-i18n="flow.selectAll">Zaznacz wszystko</button><button type="button" class="secondary flow-selection-action" data-action="flow-clear-selection" data-i18n="flow.clearSelection">Wyczyść zaznaczenie</button><span id="flowSelectionCount" class="badge"></span></div><div class="flow-zoom-controls" role="group" data-i18n-aria="flow.zoomControls" aria-label="Canvas zoom"><button type="button" class="secondary icon-button" data-action="flow-zoom-out" data-i18n-title="flow.zoomOut" title="Zoom out"></button><span id="flowZoomLabel" aria-live="polite">100%</span><button type="button" class="secondary icon-button" data-action="flow-zoom-in" data-i18n-title="flow.zoomIn" title="Zoom in"></button><button type="button" class="secondary flow-fit-button" data-action="flow-fit" data-i18n="flow.fitView">Fit</button></div></div>
<div id="flowWorkspace" class="flow-workspace" tabindex="0">
<div id="flowCanvas" class="flow-canvas"><svg id="flowEdges" class="flow-edges" aria-hidden="true"></svg><div id="flowNodes" class="flow-nodes"></div></div>
<div id="flowEmptyHint" class="flow-empty-hint"><strong data-i18n="flow.emptyCanvas">Start by adding blocks</strong><span data-i18n="flow.emptyCanvasHint">Conditions go on the left, actions on the right.</span><button type="button" class="primary" data-action="flow-add-block" data-i18n="flow.addBlock">Add block</button></div>
</div>
<div class="flow-natural-preview" id="flowNaturalPreview">
<button type="button" class="flow-preview-toggle" data-action="flow-preview-toggle" aria-expanded="false"><strong data-i18n="flow.interpretation">Interpretation</strong><span class="flow-preview-toggle-icon" aria-hidden="true"></span></button>
<div class="flow-preview-details">
<div class="flow-preview-item flow-preview-interpretation"><strong data-i18n="flow.interpretation">Interpretation</strong><span id="flowInterpretation"></span></div>
<div class="flow-preview-item flow-preview-runtime" id="flowRuntimeInfo"><strong data-i18n="flow.runtimeInfo">Flow cycle</strong><span id="flowRuntimeSummary"></span></div>
</div> </div>
</div> </div>
</div> </div>
<aside id="flowInspector" class="flow-inspector"><div class="empty compact"><strong data-i18n="flow.selectBlock">Select a block</strong><span data-i18n="flow.selectBlockHint">Its settings will appear here.</span></div></aside> <div class="flow-editor-actions"><button class="secondary flow-desktop-action" type="button"
data-action="flow-templates" data-i18n="flow.templates">Templates</button><button
class="secondary flow-desktop-action" type="button" data-action="import-flow"
data-i18n="flow.import">Import</button><button class="secondary flow-desktop-action" type="button"
data-action="export-flow" data-i18n="flow.export">Export</button><button class="secondary flow-desktop-action"
type="button" data-action="flow-dry-run" data-i18n="flow.dryRun">Dry-run</button><button
class="secondary flow-desktop-action" type="button" data-action="flow-logs"
data-i18n="flow.logs">Logs</button><label class="flow-enabled"><input type="checkbox" id="flowEnabled"
checked><span id="flowEnabledLabel" data-i18n="common.enabled">Enabled</span></label><span
id="flowSaveStatus" class="flow-save-status" aria-live="polite"></span><span id="flowCompileStatus"
class="badge flow-compile-status"></span><button class="secondary flow-mobile-actions-button" type="button"
data-action="flow-mobile-actions" data-i18n="flow.actions">Actions</button></div>
<button class="primary flow-save-button" type="button" data-action="save-flow"
data-i18n="actions.save">Save</button>
</header>
<div class="flow-editor-body">
<aside class="flow-palette">
<div class="flow-palette-head"><strong data-i18n="flow.blocks">Blocks</strong><small
data-i18n="flow.paletteHint">Add a block, then connect its output to the next block.</small></div>
<div class="flow-palette-group flow-palette-trigger"><span data-i18n="flow.triggers">Wyzwalacze</span>
<button type="button" data-flow-add="cron_trigger" data-i18n="flow.node.cronTrigger">CRON</button>
</div>
<div class="flow-palette-group flow-palette-time"><span data-i18n="flow.time">Czas</span>
<button type="button" data-flow-add="weekday" data-i18n="flow.node.weekday">Dni tygodnia</button><button
type="button" data-flow-add="time_range" data-i18n="flow.node.timeRange">Przedział godzin</button><button
type="button" data-flow-add="date_range" data-i18n="flow.node.dateRange">Zakres dat</button><button
type="button" data-flow-add="night_mode" data-i18n="flow.node.nightMode">Tryb nocny</button>
</div>
<div class="flow-palette-group flow-palette-timeop"><span data-i18n="flow.timeOps">Operacje czasu</span>
<button type="button" data-flow-add="stable_for" data-i18n="flow.node.stableFor">Utrzymuje się
przez…</button><button type="button" data-flow-add="state_duration" data-i18n="flow.node.stateDuration">Czas
trwania stanu</button><button type="button" data-flow-add="on_change" data-i18n="flow.node.onChange">Tylko
przy zmianie</button><button type="button" data-flow-add="rate_limit" data-i18n="flow.node.rateLimit">Maks.
X razy / okres</button><button type="button" data-flow-add="delay"
data-i18n="flow.node.delay">Odczekaj…</button>
</div>
<div class="flow-palette-group flow-palette-sensor"><span data-i18n="flow.sensors">Sensory / wartości</span>
<button type="button" data-flow-add="outdoor_temperature" data-i18n="flow.node.outdoorTemperature">Temperatura
zewn.</button><button type="button" data-flow-add="device_temperature"
data-i18n="flow.node.deviceTemperature">Temperatura urządzenia</button><button type="button"
data-flow-add="zone_temperature" data-i18n="flow.node.zoneTemperature">Temperatura strefy</button><button
type="button" data-flow-add="house_mode" data-i18n="flow.node.houseMode">Tryb domu</button><button
type="button" data-flow-add="device_state" data-i18n="flow.node.deviceState">Stan urządzenia</button><button
type="button" data-flow-add="zone_state" data-i18n="flow.node.zoneState">Stan termostatu</button><button
type="button" data-flow-add="group_state" data-i18n="flow.node.groupState">Stan grupy</button><button
type="button" data-flow-add="ha_state" data-i18n="flow.node.haState">HA: stan</button><button type="button"
data-flow-add="ha_numeric" data-i18n="flow.node.haNumeric">HA: wartość</button><button type="button"
data-flow-add="ha_attribute" data-i18n="flow.node.haAttribute">HA: atrybut</button><button type="button"
data-flow-add="ha_available" data-i18n="flow.node.haAvailable">HA: dostępność</button><button type="button"
data-flow-add="constant" data-i18n="flow.node.constant">Stała testowa</button><button type="button"
data-flow-add="shared_input" data-i18n="flow.node.sharedInput">Wspólne wejście</button><button type="button"
data-flow-add="rolling_stat" data-i18n="flow.node.rollingStat">Średnia / mediana</button><button
type="button" data-flow-add="oscillates" data-i18n="flow.node.oscillates">Wartość oscyluje</button>
</div>
<div class="flow-palette-group flow-palette-logic"><span data-i18n="flow.logic">Logika</span><button
type="button" data-flow-add="logic_and" data-i18n="flow.node.and">AND</button><button type="button"
data-flow-add="logic_or" data-i18n="flow.node.or">OR</button><button type="button" data-flow-add="logic_not"
data-i18n="flow.node.not">NOT</button></div>
<div class="flow-palette-group flow-palette-action"><span data-i18n="flow.actions">Akcje</span>
<button type="button" data-flow-add="zone_thermostat"
data-i18n="flow.node.thermostat">Termostat</button><button type="button" data-flow-add="device_action"
data-i18n="flow.node.greeDevice">Urządzenie GREE</button><button type="button" data-flow-add="group_action"
data-i18n="flow.node.group">Grupa</button>
</div>
<div class="flow-palette-group flow-palette-haaction"><span data-i18n="flow.haActions">Home Assistant</span>
<button type="button" data-flow-add="ha_service_action" data-i18n="flow.node.haServiceAction">Home Assistant:
usługa</button>
</div>
</aside>
<div class="flow-workspace-wrap">
<div class="flow-workspace-toolbar">
<div class="flow-workspace-primary-actions"><button type="button" class="primary flow-add-block-button"
data-action="flow-add-block"><span aria-hidden="true"></span><span data-i18n="flow.addBlock">Add
block</span></button><button type="button" class="secondary flow-selection-action"
data-action="flow-select-all" data-i18n="flow.selectAll">Zaznacz wszystko</button><button type="button"
class="secondary flow-selection-action" data-action="flow-clear-selection"
data-i18n="flow.clearSelection">Wyczyść zaznaczenie</button><span id="flowSelectionCount"
class="badge"></span></div>
<div class="flow-zoom-controls" role="group" data-i18n-aria="flow.zoomControls" aria-label="Canvas zoom">
<button type="button" class="secondary icon-button" data-action="flow-zoom-out"
data-i18n-title="flow.zoomOut" title="Zoom out"></button><span id="flowZoomLabel"
aria-live="polite">100%</span><button type="button" class="secondary icon-button"
data-action="flow-zoom-in" data-i18n-title="flow.zoomIn" title="Zoom in"></button><button type="button"
class="secondary flow-fit-button" data-action="flow-fit" data-i18n="flow.fitView">Fit</button></div>
</div>
<div id="flowWorkspace" class="flow-workspace" tabindex="0">
<div id="flowCanvas" class="flow-canvas"><svg id="flowEdges" class="flow-edges" aria-hidden="true"></svg>
<div id="flowNodes" class="flow-nodes"></div>
</div>
<div id="flowEmptyHint" class="flow-empty-hint"><strong data-i18n="flow.emptyCanvas">Start by adding
blocks</strong><span data-i18n="flow.emptyCanvasHint">Conditions go on the left, actions on the
right.</span><button type="button" class="primary" data-action="flow-add-block"
data-i18n="flow.addBlock">Add block</button></div>
</div>
<div class="flow-natural-preview" id="flowNaturalPreview">
<button type="button" class="flow-preview-toggle" data-action="flow-preview-toggle"
aria-expanded="false"><strong data-i18n="flow.interpretation">Interpretation</strong><span
class="flow-preview-toggle-icon" aria-hidden="true">⌃</span></button>
<div class="flow-preview-details">
<div class="flow-preview-item flow-preview-interpretation"><strong
data-i18n="flow.interpretation">Interpretation</strong><span id="flowInterpretation"></span></div>
<div class="flow-preview-item flow-preview-runtime" id="flowRuntimeInfo"><strong
data-i18n="flow.runtimeInfo">Flow cycle</strong><span id="flowRuntimeSummary"></span></div>
</div>
</div>
</div>
<aside id="flowInspector" class="flow-inspector">
<div class="empty compact"><strong data-i18n="flow.selectBlock">Select a block</strong><span
data-i18n="flow.selectBlockHint">Its settings will appear here.</span></div>
</aside>
</div> </div>
</section> </section>
<dialog id="flowBlockDialog" class="sheet flow-block-dialog"> <dialog id="flowBlockDialog" class="sheet flow-block-dialog">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.blocks">Blocks</span><h2 data-i18n="flow.addBlock">Add block</h2></div><button type="button" data-close aria-label="Close">×</button></div> <div class="dialog-head">
<div class="flow-block-search"><input id="flowBlockSearch" type="search" autocomplete="off" data-i18n-placeholder="flow.searchBlocks" placeholder="Search blocks"></div> <div><span class="eyebrow" data-i18n="flow.blocks">Blocks</span>
<h2 data-i18n="flow.addBlock">Add block</h2>
</div><button type="button" data-close aria-label="Close">×</button>
</div>
<div class="flow-block-search"><input id="flowBlockSearch" type="search" autocomplete="off"
data-i18n-placeholder="flow.searchBlocks" placeholder="Search blocks…"></div>
<div id="flowBlockLibrary" class="flow-block-library"></div> <div id="flowBlockLibrary" class="flow-block-library"></div>
</dialog> </dialog>
<dialog id="flowEditorActionsDialog" class="sheet flow-editor-actions-dialog"> <dialog id="flowEditorActionsDialog" class="sheet flow-editor-actions-dialog">
<div class="dialog-head"><h2 data-i18n="flow.actions">Actions</h2><button type="button" data-close>×</button></div> <div class="dialog-head">
<h2 data-i18n="flow.actions">Actions</h2><button type="button" data-close>×</button>
</div>
<div class="menu-list"> <div class="menu-list">
<button type="button" data-action="flow-templates"><span data-i18n="flow.templates">Templates</span><span></span></button> <button type="button" data-action="flow-templates"><span
data-i18n="flow.templates">Templates</span><span></span></button>
<button type="button" data-action="import-flow"><span data-i18n="flow.import">Import</span><span></span></button> <button type="button" data-action="import-flow"><span data-i18n="flow.import">Import</span><span></span></button>
<button type="button" data-action="export-flow"><span data-i18n="flow.export">Export</span><span></span></button> <button type="button" data-action="export-flow"><span data-i18n="flow.export">Export</span><span></span></button>
<button type="button" data-action="flow-dry-run"><span data-i18n="flow.dryRun">Dry-run</span><span></span></button> <button type="button" data-action="flow-dry-run"><span
data-i18n="flow.dryRun">Dry-run</span><span></span></button>
<button type="button" data-action="flow-logs"><span data-i18n="flow.logs">Logs</span><span></span></button> <button type="button" data-action="flow-logs"><span data-i18n="flow.logs">Logs</span><span></span></button>
</div> </div>
</dialog> </dialog>
@@ -770,43 +928,78 @@
<dialog id="flowSharedInputDialog"> <dialog id="flowSharedInputDialog">
<form id="flowSharedInputForm" class="dialog-form"> <form id="flowSharedInputForm" class="dialog-form">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.sharedInputEyebrow">Wspólne dane</span><h2 id="flowSharedInputDialogTitle" data-i18n="flow.sharedInputTitle">Wspólne wejście Flow</h2></div><button type="button" data-close>×</button></div> <div class="dialog-head">
<div><span class="eyebrow" data-i18n="flow.sharedInputEyebrow">Wspólne dane</span>
<h2 id="flowSharedInputDialogTitle" data-i18n="flow.sharedInputTitle">Wspólne wejście Flow</h2>
</div><button type="button" data-close>×</button>
</div>
<input type="hidden" name="id"> <input type="hidden" name="id">
<label><span data-i18n="common.name">Nazwa</span><input name="name" maxlength="100" required placeholder="Okno w salonie zamknięte"></label> <label><span data-i18n="common.name">Nazwa</span><input name="name" maxlength="100" required
<label><span data-i18n="flow.sharedInputType">Typ źródła</span><select name="kind" id="flowSharedInputKind"></select></label> placeholder="Okno w salonie zamknięte"></label>
<label><span data-i18n="flow.sharedInputType">Typ źródła</span><select name="kind"
id="flowSharedInputKind"></select></label>
<div id="flowSharedInputFields" class="flow-shared-input-fields"></div> <div id="flowSharedInputFields" class="flow-shared-input-fields"></div>
<div id="flowSharedInputTestPanel" class="flow-shared-test-panel" hidden> <div id="flowSharedInputTestPanel" class="flow-shared-test-panel" hidden>
<div><strong data-i18n="flow.sharedInputTest">Testuj wejście HA</strong><p class="field-note" data-i18n="flow.sharedInputTestHint">Test używa zapisanej konfiguracji połączenia Home Assistant i nie zapisuje tego wejścia.</p></div> <div><strong data-i18n="flow.sharedInputTest">Testuj wejście HA</strong>
<button type="button" class="secondary" id="flowSharedInputTest" data-i18n="flow.sharedInputTest">Testuj wejście HA</button> <p class="field-note" data-i18n="flow.sharedInputTestHint">Test używa zapisanej konfiguracji połączenia Home
<div id="flowSharedInputTestResult" class="flow-shared-test-result" role="status" aria-live="polite" hidden></div> Assistant i nie zapisuje tego wejścia.</p>
</div>
<button type="button" class="secondary" id="flowSharedInputTest" data-i18n="flow.sharedInputTest">Testuj wejście
HA</button>
<div id="flowSharedInputTestResult" class="flow-shared-test-result" role="status" aria-live="polite" hidden>
</div>
</div> </div>
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Anuluj</button><button type="submit" class="primary" data-i18n="actions.save">Zapisz</button></div> <div class="form-actions"><button type="button" class="secondary" data-close
data-i18n="actions.cancel">Anuluj</button><button type="submit" class="primary"
data-i18n="actions.save">Zapisz</button></div>
</form> </form>
</dialog> </dialog>
<dialog id="flowTemplateDialog" class="dialog-wide"> <dialog id="flowTemplateDialog" class="dialog-wide">
<div class="dialog-form"> <div class="dialog-form">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.templateLibrary">Flow library</span><h2 data-i18n="flow.templates">Templates</h2></div><button type="button" data-close>×</button></div> <div class="dialog-head">
<p class="field-note" data-i18n="flow.templatesHint">Ready-made layouts create an editable graph using existing zones, devices and groups.</p> <div><span class="eyebrow" data-i18n="flow.templateLibrary">Flow library</span>
<div class="flow-template-toolbar"><label><span data-i18n="flow.templateSearchLabel">Szukaj presetów</span><input id="flowTemplateSearch" type="search" autocomplete="off" data-i18n-placeholder="flow.templateSearch" placeholder="Szukaj presetów…"></label></div> <h2 data-i18n="flow.templates">Templates</h2>
</div><button type="button" data-close>×</button>
</div>
<p class="field-note" data-i18n="flow.templatesHint">Ready-made layouts create an editable graph using existing
zones, devices and groups.</p>
<div class="flow-template-toolbar"><label><span data-i18n="flow.templateSearchLabel">Szukaj presetów</span><input
id="flowTemplateSearch" type="search" autocomplete="off" data-i18n-placeholder="flow.templateSearch"
placeholder="Szukaj presetów…"></label></div>
<div class="flow-template-browser"> <div class="flow-template-browser">
<div class="flow-template-catalog"> <div class="flow-template-catalog">
<div id="flowTemplateTabs" class="flow-template-tabs" role="tablist" data-i18n-aria="flow.templateCategories" aria-label="Preset categories"></div> <div id="flowTemplateTabs" class="flow-template-tabs" role="tablist" data-i18n-aria="flow.templateCategories"
aria-label="Preset categories"></div>
<div id="flowTemplateList" class="flow-template-list"></div> <div id="flowTemplateList" class="flow-template-list"></div>
</div> </div>
<aside id="flowTemplatePreview" class="flow-template-preview"><div class="empty compact"><strong data-i18n="flow.templatePreview">Podgląd presetu</strong><span data-i18n="flow.templateSelectPreview">Wybierz preset, aby zobaczyć podgląd.</span></div></aside> <aside id="flowTemplatePreview" class="flow-template-preview">
<div class="empty compact"><strong data-i18n="flow.templatePreview">Podgląd presetu</strong><span
data-i18n="flow.templateSelectPreview">Wybierz preset, aby zobaczyć podgląd.</span></div>
</aside>
</div> </div>
</div> </div>
</dialog> </dialog>
<dialog id="flowTestDialog" class="dialog-wide"> <dialog id="flowTestDialog" class="dialog-wide">
<div class="dialog-form"> <div class="dialog-form">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.testMode">Flow diagnostics</span><h2 id="flowTestTitle" data-i18n="flow.dryRun">Dry-run</h2></div><button type="button" data-close>×</button></div> <div class="dialog-head">
<div><span class="eyebrow" data-i18n="flow.testMode">Flow diagnostics</span>
<h2 id="flowTestTitle" data-i18n="flow.dryRun">Dry-run</h2>
</div><button type="button" data-close>×</button>
</div>
<div id="flowSimulationControls" class="flow-simulation-controls"> <div id="flowSimulationControls" class="flow-simulation-controls">
<p class="field-note" data-i18n="flow.dryRunHint">Dry-run does not change thermostat, device, group, schedule or automation state.</p> <p class="field-note" data-i18n="flow.dryRunHint">Dry-run does not change thermostat, device, group, schedule or
<label><span data-i18n="flow.simulationTime">Simulation date and time</span><input id="flowSimulationAt" type="datetime-local"></label> automation state.</p>
<div><strong data-i18n="flow.simulationOverrides">Sensor overrides</strong><p class="field-note" data-i18n="flow.simulationOverridesHint">Leave blank to use the current application or Home Assistant state.</p><div id="flowSimulationOverrides" class="flow-simulation-overrides"></div></div> <label><span data-i18n="flow.simulationTime">Simulation date and time</span><input id="flowSimulationAt"
<div class="form-actions"><button class="primary" type="button" data-action="run-flow-dry-run" data-i18n="flow.runSimulation">Run simulation</button></div> type="datetime-local"></label>
<div><strong data-i18n="flow.simulationOverrides">Sensor overrides</strong>
<p class="field-note" data-i18n="flow.simulationOverridesHint">Leave blank to use the current application or
Home Assistant state.</p>
<div id="flowSimulationOverrides" class="flow-simulation-overrides"></div>
</div>
<div class="form-actions"><button class="primary" type="button" data-action="run-flow-dry-run"
data-i18n="flow.runSimulation">Run simulation</button></div>
</div> </div>
<div id="flowTestResults" class="flow-test-results"></div> <div id="flowTestResults" class="flow-test-results"></div>
</div> </div>
@@ -819,13 +1012,17 @@
<button class="desktop-nav-only" data-nav="groups"><span></span><b data-i18n="nav.groups">Groups</b></button> <button class="desktop-nav-only" data-nav="groups"><span></span><b data-i18n="nav.groups">Groups</b></button>
<span class="nav-section-label desktop-nav-only" data-i18n="nav.sectionAutomation">Automatyzacja</span> <span class="nav-section-label desktop-nav-only" data-i18n="nav.sectionAutomation">Automatyzacja</span>
<button data-nav="flows"><span></span><b data-i18n="nav.flows">Flow</b></button> <button data-nav="flows"><span></span><b data-i18n="nav.flows">Flow</b></button>
<button class="desktop-nav-only" data-nav="schedules"><span></span><b data-i18n="nav.schedules">Schedules</b></button> <button class="desktop-nav-only" data-nav="schedules"><span></span><b
<button class="desktop-nav-only" data-nav="automations"><span></span><b data-i18n="nav.automations">Automations</b></button> data-i18n="nav.schedules">Schedules</b></button>
<button class="desktop-nav-only" data-nav="automations"><span></span><b
data-i18n="nav.automations">Automations</b></button>
<span class="nav-section-label desktop-nav-only" data-i18n="nav.sectionData">Dane i system</span> <span class="nav-section-label desktop-nav-only" data-i18n="nav.sectionData">Dane i system</span>
<button data-nav="history"><span></span><b data-i18n="nav.history">History</b></button> <button data-nav="history"><span></span><b data-i18n="nav.history">History</b></button>
<button class="desktop-nav-only" data-nav="devices"><span></span><b data-i18n="nav.devices">Devices</b></button> <button class="desktop-nav-only" data-nav="devices"><span></span><b data-i18n="nav.devices">Devices</b></button>
<button class="desktop-nav-only" data-nav="homeassistant"><span>HA</span><b data-i18n="nav.homeAssistantShort">Home Assistant</b></button> <button class="desktop-nav-only" data-nav="homeassistant"><span>HA</span><b data-i18n="nav.homeAssistantShort">Home
<button class="desktop-nav-only" data-nav="simulation"><span></span><b data-i18n="nav.simulation">Simulator</b></button> Assistant</b></button>
<button class="desktop-nav-only" data-nav="simulation"><span></span><b
data-i18n="nav.simulation">Simulator</b></button>
<button class="desktop-nav-only" data-nav="night"><span></span><b data-i18n="nav.nightMode">Night mode</b></button> <button class="desktop-nav-only" data-nav="night"><span></span><b data-i18n="nav.nightMode">Night mode</b></button>
<button class="desktop-nav-only" data-nav="logs"><span></span><b data-i18n="nav.logs">Events</b></button> <button class="desktop-nav-only" data-nav="logs"><span></span><b data-i18n="nav.logs">Events</b></button>
<button class="desktop-nav-only" data-nav="settings"><span></span><b data-i18n="nav.settings">Settings</b></button> <button class="desktop-nav-only" data-nav="settings"><span></span><b data-i18n="nav.settings">Settings</b></button>
@@ -923,13 +1120,16 @@
<h2 data-i18n="devices.technicalConfig">Technical configuration</h2><button type="button" data-close>×</button> <h2 data-i18n="devices.technicalConfig">Technical configuration</h2><button type="button" data-close>×</button>
</div> </div>
<label><span data-i18n="common.name">Name</span><input name="name" required maxlength="80"></label> <label><span data-i18n="common.name">Name</span><input name="name" required maxlength="80"></label>
<div class="two"><label><span data-i18n="devices.ipAddress">IP address</span><input name="ip" required></label><label><span>Port</span><input type="number" name="port" min="1" max="65535" required></label></div> <div class="two"><label><span data-i18n="devices.ipAddress">IP address</span><input name="ip"
required></label><label><span>Port</span><input type="number" name="port" min="1" max="65535"
required></label></div>
<label><span data-i18n="devices.protocol">Protocol</span><select name="protocol_version"> <label><span data-i18n="devices.protocol">Protocol</span><select name="protocol_version">
<option value="0" data-i18n="devices.protocolAuto">Auto (V1 + V2)</option> <option value="0" data-i18n="devices.protocolAuto">Auto (V1 + V2)</option>
<option value="1">V1 AES-ECB</option> <option value="1">V1 AES-ECB</option>
<option value="2">V2 AES-GCM</option> <option value="2">V2 AES-GCM</option>
</select></label> </select></label>
<p class="field-note" data-i18n="devices.protocolChangeHint">Changing protocol clears the saved device key. Save and check connection immediately performs the required bind and test.</p> <p class="field-note" data-i18n="devices.protocolChangeHint">Changing protocol clears the saved device key. Save
and check connection immediately performs the required bind and test.</p>
<div id="deviceConfigCheckResult" class="config-check-result" hidden></div> <div id="deviceConfigCheckResult" class="config-check-result" hidden></div>
<div class="form-actions technical-config-actions"><button type="button" class="secondary" data-close <div class="form-actions technical-config-actions"><button type="button" class="secondary" data-close
data-i18n="actions.cancel">Cancel</button><button class="secondary" type="submit" data-save-mode="save" data-i18n="actions.cancel">Cancel</button><button class="secondary" type="submit" data-save-mode="save"
@@ -941,14 +1141,19 @@
<dialog id="pingDialog" class="dialog-wide ping-dialog"> <dialog id="pingDialog" class="dialog-wide ping-dialog">
<div class="dialog-form"> <div class="dialog-form">
<div class="dialog-head"> <div class="dialog-head">
<div><span class="eyebrow" data-i18n="devices.diagnostics">Diagnostics</span><h2 data-i18n="devices.pingLive">Live ping</h2></div> <div><span class="eyebrow" data-i18n="devices.diagnostics">Diagnostics</span>
<h2 data-i18n="devices.pingLive">Live ping</h2>
</div>
<button type="button" data-close>×</button> <button type="button" data-close>×</button>
</div> </div>
<p class="field-note" data-i18n="devices.pingHint">Measures a minimal GREE round-trip without changing device state, error counters or automation ownership. The chart refreshes while monitoring is enabled.</p> <p class="field-note" data-i18n="devices.pingHint">Measures a minimal GREE round-trip without changing device
state, error counters or automation ownership. The chart refreshes while monitoring is enabled.</p>
<div class="ping-toolbar"> <div class="ping-toolbar">
<label><span data-i18n="common.device">Device</span><select id="pingDeviceSelect"></select></label> <label><span data-i18n="common.device">Device</span><select id="pingDeviceSelect"></select></label>
<label class="check ping-all-toggle"><input type="checkbox" id="pingAllDevices"> <span data-i18n="devices.pingAll">Ping all units</span></label> <label class="check ping-all-toggle"><input type="checkbox" id="pingAllDevices"> <span
<button type="button" class="primary" id="pingToggleButton" data-action="ping-toggle" data-i18n="devices.pingStop">Stop</button> data-i18n="devices.pingAll">Ping all units</span></label>
<button type="button" class="primary" id="pingToggleButton" data-action="ping-toggle"
data-i18n="devices.pingStop">Stop</button>
</div> </div>
<div class="ping-live-grid" id="pingLiveGrid"></div> <div class="ping-live-grid" id="pingLiveGrid"></div>
</div> </div>
+1 -1
View File
@@ -191,7 +191,7 @@ function prepareCanvas(canvas, height) {
canvas.style.width = `${width}px`; canvas.style.height = `${actualHeight}px`; canvas.style.width = `${width}px`; canvas.style.height = `${actualHeight}px`;
const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, actualHeight); ctx.clearRect(0, 0, width, actualHeight);
return { ctx, width, height:actualHeight }; return { ctx, width, height: actualHeight };
} }
function drawEmptyChart(canvas, height = 340) { function drawEmptyChart(canvas, height = 340) {
+97 -97
View File
@@ -39,24 +39,24 @@ function newFlowId(prefix = 'node') {
} }
function sharedFlowInputRequiresComparison(kind) { function sharedFlowInputRequiresComparison(kind) {
return ['outdoor_temperature','device_temperature','zone_temperature','ha_state','ha_numeric','ha_attribute','house_mode','device_state','zone_state','group_state'].includes(kind); return ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_state', 'ha_numeric', 'ha_attribute', 'house_mode', 'device_state', 'zone_state', 'group_state'].includes(kind);
} }
function sharedFlowReferenceComparisonDefaults(item) { function sharedFlowReferenceComparisonDefaults(item) {
const kind = item?.kind || ''; const kind = item?.kind || '';
if (['outdoor_temperature','device_temperature','zone_temperature'].includes(kind)) return { operator:'lt', value:20 }; if (['outdoor_temperature', 'device_temperature', 'zone_temperature'].includes(kind)) return { operator: 'lt', value: 20 };
if (kind === 'ha_numeric') return { operator:'lt', value:0 }; if (kind === 'ha_numeric') return { operator: 'lt', value: 0 };
if (kind === 'ha_state') return { operator:'eq', value:'on' }; if (kind === 'ha_state') return { operator: 'eq', value: 'on' };
if (kind === 'ha_attribute') return { operator:'eq', value:'' }; if (kind === 'ha_attribute') return { operator: 'eq', value: '' };
if (kind === 'house_mode') return { operator:'eq', value:'cool' }; if (kind === 'house_mode') return { operator: 'eq', value: 'cool' };
if (kind === 'device_state') return { operator:'eq', value:'true' }; if (kind === 'device_state') return { operator: 'eq', value: 'true' };
if (kind === 'zone_state') return { operator:'eq', value:'true' }; if (kind === 'zone_state') return { operator: 'eq', value: 'true' };
if (kind === 'group_state') return { operator:'eq', value:'true' }; if (kind === 'group_state') return { operator: 'eq', value: 'true' };
return {}; return {};
} }
function sharedFlowReferenceDefaultConfig(item) { function sharedFlowReferenceDefaultConfig(item) {
const config = { input_id:item?.id || '' }; const config = { input_id: item?.id || '' };
if (item && sharedFlowInputRequiresComparison(item.kind)) Object.assign(config, sharedFlowReferenceComparisonDefaults(item)); if (item && sharedFlowInputRequiresComparison(item.kind)) Object.assign(config, sharedFlowReferenceComparisonDefaults(item));
return config; return config;
} }
@@ -180,38 +180,38 @@ function flowSharedInputValueSignature(item) {
} }
function flowSharedInputLocalObservation(item) { function flowSharedInputLocalObservation(item) {
if (!item) return { hasValue:false, value:null }; if (!item) return { hasValue: false, value: null };
const c = item.config || {}; const c = item.config || {};
if (item.kind === 'outdoor_temperature') return { hasValue:app.outdoorTemperature !== null && app.outdoorTemperature !== undefined && Number.isFinite(Number(app.outdoorTemperature)), value:app.outdoorTemperature }; if (item.kind === 'outdoor_temperature') return { hasValue: app.outdoorTemperature !== null && app.outdoorTemperature !== undefined && Number.isFinite(Number(app.outdoorTemperature)), value: app.outdoorTemperature };
if (item.kind === 'device_temperature') { if (item.kind === 'device_temperature') {
const value = app.devices.find(device => device.id === c.device_id)?.current_temperature; const value = app.devices.find(device => device.id === c.device_id)?.current_temperature;
return { hasValue:value !== null && value !== undefined && Number.isFinite(Number(value)), value }; return { hasValue: value !== null && value !== undefined && Number.isFinite(Number(value)), value };
} }
if (item.kind === 'zone_temperature') { if (item.kind === 'zone_temperature') {
const value = app.zones.find(zone => zone.id === c.zone_id)?.current_temperature; const value = app.zones.find(zone => zone.id === c.zone_id)?.current_temperature;
return { hasValue:value !== null && value !== undefined && Number.isFinite(Number(value)), value }; return { hasValue: value !== null && value !== undefined && Number.isFinite(Number(value)), value };
} }
if (item.kind === 'house_mode') { if (item.kind === 'house_mode') {
const value = app.settings?.house_mode; const value = app.settings?.house_mode;
return { hasValue:value !== null && value !== undefined && value !== '', value }; return { hasValue: value !== null && value !== undefined && value !== '', value };
} }
if (item.kind === 'device_state') { if (item.kind === 'device_state') {
const device = app.devices.find(value => value.id === c.device_id), value = device?.[c.field]; const device = app.devices.find(value => value.id === c.device_id), value = device?.[c.field];
return { hasValue:value !== undefined && value !== null, value }; return { hasValue: value !== undefined && value !== null, value };
} }
if (item.kind === 'zone_state') { if (item.kind === 'zone_state') {
const zone = app.zones.find(value => value.id === c.zone_id), value = zone?.[c.field]; const zone = app.zones.find(value => value.id === c.zone_id), value = zone?.[c.field];
return { hasValue:value !== undefined && value !== null, value }; return { hasValue: value !== undefined && value !== null, value };
} }
if (item.kind === 'group_state') { if (item.kind === 'group_state') {
const group = app.groups.find(value => value.id === c.group_id), value = group?.[c.field || 'power_enabled']; const group = app.groups.find(value => value.id === c.group_id), value = group?.[c.field || 'power_enabled'];
return { hasValue:value !== undefined && value !== null, value }; return { hasValue: value !== undefined && value !== null, value };
} }
if (item.kind === 'night_mode') { if (item.kind === 'night_mode') {
const value = app.controlPlan?.night_mode_active; const value = app.controlPlan?.night_mode_active;
return { hasValue:typeof value === 'boolean', value }; return { hasValue: typeof value === 'boolean', value };
} }
if (item.kind === 'constant') return { hasValue:true, value:c.value }; if (item.kind === 'constant') return { hasValue: true, value: c.value };
return null; return null;
} }
@@ -219,13 +219,13 @@ function flowSharedInputObservation(item) {
const local = flowSharedInputLocalObservation(item); const local = flowSharedInputLocalObservation(item);
if (local) return local; if (local) return local;
const cached = app.flowSharedInputValueCache?.[item?.id]; const cached = app.flowSharedInputValueCache?.[item?.id];
return cached?.signature === flowSharedInputValueSignature(item) ? cached : { hasValue:false, value:null }; return cached?.signature === flowSharedInputValueSignature(item) ? cached : { hasValue: false, value: null };
} }
function flowSharedInputCurrentText(item, observation = flowSharedInputObservation(item)) { function flowSharedInputCurrentText(item, observation = flowSharedInputObservation(item)) {
if (!observation?.hasValue) return '—'; if (!observation?.hasValue) return '—';
const value = observation.value; const value = observation.value;
if (['outdoor_temperature','device_temperature','zone_temperature'].includes(item.kind)) return fmtTemp(value); if (['outdoor_temperature', 'device_temperature', 'zone_temperature'].includes(item.kind)) return fmtTemp(value);
if (item.kind === 'ha_numeric') { if (item.kind === 'ha_numeric') {
const numeric = Number(value); const numeric = Number(value);
if (!Number.isFinite(numeric)) return '—'; if (!Number.isFinite(numeric)) return '—';
@@ -249,7 +249,7 @@ function renderFlowSharedInputCurrentValues() {
} }
async function loadFlowSharedInputHaValue(item) { async function loadFlowSharedInputHaValue(item) {
if (!item || !['ha_state','ha_numeric','ha_attribute','ha_available'].includes(item.kind)) return; if (!item || !['ha_state', 'ha_numeric', 'ha_attribute', 'ha_available'].includes(item.kind)) return;
const signature = flowSharedInputValueSignature(item), cached = app.flowSharedInputValueCache?.[item.id]; const signature = flowSharedInputValueSignature(item), cached = app.flowSharedInputValueCache?.[item.id];
if (cached?.signature === signature && Date.now() - Number(cached.fetchedAt || 0) < 15000) return; if (cached?.signature === signature && Date.now() - Number(cached.fetchedAt || 0) < 15000) return;
if (app.flowSharedInputValueRequests?.[item.id] === signature) return; if (app.flowSharedInputValueRequests?.[item.id] === signature) return;
@@ -257,7 +257,7 @@ async function loadFlowSharedInputHaValue(item) {
try { try {
const entityId = String(item.config?.entity_id || '').trim(); const entityId = String(item.config?.entity_id || '').trim();
if (!entityId) throw new Error('missing entity_id'); if (!entityId) throw new Error('missing entity_id');
const result = await api('/api/integrations/home-assistant/entity', { method:'POST', body:{ entity_id:entityId } }); const result = await api('/api/integrations/home-assistant/entity', { method: 'POST', body: { entity_id: entityId } });
let value = result.state, hasValue = true; let value = result.state, hasValue = true;
if (item.kind === 'ha_available') value = result.available === true; if (item.kind === 'ha_available') value = result.available === true;
else if (item.kind === 'ha_attribute') { else if (item.kind === 'ha_attribute') {
@@ -268,11 +268,11 @@ async function loadFlowSharedInputHaValue(item) {
hasValue = Number.isFinite(value); hasValue = Number.isFinite(value);
} }
app.flowSharedInputValueCache[item.id] = { app.flowSharedInputValueCache[item.id] = {
signature, fetchedAt:Date.now(), hasValue, value, signature, fetchedAt: Date.now(), hasValue, value,
unit:item.kind === 'ha_numeric' ? result.attributes?.unit_of_measurement : '', unit: item.kind === 'ha_numeric' ? result.attributes?.unit_of_measurement : '',
}; };
} catch (_) { } catch (_) {
app.flowSharedInputValueCache[item.id] = { signature, fetchedAt:Date.now(), hasValue:false, value:null }; app.flowSharedInputValueCache[item.id] = { signature, fetchedAt: Date.now(), hasValue: false, value: null };
} finally { } finally {
if (app.flowSharedInputValueRequests?.[item.id] === signature) delete app.flowSharedInputValueRequests[item.id]; if (app.flowSharedInputValueRequests?.[item.id] === signature) delete app.flowSharedInputValueRequests[item.id];
renderFlowSharedInputCurrentValues(); renderFlowSharedInputCurrentValues();
@@ -377,7 +377,7 @@ function setFlowZoom(value, { render = true } = {}) {
function fitFlowToView({ maxZoom = 1 } = {}) { function fitFlowToView({ maxZoom = 1 } = {}) {
const workspace = $('#flowWorkspace'); const workspace = $('#flowWorkspace');
const nodes = app.flowDraft?.nodes || []; const nodes = app.flowDraft?.nodes || [];
if (!workspace || !nodes.length) { setFlowZoom(1); if (workspace) workspace.scrollTo({ left:0, top:0, behavior:'smooth' }); return; } if (!workspace || !nodes.length) { setFlowZoom(1); if (workspace) workspace.scrollTo({ left: 0, top: 0, behavior: 'smooth' }); return; }
const width = 170, height = 96, pad = 56; const width = 170, height = 96, pad = 56;
const minX = Math.max(0, Math.min(...nodes.map(node => Number(node.x || 0))) - pad); const minX = Math.max(0, Math.min(...nodes.map(node => Number(node.x || 0))) - pad);
const minY = Math.max(0, Math.min(...nodes.map(node => Number(node.y || 0))) - pad); const minY = Math.max(0, Math.min(...nodes.map(node => Number(node.y || 0))) - pad);
@@ -387,7 +387,7 @@ function fitFlowToView({ maxZoom = 1 } = {}) {
const availableW = Math.max(220, workspace.clientWidth - 24), availableH = Math.max(180, workspace.clientHeight - 24); const availableW = Math.max(220, workspace.clientWidth - 24), availableH = Math.max(180, workspace.clientHeight - 24);
const zoom = clamp(Math.min(availableW / contentW, availableH / contentH, maxZoom), .45, 1.35); const zoom = clamp(Math.min(availableW / contentW, availableH / contentH, maxZoom), .45, 1.35);
setFlowZoom(zoom); setFlowZoom(zoom);
requestAnimationFrame(() => workspace.scrollTo({ left:Math.max(0, minX * app.flowZoom - 12), top:Math.max(0, minY * app.flowZoom - 12), behavior:'smooth' })); requestAnimationFrame(() => workspace.scrollTo({ left: Math.max(0, minX * app.flowZoom - 12), top: Math.max(0, minY * app.flowZoom - 12), behavior: 'smooth' }));
} }
function renderFlowBlockLibrary(filter = '') { function renderFlowBlockLibrary(filter = '') {
@@ -423,8 +423,8 @@ function renderFlowEditor() {
</article>`; </article>`;
}).join(''); }).join('');
$('#flowEmptyHint').hidden = draft.nodes.length > 0; $('#flowEmptyHint').hidden = draft.nodes.length > 0;
const selectionCount = $('#flowSelectionCount'); if (selectionCount) selectionCount.textContent = (app.flowSelectedNodeIds || []).length ? tr('flow.selectedCount', { count:(app.flowSelectedNodeIds || []).length }) : ''; const selectionCount = $('#flowSelectionCount'); if (selectionCount) selectionCount.textContent = (app.flowSelectedNodeIds || []).length ? tr('flow.selectedCount', { count: (app.flowSelectedNodeIds || []).length }) : '';
renderFlowNameMode(); renderFlowEnabledLabel(); setFlowZoom(app.flowZoom || 1, { render:false }); renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); renderFlowRuntimeInfo(); renderFlowSaveStatus(); refreshFlowSharedInputCurrentValues(); renderFlowNameMode(); renderFlowEnabledLabel(); setFlowZoom(app.flowZoom || 1, { render: false }); renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); renderFlowRuntimeInfo(); renderFlowSaveStatus(); refreshFlowSharedInputCurrentValues();
const status = $('#flowCompileStatus'); const status = $('#flowCompileStatus');
status.textContent = draft.draft ? tr('flow.draftStatus') : tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 }); status.textContent = draft.draft ? tr('flow.draftStatus') : tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 });
status.classList.toggle('flow-draft-badge', draft.draft === true); status.classList.toggle('flow-draft-badge', draft.draft === true);
@@ -447,19 +447,19 @@ function renderFlowEdges() {
function flowSelectOptions(items, selected, nameFn = item => item.name) { function flowSelectOptions(items, selected, nameFn = item => item.name) {
return items.map(item => `<option value="${esc(item.id)}" ${item.id === selected ? 'selected' : ''}>${esc(nameFn(item))}</option>`).join(''); return items.map(item => `<option value="${esc(item.id)}" ${item.id === selected ? 'selected' : ''}>${esc(nameFn(item))}</option>`).join('');
} }
function flowOperatorOptions(selected) { return [['lt','<'],['lte','≤'],['gt','>'],['gte','≥'],['eq','='],['neq','≠']].map(([v,l]) => `<option value="${v}" ${v === selected ? 'selected' : ''}>${l}</option>`).join(''); } function flowOperatorOptions(selected) { return [['lt', '<'], ['lte', '≤'], ['gt', '>'], ['gte', '≥'], ['eq', '='], ['neq', '≠']].map(([v, l]) => `<option value="${v}" ${v === selected ? 'selected' : ''}>${l}</option>`).join(''); }
function sharedFlowReferenceComparisonFields(item, config) { function sharedFlowReferenceComparisonFields(item, config) {
if (!item || !sharedFlowInputRequiresComparison(item.kind)) return ''; if (!item || !sharedFlowInputRequiresComparison(item.kind)) return '';
const defaults = sharedFlowReferenceComparisonDefaults(item); const defaults = sharedFlowReferenceComparisonDefaults(item);
const selected = config.operator || ''; const selected = config.operator || '';
const numeric = ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(item.kind); const numeric = ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(item.kind);
const fullOperators = numeric || item.kind === 'ha_attribute'; const fullOperators = numeric || item.kind === 'ha_attribute';
const operatorOptions = `${!selected ? `<option value="" selected disabled>${esc(tr('flow.selectOperator'))}</option>` : ''}${fullOperators ? flowOperatorOptions(selected) : [['eq','='],['neq','≠']].map(([value,label]) => `<option value="${value}" ${value === selected ? 'selected' : ''}>${label}</option>`).join('')}`; const operatorOptions = `${!selected ? `<option value="" selected disabled>${esc(tr('flow.selectOperator'))}</option>` : ''}${fullOperators ? flowOperatorOptions(selected) : [['eq', '='], ['neq', '≠']].map(([value, label]) => `<option value="${value}" ${value === selected ? 'selected' : ''}>${label}</option>`).join('')}`;
const value = config.value ?? defaults.value ?? ''; const value = config.value ?? defaults.value ?? '';
let valueField = `<input data-flow-config="value" value="${esc(value)}">`; let valueField = `<input data-flow-config="value" value="${esc(value)}">`;
if (numeric) valueField = `<input type="number" step="0.1" data-flow-config="value" value="${Number(value ?? 0)}">`; if (numeric) valueField = `<input type="number" step="0.1" data-flow-config="value" value="${Number(value ?? 0)}">`;
else if (item.kind === 'house_mode') valueField = `<select data-flow-config="value">${['cool','heat','off'].map(v => `<option value="${v}" ${String(value) === v ? 'selected' : ''}>${esc(v === 'off' ? tr('common.off') : tr(`mode.${v}`))}</option>`).join('')}</select>`; else if (item.kind === 'house_mode') valueField = `<select data-flow-config="value">${['cool', 'heat', 'off'].map(v => `<option value="${v}" ${String(value) === v ? 'selected' : ''}>${esc(v === 'off' ? tr('common.off') : tr(`mode.${v}`))}</option>`).join('')}</select>`;
return `<p class="field-note">${esc(tr('flow.sharedInputFlowComparisonHint'))}</p><div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator">${operatorOptions}</select></label><label><span>${esc(tr('flow.value'))}</span>${valueField}</label></div>`; return `<p class="field-note">${esc(tr('flow.sharedInputFlowComparisonHint'))}</p><div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator">${operatorOptions}</select></label><label><span>${esc(tr('flow.value'))}</span>${valueField}</label></div>`;
} }
@@ -469,7 +469,7 @@ function renderFlowInspector() {
if (!node) { host.classList.remove('is-expanded'); host.innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.selectBlock'))}</strong><span>${esc(tr('flow.selectBlockHint'))}</span></div>`; return; } if (!node) { host.classList.remove('is-expanded'); host.innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.selectBlock'))}</strong><span>${esc(tr('flow.selectBlockHint'))}</span></div>`; return; }
const c = node.config || {}, meta = FLOW_NODE_META[node.kind] || { title: node.kind }; const c = node.config || {}, meta = FLOW_NODE_META[node.kind] || { title: node.kind };
let fields = ''; let fields = '';
if (node.kind === 'weekday') fields = `<div class="flow-day-grid">${[1,2,3,4,5,6,7].map(day => `<label><input type="checkbox" data-flow-config="days" value="${day}" ${(c.days || []).includes(day) ? 'checked' : ''}><span>${esc(tr(`day.${day}`))}</span></label>`).join('')}</div>`; if (node.kind === 'weekday') fields = `<div class="flow-day-grid">${[1, 2, 3, 4, 5, 6, 7].map(day => `<label><input type="checkbox" data-flow-config="days" value="${day}" ${(c.days || []).includes(day) ? 'checked' : ''}><span>${esc(tr(`day.${day}`))}</span></label>`).join('')}</div>`;
else if (node.kind === 'time_range') fields = `<div class="two"><label><span>${esc(tr('common.from'))}</span><input type="time" data-flow-config="start" value="${esc(c.start || '06:00')}"></label><label><span>${esc(tr('common.to'))}</span><input type="time" data-flow-config="end" value="${esc(c.end || '08:00')}"></label></div>`; else if (node.kind === 'time_range') fields = `<div class="two"><label><span>${esc(tr('common.from'))}</span><input type="time" data-flow-config="start" value="${esc(c.start || '06:00')}"></label><label><span>${esc(tr('common.to'))}</span><input type="time" data-flow-config="end" value="${esc(c.end || '08:00')}"></label></div>`;
else if (node.kind === 'date_range') fields = `<div class="two"><label><span>${esc(tr('common.from'))}</span><input type="date" data-flow-config="start" value="${esc(c.start || '')}"></label><label><span>${esc(tr('common.to'))}</span><input type="date" data-flow-config="end" value="${esc(c.end || '')}"></label></div>`; else if (node.kind === 'date_range') fields = `<div class="two"><label><span>${esc(tr('common.from'))}</span><input type="date" data-flow-config="start" value="${esc(c.start || '')}"></label><label><span>${esc(tr('common.to'))}</span><input type="date" data-flow-config="end" value="${esc(c.end || '')}"></label></div>`;
else if (node.kind === 'cron_trigger') fields = `<label><span>CRON</span><input data-flow-config="expression" value="${esc(c.expression || '*/5 * * * *')}" placeholder="*/5 * * * *"></label><p class="field-note">${esc(tr('flow.cronHint'))}</p>`; else if (node.kind === 'cron_trigger') fields = `<label><span>CRON</span><input data-flow-config="expression" value="${esc(c.expression || '*/5 * * * *')}" placeholder="*/5 * * * *"></label><p class="field-note">${esc(tr('flow.cronHint'))}</p>`;
@@ -478,8 +478,8 @@ function renderFlowInspector() {
else if (node.kind === 'on_change') fields = `<label><span>${esc(tr('flow.changeMode'))}</span><select data-flow-config="mode"><option value="result" ${c.mode !== 'value' ? 'selected' : ''}>${esc(tr('flow.changeModeResult'))}</option><option value="value" ${c.mode === 'value' ? 'selected' : ''}>${esc(tr('flow.changeModeValue'))}</option></select></label><p class="field-note">${esc(tr('flow.onChangeHint'))}</p>`; else if (node.kind === 'on_change') fields = `<label><span>${esc(tr('flow.changeMode'))}</span><select data-flow-config="mode"><option value="result" ${c.mode !== 'value' ? 'selected' : ''}>${esc(tr('flow.changeModeResult'))}</option><option value="value" ${c.mode === 'value' ? 'selected' : ''}>${esc(tr('flow.changeModeValue'))}</option></select></label><p class="field-note">${esc(tr('flow.onChangeHint'))}</p>`;
else if (node.kind === 'rate_limit') fields = `<div class="two"><label><span>${esc(tr('flow.maxExecutions'))}</span><input type="number" min="1" max="1000" data-flow-config="max_count" value="${Number(c.max_count || 1)}"></label><label><span>${esc(tr('flow.periodSeconds'))}</span><input type="number" min="1" max="2678400" data-flow-config="period_seconds" value="${Number(c.period_seconds || 3600)}"></label></div><p class="field-note">${esc(tr('flow.rateLimitHint'))}</p>`; else if (node.kind === 'rate_limit') fields = `<div class="two"><label><span>${esc(tr('flow.maxExecutions'))}</span><input type="number" min="1" max="1000" data-flow-config="max_count" value="${Number(c.max_count || 1)}"></label><label><span>${esc(tr('flow.periodSeconds'))}</span><input type="number" min="1" max="2678400" data-flow-config="period_seconds" value="${Number(c.period_seconds || 3600)}"></label></div><p class="field-note">${esc(tr('flow.rateLimitHint'))}</p>`;
else if (node.kind === 'delay') fields = `<label><span>${esc(tr('flow.durationSeconds'))}</span><input type="number" min="1" max="604800" data-flow-config="seconds" value="${Number(c.seconds || 30)}"></label><p class="field-note">${esc(tr('flow.delayHint'))}</p>`; else if (node.kind === 'delay') fields = `<label><span>${esc(tr('flow.durationSeconds'))}</span><input type="number" min="1" max="604800" data-flow-config="seconds" value="${Number(c.seconds || 30)}"></label><p class="field-note">${esc(tr('flow.delayHint'))}</p>`;
else if (node.kind === 'rolling_stat') { const source=c.source || 'outdoor_temperature'; fields = `<label><span>${esc(tr('flow.source'))}</span><select data-flow-config="source">${[['outdoor_temperature',tr('flow.node.outdoorTemperature')],['device_temperature',tr('flow.node.deviceTemperature')],['zone_temperature',tr('flow.node.zoneTemperature')],['ha_numeric',tr('flow.node.haNumeric')]].map(([v,l])=>`<option value="${v}" ${source===v?'selected':''}>${esc(l)}</option>`).join('')}</select></label>${source==='device_temperature'?`<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices,c.device_id)}</select></label>`:''}${source==='zone_temperature'?`<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones,c.zone_id)}</select></label>`:''}${source==='ha_numeric'?`<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id||'')}" placeholder="sensor.temperature"></label>`:''}<div class="two"><label><span>${esc(tr('flow.statistic'))}</span><select data-flow-config="statistic"><option value="mean" ${c.statistic!=='median'?'selected':''}>${esc(tr('flow.mean'))}</option><option value="median" ${c.statistic==='median'?'selected':''}>${esc(tr('flow.median'))}</option></select></label><label><span>${esc(tr('flow.windowSeconds'))}</span><input type="number" min="10" max="604800" data-flow-config="window_seconds" value="${Number(c.window_seconds||300)}"></label></div>${flowComparisonFields(c,false)}`; } else if (node.kind === 'rolling_stat') { const source = c.source || 'outdoor_temperature'; fields = `<label><span>${esc(tr('flow.source'))}</span><select data-flow-config="source">${[['outdoor_temperature', tr('flow.node.outdoorTemperature')], ['device_temperature', tr('flow.node.deviceTemperature')], ['zone_temperature', tr('flow.node.zoneTemperature')], ['ha_numeric', tr('flow.node.haNumeric')]].map(([v, l]) => `<option value="${v}" ${source === v ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select></label>${source === 'device_temperature' ? `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>` : ''}${source === 'zone_temperature' ? `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label>` : ''}${source === 'ha_numeric' ? `<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="sensor.temperature"></label>` : ''}<div class="two"><label><span>${esc(tr('flow.statistic'))}</span><select data-flow-config="statistic"><option value="mean" ${c.statistic !== 'median' ? 'selected' : ''}>${esc(tr('flow.mean'))}</option><option value="median" ${c.statistic === 'median' ? 'selected' : ''}>${esc(tr('flow.median'))}</option></select></label><label><span>${esc(tr('flow.windowSeconds'))}</span><input type="number" min="10" max="604800" data-flow-config="window_seconds" value="${Number(c.window_seconds || 300)}"></label></div>${flowComparisonFields(c, false)}`; }
else if (node.kind === 'oscillates') { const source=c.source || 'outdoor_temperature'; fields = `<label><span>${esc(tr('flow.source'))}</span><select data-flow-config="source">${[['outdoor_temperature',tr('flow.node.outdoorTemperature')],['device_temperature',tr('flow.node.deviceTemperature')],['zone_temperature',tr('flow.node.zoneTemperature')],['ha_numeric',tr('flow.node.haNumeric')]].map(([v,l])=>`<option value="${v}" ${source===v?'selected':''}>${esc(l)}</option>`).join('')}</select></label>${source==='device_temperature'?`<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices,c.device_id)}</select></label>`:''}${source==='zone_temperature'?`<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones,c.zone_id)}</select></label>`:''}${source==='ha_numeric'?`<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id||'')}" placeholder="sensor.temperature"></label>`:''}<div class="two"><label><span>${esc(tr('flow.windowSeconds'))}</span><input type="number" min="10" max="604800" data-flow-config="window_seconds" value="${Number(c.window_seconds||300)}"></label><label><span>${esc(tr('flow.minSpan'))}</span><input type="number" min="0.001" step="0.1" data-flow-config="min_span" value="${Number(c.min_span??1)}"></label></div><label><span>${esc(tr('flow.minDirectionChanges'))}</span><input type="number" min="1" max="1000" data-flow-config="min_direction_changes" value="${Number(c.min_direction_changes||2)}"></label><p class="field-note">${esc(tr('flow.oscillatesHint'))}</p>`; } else if (node.kind === 'oscillates') { const source = c.source || 'outdoor_temperature'; fields = `<label><span>${esc(tr('flow.source'))}</span><select data-flow-config="source">${[['outdoor_temperature', tr('flow.node.outdoorTemperature')], ['device_temperature', tr('flow.node.deviceTemperature')], ['zone_temperature', tr('flow.node.zoneTemperature')], ['ha_numeric', tr('flow.node.haNumeric')]].map(([v, l]) => `<option value="${v}" ${source === v ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select></label>${source === 'device_temperature' ? `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>` : ''}${source === 'zone_temperature' ? `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label>` : ''}${source === 'ha_numeric' ? `<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="sensor.temperature"></label>` : ''}<div class="two"><label><span>${esc(tr('flow.windowSeconds'))}</span><input type="number" min="10" max="604800" data-flow-config="window_seconds" value="${Number(c.window_seconds || 300)}"></label><label><span>${esc(tr('flow.minSpan'))}</span><input type="number" min="0.001" step="0.1" data-flow-config="min_span" value="${Number(c.min_span ?? 1)}"></label></div><label><span>${esc(tr('flow.minDirectionChanges'))}</span><input type="number" min="1" max="1000" data-flow-config="min_direction_changes" value="${Number(c.min_direction_changes || 2)}"></label><p class="field-note">${esc(tr('flow.oscillatesHint'))}</p>`; }
else if (node.kind === 'outdoor_temperature') fields = flowComparisonFields(c); else if (node.kind === 'outdoor_temperature') fields = flowComparisonFields(c);
else if (node.kind === 'device_temperature') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>${flowComparisonFields(c)}`; else if (node.kind === 'device_temperature') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>${flowComparisonFields(c)}`;
else if (node.kind === 'zone_temperature') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label>${flowComparisonFields(c)}`; else if (node.kind === 'zone_temperature') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label>${flowComparisonFields(c)}`;
@@ -487,9 +487,9 @@ function renderFlowInspector() {
else if (node.kind === 'ha_numeric') fields = `<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="sensor.outdoor_temperature"></label>${flowComparisonFields(c, false)}`; else if (node.kind === 'ha_numeric') fields = `<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="sensor.outdoor_temperature"></label>${flowComparisonFields(c, false)}`;
else if (node.kind === 'ha_attribute') fields = `<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="climate.living_room"></label><label><span>${esc(tr('flow.attribute'))}</span><input data-flow-config="attribute" value="${esc(c.attribute || '')}" placeholder="hvac_action"></label>${flowTextComparisonFields(c, true)}`; else if (node.kind === 'ha_attribute') fields = `<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="climate.living_room"></label><label><span>${esc(tr('flow.attribute'))}</span><input data-flow-config="attribute" value="${esc(c.attribute || '')}" placeholder="hvac_action"></label>${flowTextComparisonFields(c, true)}`;
else if (node.kind === 'ha_available') fields = `<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="binary_sensor.window"></label><p class="field-note">${esc(tr('flow.haAvailableHint'))}</p>`; else if (node.kind === 'ha_available') fields = `<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="binary_sensor.window"></label><p class="field-note">${esc(tr('flow.haAvailableHint'))}</p>`;
else if (node.kind === 'house_mode') fields = `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator"><option value="eq" ${c.operator !== 'neq' ? 'selected' : ''}>=</option><option value="neq" ${c.operator === 'neq' ? 'selected' : ''}>≠</option></select></label><label><span>${esc(tr('flow.houseMode'))}</span><select data-flow-config="value">${['cool','heat','off'].map(v => `<option value="${v}" ${c.value === v ? 'selected' : ''}>${esc(v === 'off' ? tr('common.off') : tr(`mode.${v}`))}</option>`).join('')}</select></label></div>`; else if (node.kind === 'house_mode') fields = `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator"><option value="eq" ${c.operator !== 'neq' ? 'selected' : ''}>=</option><option value="neq" ${c.operator === 'neq' ? 'selected' : ''}>≠</option></select></label><label><span>${esc(tr('flow.houseMode'))}</span><select data-flow-config="value">${['cool', 'heat', 'off'].map(v => `<option value="${v}" ${c.value === v ? 'selected' : ''}>${esc(v === 'off' ? tr('common.off') : tr(`mode.${v}`))}</option>`).join('')}</select></label></div>`;
else if (node.kind === 'device_state') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-flow-config="field">${['enabled','online','power','mode','fan_speed','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>${flowTextComparisonFields(c)}`; else if (node.kind === 'device_state') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-flow-config="field">${['enabled', 'online', 'power', 'mode', 'fan_speed', 'swing_vertical', 'swing_horizontal', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>${flowTextComparisonFields(c)}`;
else if (node.kind === 'zone_state') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-flow-config="field">${['enabled','mode','active_preset','demand','control_owner','device_manual_override','local_thermostat_power'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>${flowTextComparisonFields(c)}`; else if (node.kind === 'zone_state') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-flow-config="field">${['enabled', 'mode', 'active_preset', 'demand', 'control_owner', 'device_manual_override', 'local_thermostat_power'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>${flowTextComparisonFields(c)}`;
else if (node.kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-flow-config="group_id">${flowSelectOptions(app.groups, c.group_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-flow-config="field"><option value="power_enabled" selected>power_enabled</option></select></label>${flowTextComparisonFields(c)}`; else if (node.kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-flow-config="group_id">${flowSelectOptions(app.groups, c.group_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-flow-config="field"><option value="power_enabled" selected>power_enabled</option></select></label>${flowTextComparisonFields(c)}`;
else if (node.kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`; else if (node.kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`;
else if (node.kind === 'constant') fields = `<label><span>${esc(tr('flow.value'))}</span><select data-flow-config="value"><option value="true" ${c.value !== false ? 'selected' : ''}>${esc(tr('common.yes'))}</option><option value="false" ${c.value === false ? 'selected' : ''}>${esc(tr('common.no'))}</option></select></label>`; else if (node.kind === 'constant') fields = `<label><span>${esc(tr('flow.value'))}</span><select data-flow-config="value"><option value="true" ${c.value !== false ? 'selected' : ''}>${esc(tr('common.yes'))}</option><option value="false" ${c.value === false ? 'selected' : ''}>${esc(tr('common.no'))}</option></select></label>`;
@@ -502,26 +502,26 @@ function renderFlowInspector() {
else if (node.kind === 'logic_and') fields = `<p class="field-note">${esc(tr('flow.andHint'))}</p>`; else if (node.kind === 'logic_and') fields = `<p class="field-note">${esc(tr('flow.andHint'))}</p>`;
else if (node.kind === 'logic_or') fields = `<p class="field-note">${esc(tr('flow.orHint'))}</p>`; else if (node.kind === 'logic_or') fields = `<p class="field-note">${esc(tr('flow.orHint'))}</p>`;
else if (node.kind === 'logic_not') fields = `<p class="field-note">${esc(tr('flow.notHint'))}</p>`; else if (node.kind === 'logic_not') fields = `<p class="field-note">${esc(tr('flow.notHint'))}</p>`;
else if (node.kind === 'zone_thermostat') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label><div class="two"><label><span>${esc(tr('schedules.profile'))}</span><select data-flow-config="preset">${['auto','comfort','sleep','away','custom'].map(v => `<option value="${v}" ${c.preset === v ? 'selected' : ''}>${esc(zonePresetLabel(v))}</option>`).join('')}</select></label><label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.5" data-flow-config="setpoint" value="${Number(c.setpoint ?? 21)}"></label></div><div class="two"><label><span>${esc(tr('common.mode'))}</span><select data-flow-config="mode"><option value="auto" ${c.mode === 'auto' ? 'selected' : ''}>Auto</option><option value="heat" ${c.mode === 'heat' ? 'selected' : ''}>${esc(tr('mode.heat'))}</option><option value="cool" ${c.mode === 'cool' ? 'selected' : ''}>${esc(tr('mode.cool'))}</option></select></label><label><span>${esc(tr('common.power'))}</span><select data-flow-config="power"><option value="" ${c.power == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c.power === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c.power === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label></div><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`; else if (node.kind === 'zone_thermostat') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label><div class="two"><label><span>${esc(tr('schedules.profile'))}</span><select data-flow-config="preset">${['auto', 'comfort', 'sleep', 'away', 'custom'].map(v => `<option value="${v}" ${c.preset === v ? 'selected' : ''}>${esc(zonePresetLabel(v))}</option>`).join('')}</select></label><label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.5" data-flow-config="setpoint" value="${Number(c.setpoint ?? 21)}"></label></div><div class="two"><label><span>${esc(tr('common.mode'))}</span><select data-flow-config="mode"><option value="auto" ${c.mode === 'auto' ? 'selected' : ''}>Auto</option><option value="heat" ${c.mode === 'heat' ? 'selected' : ''}>${esc(tr('mode.heat'))}</option><option value="cool" ${c.mode === 'cool' ? 'selected' : ''}>${esc(tr('mode.cool'))}</option></select></label><label><span>${esc(tr('common.power'))}</span><select data-flow-config="power"><option value="" ${c.power == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c.power === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c.power === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label></div><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
else if (node.kind === 'device_action') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>${flowActionFields(c, false)}`; else if (node.kind === 'device_action') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>${flowActionFields(c, false)}`;
else if (node.kind === 'group_action') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-flow-config="group_id">${flowSelectOptions(app.groups, c.group_id)}</select></label>${flowActionFields(c, true)}`; else if (node.kind === 'group_action') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-flow-config="group_id">${flowSelectOptions(app.groups, c.group_id)}</select></label>${flowActionFields(c, true)}`;
else if (node.kind === 'ha_service_action') fields = `<div class="two"><label><span>domain</span><input data-flow-config="domain" value="${esc(c.domain || 'switch')}" placeholder="switch"></label><label><span>service</span><input data-flow-config="service" value="${esc(c.service || 'turn_on')}" placeholder="turn_on"></label></div><p class="field-note">${esc(tr('flow.serviceExample'))}</p><label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="switch.gas"></label><label><span>${esc(tr('flow.serviceData'))}</span><textarea rows="5" data-flow-config="data_json">${esc(JSON.stringify(c.data || {}, null, 2))}</textarea></label><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`; else if (node.kind === 'ha_service_action') fields = `<div class="two"><label><span>domain</span><input data-flow-config="domain" value="${esc(c.domain || 'switch')}" placeholder="switch"></label><label><span>service</span><input data-flow-config="service" value="${esc(c.service || 'turn_on')}" placeholder="turn_on"></label></div><p class="field-note">${esc(tr('flow.serviceExample'))}</p><label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="switch.gas"></label><label><span>${esc(tr('flow.serviceData'))}</span><textarea rows="5" data-flow-config="data_json">${esc(JSON.stringify(c.data || {}, null, 2))}</textarea></label><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
host.innerHTML = `<div class="flow-inspector-head"><div><span class="eyebrow">${esc(tr('flow.blockSettings'))}</span><h3>${esc(flowNodeTitle(meta))}</h3></div><div class="flow-inspector-head-actions"><button type="button" class="secondary flow-inspector-expand" data-action="flow-toggle-inspector" aria-label="${esc(tr('flow.expandSettings'))}">↕</button><button type="button" class="secondary flow-inspector-close" data-action="flow-clear-selection" aria-label="${esc(tr('flow.clearSelection'))}">×</button><button type="button" class="danger flow-inspector-delete" data-flow-remove="${esc(node.id)}">${esc(tr('actions.delete'))}</button></div></div>${fields}<div class="flow-inspector-meta"><small>ID</small><code>${esc(node.id)}</code></div>`; host.innerHTML = `<div class="flow-inspector-head"><div><span class="eyebrow">${esc(tr('flow.blockSettings'))}</span><h3>${esc(flowNodeTitle(meta))}</h3></div><div class="flow-inspector-head-actions"><button type="button" class="secondary flow-inspector-expand" data-action="flow-toggle-inspector" aria-label="${esc(tr('flow.expandSettings'))}">↕</button><button type="button" class="secondary flow-inspector-close" data-action="flow-clear-selection" aria-label="${esc(tr('flow.clearSelection'))}">×</button><button type="button" class="danger flow-inspector-delete" data-flow-remove="${esc(node.id)}">${esc(tr('actions.delete'))}</button></div></div>${fields}<div class="flow-inspector-meta"><small>ID</small><code>${esc(node.id)}</code></div>`;
} }
function flowTextComparisonFields(c, numeric = false) { const options = numeric ? flowOperatorOptions(c.operator || 'eq') : [['eq','='],['neq','≠']].map(([v,l]) => `<option value="${v}" ${v === (c.operator || 'eq') ? 'selected' : ''}>${l}</option>`).join(''); return `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator">${options}</select></label><label><span>${esc(tr('flow.value'))}</span><input data-flow-config="value" value="${esc(c.value ?? '')}"></label></div>`; } function flowTextComparisonFields(c, numeric = false) { const options = numeric ? flowOperatorOptions(c.operator || 'eq') : [['eq', '='], ['neq', '≠']].map(([v, l]) => `<option value="${v}" ${v === (c.operator || 'eq') ? 'selected' : ''}>${l}</option>`).join(''); return `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator">${options}</select></label><label><span>${esc(tr('flow.value'))}</span><input data-flow-config="value" value="${esc(c.value ?? '')}"></label></div>`; }
function flowComparisonFields(c, temperature = true) { return `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator">${flowOperatorOptions(c.operator || 'lt')}</select></label><label><span>${esc(temperature ? tr('automations.thresholdC') : tr('flow.value'))}</span><input type="number" step="0.1" data-flow-config="value" value="${Number(c.value ?? 0)}"></label></div>`; } function flowComparisonFields(c, temperature = true) { return `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator">${flowOperatorOptions(c.operator || 'lt')}</select></label><label><span>${esc(temperature ? tr('automations.thresholdC') : tr('flow.value'))}</span><input type="number" step="0.1" data-flow-config="value" value="${Number(c.value ?? 0)}"></label></div>`; }
function flowOptionalBoolField(c, key) { function flowOptionalBoolField(c, key) {
return `<label><span>${esc(key)}</span><select data-flow-config="${esc(key)}"><option value="" ${c[key] == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c[key] === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c[key] === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label>`; return `<label><span>${esc(key)}</span><select data-flow-config="${esc(key)}"><option value="" ${c[key] == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c[key] === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c[key] === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label>`;
} }
function flowActionFields(c, group) { function flowActionFields(c, group) {
const modes = group ? ['auto','house','heat','cool'] : ['auto','cool','dry','fan','heat']; const modes = group ? ['auto', 'house', 'heat', 'cool'] : ['auto', 'cool', 'dry', 'fan', 'heat'];
const modeOptions = modes.map(v => `<option value="${v}" ${c.mode === v ? 'selected' : ''}>${esc(v === 'auto' ? 'Auto' : v === 'house' ? tr('flow.houseMode') : (tr(`mode.${v}`) || v))}</option>`).join(''); const modeOptions = modes.map(v => `<option value="${v}" ${c.mode === v ? 'selected' : ''}>${esc(v === 'auto' ? 'Auto' : v === 'house' ? tr('flow.houseMode') : (tr(`mode.${v}`) || v))}</option>`).join('');
const base = `<div class="two"><label><span>${esc(tr('common.power'))}</span><select data-flow-config="power"><option value="" ${c.power == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c.power === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c.power === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label><label><span>${esc(tr('common.mode'))}</span><select data-flow-config="mode"><option value="">${esc(tr('actions.noChange'))}</option>${modeOptions}</select></label></div>`; const base = `<div class="two"><label><span>${esc(tr('common.power'))}</span><select data-flow-config="power"><option value="" ${c.power == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c.power === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c.power === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label><label><span>${esc(tr('common.mode'))}</span><select data-flow-config="mode"><option value="">${esc(tr('actions.noChange'))}</option>${modeOptions}</select></label></div>`;
const target = group const target = group
? `<label><span>${esc(tr('groups.profile'))}</span><select data-flow-config="preset"><option value="">${esc(tr('actions.noChange'))}</option>${['auto','comfort','sleep','away','custom'].map(v => `<option value="${v}" ${c.preset === v ? 'selected' : ''}>${esc(zonePresetLabel(v))}</option>`).join('')}</select></label><label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.5" data-flow-config="setpoint" value="${c.setpoint ?? 21}"></label>` ? `<label><span>${esc(tr('groups.profile'))}</span><select data-flow-config="preset"><option value="">${esc(tr('actions.noChange'))}</option>${['auto', 'comfort', 'sleep', 'away', 'custom'].map(v => `<option value="${v}" ${c.preset === v ? 'selected' : ''}>${esc(zonePresetLabel(v))}</option>`).join('')}</select></label><label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.5" data-flow-config="setpoint" value="${c.setpoint ?? 21}"></label>`
: `<label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.5" data-flow-config="target_temperature" value="${c.target_temperature ?? ''}"></label>`; : `<label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.5" data-flow-config="target_temperature" value="${c.target_temperature ?? ''}"></label>`;
const deviceOptions = group ? '' : `<details class="flow-device-options"><summary>${esc(tr('flow.deviceOptions'))}</summary><div class="two"><label><span>fan_speed</span><select data-flow-config="fan_speed"><option value="" ${c.fan_speed == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option>${[0,1,2,3,4,5].map(v => `<option value="${v}" ${Number(c.fan_speed) === v ? 'selected' : ''}>${v}</option>`).join('')}</select></label>${flowOptionalBoolField(c,'swing_vertical')}${flowOptionalBoolField(c,'swing_horizontal')}${flowOptionalBoolField(c,'quiet')}${flowOptionalBoolField(c,'turbo')}${flowOptionalBoolField(c,'light')}${flowOptionalBoolField(c,'air')}${flowOptionalBoolField(c,'xfan')}${flowOptionalBoolField(c,'health')}${flowOptionalBoolField(c,'sleep')}</div><p class="field-note">${esc(tr('flow.deviceOptionsHint'))}</p></details>`; const deviceOptions = group ? '' : `<details class="flow-device-options"><summary>${esc(tr('flow.deviceOptions'))}</summary><div class="two"><label><span>fan_speed</span><select data-flow-config="fan_speed"><option value="" ${c.fan_speed == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option>${[0, 1, 2, 3, 4, 5].map(v => `<option value="${v}" ${Number(c.fan_speed) === v ? 'selected' : ''}>${v}</option>`).join('')}</select></label>${flowOptionalBoolField(c, 'swing_vertical')}${flowOptionalBoolField(c, 'swing_horizontal')}${flowOptionalBoolField(c, 'quiet')}${flowOptionalBoolField(c, 'turbo')}${flowOptionalBoolField(c, 'light')}${flowOptionalBoolField(c, 'air')}${flowOptionalBoolField(c, 'xfan')}${flowOptionalBoolField(c, 'health')}${flowOptionalBoolField(c, 'sleep')}</div><p class="field-note">${esc(tr('flow.deviceOptionsHint'))}</p></details>`;
return `${base}${target}${deviceOptions}<label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`; return `${base}${target}${deviceOptions}<label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
} }
@@ -546,7 +546,7 @@ function flowExpressionSummary(actionId) {
function renderFlowInterpretation() { function renderFlowInterpretation() {
const target = $('#flowInterpretation'); if (!target || !app.flowDraft) return; const target = $('#flowInterpretation'); if (!target || !app.flowDraft) return;
const actions = app.flowDraft.nodes.filter(node => ['action','haaction'].includes(FLOW_NODE_META[node.kind]?.category)); const actions = app.flowDraft.nodes.filter(node => ['action', 'haaction'].includes(FLOW_NODE_META[node.kind]?.category));
if (!actions.length) { target.textContent = tr('flow.noActionsYet'); return; } if (!actions.length) { target.textContent = tr('flow.noActionsYet'); return; }
target.textContent = actions.map(action => `${flowExpressionSummary(action.id)}${flowNodeTitle(FLOW_NODE_META[action.kind])}: ${flowNodeSummary(action)}`).join(' · '); target.textContent = actions.map(action => `${flowExpressionSummary(action.id)}${flowNodeTitle(FLOW_NODE_META[action.kind])}: ${flowNodeSummary(action)}`).join(' · ');
} }
@@ -570,7 +570,7 @@ function renderFlowRuntimeInfo() {
const container = $('#flowRuntimeInfo'); const container = $('#flowRuntimeInfo');
if (!target || !app.flowDraft) return; if (!target || !app.flowDraft) return;
const seconds = Math.max(2, Number(app.settings?.zone_interval_seconds || 5)); const seconds = Math.max(2, Number(app.settings?.zone_interval_seconds || 5));
const actions = app.flowDraft.nodes.filter(node => ['action','haaction'].includes(FLOW_NODE_META[node.kind]?.category)); const actions = app.flowDraft.nodes.filter(node => ['action', 'haaction'].includes(FLOW_NODE_META[node.kind]?.category));
if (!actions.length) { if (!actions.length) {
target.textContent = tr('flow.runtimeNoActions', { seconds }); target.textContent = tr('flow.runtimeNoActions', { seconds });
if (container) container.title = tr('flow.runtimeHint'); if (container) container.title = tr('flow.runtimeHint');
@@ -610,7 +610,7 @@ function addFlowNode(kind) {
const viewportY = workspace ? (workspace.scrollTop / (app.flowZoom || 1)) + 54 : 70; const viewportY = workspace ? (workspace.scrollTop / (app.flowZoom || 1)) + 54 : 70;
const node = { id: newFlowId('node'), kind, x: Math.max(36, viewportX + (count % 3) * 24), y: Math.max(36, viewportY + (count % 3) * 24), config: flowDefaultConfig(kind) }; const node = { id: newFlowId('node'), kind, x: Math.max(36, viewportX + (count % 3) * 24), y: Math.max(36, viewportY + (count % 3) * 24), config: flowDefaultConfig(kind) };
app.flowDraft.nodes.push(node); app.flowSelectedNodeId = node.id; app.flowSelectedNodeIds = [node.id]; app.flowDirty = true; renderFlowEditor(); app.flowDraft.nodes.push(node); app.flowSelectedNodeId = node.id; app.flowSelectedNodeIds = [node.id]; app.flowDirty = true; renderFlowEditor();
requestAnimationFrame(() => $(`[data-flow-node="${CSS.escape(node.id)}"]`)?.scrollIntoView({ block:'center', inline:'center', behavior:'smooth' })); requestAnimationFrame(() => $(`[data-flow-node="${CSS.escape(node.id)}"]`)?.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' }));
} }
function removeFlowNode(id) { function removeFlowNode(id) {
if (!app.flowDraft) return; if (!app.flowDraft) return;
@@ -668,7 +668,7 @@ async function importFlowFile(file) {
finally { const input = $('#flowImportFile'); if (input) input.value = ''; } finally { const input = $('#flowImportFile'); if (input) input.value = ''; }
} }
const FLOW_PRESET_CATEGORY_ORDER = ['comfort','energy','safety','night','reliability','home_assistant','advanced']; const FLOW_PRESET_CATEGORY_ORDER = ['comfort', 'energy', 'safety', 'night', 'reliability', 'home_assistant', 'advanced'];
const FLOW_PRESET_FAVORITES_KEY = 'gree_controller_flow_preset_favorites'; const FLOW_PRESET_FAVORITES_KEY = 'gree_controller_flow_preset_favorites';
const FLOW_PRESET_RECENT_KEY = 'gree_controller_flow_preset_recent'; const FLOW_PRESET_RECENT_KEY = 'gree_controller_flow_preset_recent';
let flowPresetLoadPromise = null; let flowPresetLoadPromise = null;
@@ -684,7 +684,7 @@ function flowPresetStoredIds(key) {
} }
function flowPresetFavoriteIds() { return new Set(flowPresetStoredIds(FLOW_PRESET_FAVORITES_KEY)); } function flowPresetFavoriteIds() { return new Set(flowPresetStoredIds(FLOW_PRESET_FAVORITES_KEY)); }
function flowPresetRecentIds() { return flowPresetStoredIds(FLOW_PRESET_RECENT_KEY); } function flowPresetRecentIds() { return flowPresetStoredIds(FLOW_PRESET_RECENT_KEY); }
function saveFlowPresetIds(key, ids) { try { localStorage.setItem(key, JSON.stringify(ids)); } catch (_) {} } function saveFlowPresetIds(key, ids) { try { localStorage.setItem(key, JSON.stringify(ids)); } catch (_) { } }
function toggleFlowPresetFavorite(id) { function toggleFlowPresetFavorite(id) {
const ids = flowPresetFavoriteIds(); const ids = flowPresetFavoriteIds();
if (ids.has(id)) ids.delete(id); else ids.add(id); if (ids.has(id)) ids.delete(id); else ids.add(id);
@@ -757,7 +757,7 @@ function materializeFlowPreset(preset) {
zone_id: zone1, zone_id: zone1,
preset: node.config.preset || 'comfort', preset: node.config.preset || 'comfort',
setpoint: Number(node.config.setpoint ?? 21), setpoint: Number(node.config.setpoint ?? 21),
mode: ['heat','cool'].includes(node.config.mode) ? node.config.mode : 'auto', mode: ['heat', 'cool'].includes(node.config.mode) ? node.config.mode : 'auto',
cooldown_seconds: Number(node.config.cooldown_seconds || 60), cooldown_seconds: Number(node.config.cooldown_seconds || 60),
power: node.config.power ?? null, power: node.config.power ?? null,
}; };
@@ -767,13 +767,13 @@ function materializeFlowPreset(preset) {
}); });
const edges = sourceEdges const edges = sourceEdges
.filter(edge => !skipped.has(edge.from) && !skipped.has(edge.to) && idMap.has(edge.from) && idMap.has(edge.to)) .filter(edge => !skipped.has(edge.from) && !skipped.has(edge.to) && idMap.has(edge.from) && idMap.has(edge.to))
.map(edge => ({ id:newFlowId('edge'), from:idMap.get(edge.from), to:idMap.get(edge.to) })); .map(edge => ({ id: newFlowId('edge'), from: idMap.get(edge.from), to: idMap.get(edge.to) }));
return { nodes, edges }; return { nodes, edges };
} }
function flowPresetRequirements(preset) { function flowPresetRequirements(preset) {
const nodes = preset?.flow?.nodes || []; const nodes = preset?.flow?.nodes || [];
const placeholders = { zone:new Set(), device:new Set(), group:new Set(), shared:new Set() }; const placeholders = { zone: new Set(), device: new Set(), group: new Set(), shared: new Set() };
const visit = value => { const visit = value => {
if (typeof value === 'string') { if (typeof value === 'string') {
const match = /^\$(zone|device|group|shared)(\d+)$/.exec(value); const match = /^\$(zone|device|group|shared)(\d+)$/.exec(value);
@@ -784,14 +784,14 @@ function flowPresetRequirements(preset) {
else if (value && typeof value === 'object') Object.values(value).forEach(visit); else if (value && typeof value === 'object') Object.values(value).forEach(visit);
}; };
nodes.forEach(node => visit(node.config || {})); nodes.forEach(node => visit(node.config || {}));
const hasHa = nodes.some(node => ['ha_state','ha_numeric','ha_attribute','ha_available','ha_service_action'].includes(node.kind)); const hasHa = nodes.some(node => ['ha_state', 'ha_numeric', 'ha_attribute', 'ha_available', 'ha_service_action'].includes(node.kind));
const haEntities = [...new Set(nodes.map(node => node.config?.entity_id).filter(value => typeof value === 'string' && value && !value.startsWith('$')))]; const haEntities = [...new Set(nodes.map(node => node.config?.entity_id).filter(value => typeof value === 'string' && value && !value.startsWith('$')))];
const requirements = []; const requirements = [];
const missing = []; const missing = [];
const addCount = (kind, count, available, key) => { const addCount = (kind, count, available, key) => {
if (!count) return; if (!count) return;
const label = tr(key, { count }); const label = tr(key, { count });
requirements.push({ label, ok:available >= count }); requirements.push({ label, ok: available >= count });
if (available < count) missing.push(label); if (available < count) missing.push(label);
}; };
addCount('zone', placeholders.zone.size, (app.zones || []).length, 'flow.templateRequiresZone'); addCount('zone', placeholders.zone.size, (app.zones || []).length, 'flow.templateRequiresZone');
@@ -800,10 +800,10 @@ function flowPresetRequirements(preset) {
addCount('shared', placeholders.shared.size, (app.flowSharedInputs || []).length, 'flow.templateRequiresShared'); addCount('shared', placeholders.shared.size, (app.flowSharedInputs || []).length, 'flow.templateRequiresShared');
if (hasHa) { if (hasHa) {
const ready = Boolean(app.settings?.home_assistant?.url && app.settings?.home_assistant?.token_configured); const ready = Boolean(app.settings?.home_assistant?.url && app.settings?.home_assistant?.token_configured);
requirements.push({ label:tr('flow.templateRequiresHa'), ok:ready }); requirements.push({ label: tr('flow.templateRequiresHa'), ok: ready });
if (!ready) missing.push(tr('flow.templateRequiresHa')); if (!ready) missing.push(tr('flow.templateRequiresHa'));
} }
if (haEntities.length) requirements.push({ label:`${tr('flow.templateRequiresEntity')}: ${haEntities.join(', ')}`, ok:true, info:true }); if (haEntities.length) requirements.push({ label: `${tr('flow.templateRequiresEntity')}: ${haEntities.join(', ')}`, ok: true, info: true });
return { requirements, missing }; return { requirements, missing };
} }
@@ -814,14 +814,14 @@ function flowPresetPreviewGraph(preset) {
const xs = nodes.map(node => Number(node.x || 0)), ys = nodes.map(node => Number(node.y || 0)); const xs = nodes.map(node => Number(node.x || 0)), ys = nodes.map(node => Number(node.y || 0));
const minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys); const minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys);
const spanX = Math.max(1, maxX - minX), spanY = Math.max(1, maxY - minY); const spanX = Math.max(1, maxX - minX), spanY = Math.max(1, maxY - minY);
const point = node => ({ x:8 + ((Number(node.x || 0) - minX) / spanX) * 84, y:12 + ((Number(node.y || 0) - minY) / spanY) * 76 }); const point = node => ({ x: 8 + ((Number(node.x || 0) - minX) / spanX) * 84, y: 12 + ((Number(node.y || 0) - minY) / spanY) * 76 });
const lines = edges.map(edge => { const lines = edges.map(edge => {
const from = byId.get(edge.from), to = byId.get(edge.to); if (!from || !to) return ''; const from = byId.get(edge.from), to = byId.get(edge.to); if (!from || !to) return '';
const a = point(from), b = point(to); const a = point(from), b = point(to);
return `<line x1="${a.x.toFixed(2)}" y1="${a.y.toFixed(2)}" x2="${b.x.toFixed(2)}" y2="${b.y.toFixed(2)}"></line>`; return `<line x1="${a.x.toFixed(2)}" y1="${a.y.toFixed(2)}" x2="${b.x.toFixed(2)}" y2="${b.y.toFixed(2)}"></line>`;
}).join(''); }).join('');
const blocks = nodes.map(node => { const blocks = nodes.map(node => {
const p = point(node), meta = FLOW_NODE_META[node.kind] || { title:node.kind, category:'logic' }; const p = point(node), meta = FLOW_NODE_META[node.kind] || { title: node.kind, category: 'logic' };
return `<div class="flow-template-preview-node flow-template-preview-node-${esc(meta.category)}" style="left:${p.x.toFixed(2)}%;top:${p.y.toFixed(2)}%" title="${esc(flowNodeTitle(meta))}">${esc(flowNodeTitle(meta))}</div>`; return `<div class="flow-template-preview-node flow-template-preview-node-${esc(meta.category)}" style="left:${p.x.toFixed(2)}%;top:${p.y.toFixed(2)}%" title="${esc(flowNodeTitle(meta))}">${esc(flowNodeTitle(meta))}</div>`;
}).join(''); }).join('');
return `<div class="flow-template-preview-canvas"><svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${lines}</svg>${blocks}</div>`; return `<div class="flow-template-preview-canvas"><svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${lines}</svg>${blocks}</div>`;
@@ -836,13 +836,13 @@ function renderFlowPresetPreview(preset) {
const req = flowPresetRequirements(preset); const req = flowPresetRequirements(preset);
const favorite = flowPresetFavoriteIds().has(preset.id); const favorite = flowPresetFavoriteIds().has(preset.id);
const reqMarkup = req.requirements.length const reqMarkup = req.requirements.length
? req.requirements.map(item => `<span class="flow-template-requirement ${item.ok ? 'ok' : 'missing'} ${item.info ? 'info' : ''}">${item.ok ? '✓' : '!' } ${esc(item.label)}</span>`).join('') ? req.requirements.map(item => `<span class="flow-template-requirement ${item.ok ? 'ok' : 'missing'} ${item.info ? 'info' : ''}">${item.ok ? '✓' : '!'} ${esc(item.label)}</span>`).join('')
: `<span class="flow-template-requirement ok">✓ ${esc(tr('flow.templateRequirementsReady'))}</span>`; : `<span class="flow-template-requirement ok">✓ ${esc(tr('flow.templateRequirementsReady'))}</span>`;
host.innerHTML = `<div class="flow-template-preview-head"><div><span class="eyebrow">${esc(tr('flow.templatePreview'))}</span><h3>${esc(flowPresetText(preset.name) || preset.id)}</h3></div><button type="button" class="flow-template-favorite ${favorite ? 'active' : ''}" data-flow-template-favorite="${esc(preset.id)}" title="${esc(favorite ? tr('flow.templateFavoriteRemove') : tr('flow.templateFavoriteAdd'))}" aria-label="${esc(favorite ? tr('flow.templateFavoriteRemove') : tr('flow.templateFavoriteAdd'))}">${favorite ? '★' : '☆'}</button></div> host.innerHTML = `<div class="flow-template-preview-head"><div><span class="eyebrow">${esc(tr('flow.templatePreview'))}</span><h3>${esc(flowPresetText(preset.name) || preset.id)}</h3></div><button type="button" class="flow-template-favorite ${favorite ? 'active' : ''}" data-flow-template-favorite="${esc(preset.id)}" title="${esc(favorite ? tr('flow.templateFavoriteRemove') : tr('flow.templateFavoriteAdd'))}" aria-label="${esc(favorite ? tr('flow.templateFavoriteRemove') : tr('flow.templateFavoriteAdd'))}">${favorite ? '★' : '☆'}</button></div>
<p>${esc(flowPresetText(preset.description))}</p> <p>${esc(flowPresetText(preset.description))}</p>
<div class="flow-template-preview-stats"><span>${esc(tr('flow.templateNodesCount', { count:preset.flow.nodes.length }))}</span><span>${esc(tr('flow.templateEdgesCount', { count:preset.flow.edges.length }))}</span></div> <div class="flow-template-preview-stats"><span>${esc(tr('flow.templateNodesCount', { count: preset.flow.nodes.length }))}</span><span>${esc(tr('flow.templateEdgesCount', { count: preset.flow.edges.length }))}</span></div>
${flowPresetPreviewGraph(preset)} ${flowPresetPreviewGraph(preset)}
<div class="flow-template-requirements"><strong>${esc(tr('flow.templateRequirements'))}</strong><div>${reqMarkup}</div>${req.missing.length ? `<p class="field-note warning-note">${esc(tr('flow.templateRequirementsMissing', { items:req.missing.join(', ') }))}</p>` : ''}</div> <div class="flow-template-requirements"><strong>${esc(tr('flow.templateRequirements'))}</strong><div>${reqMarkup}</div>${req.missing.length ? `<p class="field-note warning-note">${esc(tr('flow.templateRequirementsMissing', { items: req.missing.join(', ') }))}</p>` : ''}</div>
<div class="form-actions"><button type="button" class="primary" data-flow-template-use="${esc(preset.id)}">${esc(tr('flow.templateUse'))}</button></div>`; <div class="form-actions"><button type="button" class="primary" data-flow-template-use="${esc(preset.id)}">${esc(tr('flow.templateUse'))}</button></div>`;
} }
@@ -864,7 +864,7 @@ function renderFlowPresetBrowser(category = '') {
byCategory.set('favorites', presets.filter(preset => favorites.has(preset.id))); byCategory.set('favorites', presets.filter(preset => favorites.has(preset.id)));
byCategory.set('recent', recent.map(id => presets.find(preset => preset.id === id)).filter(Boolean)); byCategory.set('recent', recent.map(id => presets.find(preset => preset.id === id)).filter(Boolean));
const normalCategories = [...new Set([...FLOW_PRESET_CATEGORY_ORDER, ...presets.map(preset => preset.category || 'advanced')])].filter(key => (byCategory.get(key) || []).length); const normalCategories = [...new Set([...FLOW_PRESET_CATEGORY_ORDER, ...presets.map(preset => preset.category || 'advanced')])].filter(key => (byCategory.get(key) || []).length);
const categories = ['favorites','recent', ...normalCategories]; const categories = ['favorites', 'recent', ...normalCategories];
if (!presets.length) { tabs.innerHTML = ''; host.innerHTML = ''; renderFlowPresetPreview(null); return; } if (!presets.length) { tabs.innerHTML = ''; host.innerHTML = ''; renderFlowPresetPreview(null); return; }
flowPresetActiveCategory = categories.includes(category) ? category : (categories.includes(flowPresetActiveCategory) ? flowPresetActiveCategory : normalCategories[0]); flowPresetActiveCategory = categories.includes(category) ? category : (categories.includes(flowPresetActiveCategory) ? flowPresetActiveCategory : normalCategories[0]);
tabs.innerHTML = categories.map(key => `<button type="button" class="flow-template-tab ${key === flowPresetActiveCategory ? 'active' : ''}" role="tab" aria-selected="${key === flowPresetActiveCategory}" data-flow-template-category="${esc(key)}">${esc(flowPresetCategoryLabel(key))} <span class="badge">${(byCategory.get(key) || []).length}</span></button>`).join(''); tabs.innerHTML = categories.map(key => `<button type="button" class="flow-template-tab ${key === flowPresetActiveCategory ? 'active' : ''}" role="tab" aria-selected="${key === flowPresetActiveCategory}" data-flow-template-category="${esc(key)}">${esc(flowPresetCategoryLabel(key))} <span class="badge">${(byCategory.get(key) || []).length}</span></button>`).join('');
@@ -921,17 +921,17 @@ function applyFlowTemplate(key) {
function localDateTimeInputValue(date = new Date()) { function localDateTimeInputValue(date = new Date()) {
const pad = value => String(value).padStart(2, '0'); const pad = value => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
} }
function flowSimulationOverrideNodes() { function flowSimulationOverrideNodes() {
return (app.flowDraft?.nodes || []).filter(node => ['outdoor_temperature','device_temperature','zone_temperature','ha_state','ha_numeric','ha_attribute','ha_available','house_mode','device_state','zone_state','group_state','night_mode','shared_input'].includes(node.kind)); return (app.flowDraft?.nodes || []).filter(node => ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_state', 'ha_numeric', 'ha_attribute', 'ha_available', 'house_mode', 'device_state', 'zone_state', 'group_state', 'night_mode', 'shared_input'].includes(node.kind));
} }
function renderFlowSimulationOverrides() { function renderFlowSimulationOverrides() {
const host = $('#flowSimulationOverrides'); if (!host) return; const host = $('#flowSimulationOverrides'); if (!host) return;
const nodes = flowSimulationOverrideNodes(); const nodes = flowSimulationOverrideNodes();
host.innerHTML = nodes.length ? nodes.map(node => { host.innerHTML = nodes.length ? nodes.map(node => {
const effectiveKind = node.kind === 'shared_input' ? (app.flowSharedInputs || []).find(item => item.id === node.config?.input_id)?.kind : node.kind; const effectiveKind = node.kind === 'shared_input' ? (app.flowSharedInputs || []).find(item => item.id === node.config?.input_id)?.kind : node.kind;
const numeric = ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(effectiveKind); const numeric = ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(effectiveKind);
return `<label><span>${esc(flowNodeTitle(FLOW_NODE_META[node.kind]))} · ${esc(flowNodeSummary(node))}</span><input ${numeric ? 'type="number" step="0.1"' : 'type="text"'} data-flow-sim-node="${esc(node.id)}" placeholder="${esc(tr('flow.useLiveValue'))}"></label>`; return `<label><span>${esc(flowNodeTitle(FLOW_NODE_META[node.kind]))} · ${esc(flowNodeSummary(node))}</span><input ${numeric ? 'type="number" step="0.1"' : 'type="text"'} data-flow-sim-node="${esc(node.id)}" placeholder="${esc(tr('flow.useLiveValue'))}"></label>`;
}).join('') : `<p class="field-note">${esc(tr('flow.noSimulationOverrides'))}</p>`; }).join('') : `<p class="field-note">${esc(tr('flow.noSimulationOverrides'))}</p>`;
} }
@@ -941,7 +941,7 @@ function collectFlowSimulationOverrides() {
if (input.value.trim() === '') return; if (input.value.trim() === '') return;
const node = flowNodeById(input.dataset.flowSimNode); let value = input.value.trim(); const node = flowNodeById(input.dataset.flowSimNode); let value = input.value.trim();
const effectiveKind = node?.kind === 'shared_input' ? (app.flowSharedInputs || []).find(item => item.id === node.config?.input_id)?.kind : node?.kind; const effectiveKind = node?.kind === 'shared_input' ? (app.flowSharedInputs || []).find(item => item.id === node.config?.input_id)?.kind : node?.kind;
if (node && ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(effectiveKind)) value = Number(value); if (node && ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(effectiveKind)) value = Number(value);
else if (/^(true|false)$/i.test(value)) value = value.toLowerCase() === 'true'; else if (/^(true|false)$/i.test(value)) value = value.toLowerCase() === 'true';
result[input.dataset.flowSimNode] = value; result[input.dataset.flowSimNode] = value;
}); });
@@ -955,43 +955,43 @@ function openFlowDryRun() {
function renderFlowDryRunResult(result) { function renderFlowDryRunResult(result) {
const host = $('#flowTestResults'); const host = $('#flowTestResults');
const actionName = id => flowNodeById(id) ? `${flowNodeTitle(FLOW_NODE_META[flowNodeById(id).kind])}: ${flowNodeSummary(flowNodeById(id))}` : id; const actionName = id => flowNodeById(id) ? `${flowNodeTitle(FLOW_NODE_META[flowNodeById(id).kind])}: ${flowNodeSummary(flowNodeById(id))}` : id;
host.innerHTML = `<div class="flow-test-summary"><strong>${esc(result.summary || tr('flow.dryRun'))}</strong><span>${esc(tr('flow.compiledPreview', result.compiled || { schedules:0, automations:0 }))}</span></div>${(result.actions || []).map(action => `<article class="flow-test-action ${action.would_execute ? 'pass' : 'blocked'}"><div><strong>${esc(actionName(action.node_id))}</strong><span class="badge ${action.would_execute ? 'active' : ''}">${esc(action.would_execute ? tr('flow.wouldExecute') : action.matched ? tr('flow.blockedByOwnership') : tr('flow.conditionFalse'))}</span></div>${action.blocked_reason ? `<p>${esc(tr('flow.blockReason'))}: <code>${esc(action.blocked_reason)}</code></p>` : ''}<div class="flow-trace">${(action.trace || []).map(item => `<div><span>${item.matched ? '✓' : '×'} ${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title:item.kind }))}</span><code>${esc(JSON.stringify(item.actual))}</code></div>`).join('')}</div></article>`).join('')}`; host.innerHTML = `<div class="flow-test-summary"><strong>${esc(result.summary || tr('flow.dryRun'))}</strong><span>${esc(tr('flow.compiledPreview', result.compiled || { schedules: 0, automations: 0 }))}</span></div>${(result.actions || []).map(action => `<article class="flow-test-action ${action.would_execute ? 'pass' : 'blocked'}"><div><strong>${esc(actionName(action.node_id))}</strong><span class="badge ${action.would_execute ? 'active' : ''}">${esc(action.would_execute ? tr('flow.wouldExecute') : action.matched ? tr('flow.blockedByOwnership') : tr('flow.conditionFalse'))}</span></div>${action.blocked_reason ? `<p>${esc(tr('flow.blockReason'))}: <code>${esc(action.blocked_reason)}</code></p>` : ''}<div class="flow-trace">${(action.trace || []).map(item => `<div><span>${item.matched ? '✓' : '×'} ${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title: item.kind }))}</span><code>${esc(JSON.stringify(item.actual))}</code></div>`).join('')}</div></article>`).join('')}`;
} }
async function runFlowDryRun() { async function runFlowDryRun() {
if (!app.flowDraft) return; if (!app.flowDraft) return;
const input = $('#flowSimulationAt').value; const at = input ? new Date(input).toISOString() : new Date().toISOString(); const input = $('#flowSimulationAt').value; const at = input ? new Date(input).toISOString() : new Date().toISOString();
$('#flowTestResults').innerHTML = `<p class="field-note">${esc(tr('flow.runningSimulation'))}</p>`; $('#flowTestResults').innerHTML = `<p class="field-note">${esc(tr('flow.runningSimulation'))}</p>`;
try { try {
const result = await api('/api/flows/simulate', { method:'POST', body:{ flow:flowSourcePayload(), flow_id:app.flowDraft.id || null, at, overrides:collectFlowSimulationOverrides(), log:true } }); const result = await api('/api/flows/simulate', { method: 'POST', body: { flow: flowSourcePayload(), flow_id: app.flowDraft.id || null, at, overrides: collectFlowSimulationOverrides(), log: true } });
renderFlowDryRunResult(result); renderFlowDryRunResult(result);
} catch (error) { $('#flowTestResults').innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.simulationFailed'))}</strong><span>${esc(error.message)}</span></div>`; } } catch (error) { $('#flowTestResults').innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.simulationFailed'))}</strong><span>${esc(error.message)}</span></div>`; }
} }
function flowLogLevelLabel(level = '') { function flowLogLevelLabel(level = '') {
const key = { info:'flow.logLevelInfo', warn:'flow.logLevelWarn', error:'flow.logLevelError' }[String(level).toLowerCase()]; const key = { info: 'flow.logLevelInfo', warn: 'flow.logLevelWarn', error: 'flow.logLevelError' }[String(level).toLowerCase()];
return key ? tr(key) : level; return key ? tr(key) : level;
} }
function flowLogKindLabel(kind = '') { function flowLogKindLabel(kind = '') {
const key = { const key = {
'flow.created':'flow.logKindCreated', 'flow.updated':'flow.logKindUpdated', 'flow.deleted':'flow.logKindDeleted', 'flow.created': 'flow.logKindCreated', 'flow.updated': 'flow.logKindUpdated', 'flow.deleted': 'flow.logKindDeleted',
'flow.imported':'flow.logKindImported', 'flow.dry_run':'flow.logKindDryRun', 'flow.condition_error':'flow.logKindConditionError', 'flow.imported': 'flow.logKindImported', 'flow.dry_run': 'flow.logKindDryRun', 'flow.condition_error': 'flow.logKindConditionError',
'flow.action_suppressed':'flow.logKindActionSuppressed', 'automation.fired':'flow.logKindActionFired', 'flow.action_suppressed': 'flow.logKindActionSuppressed', 'automation.fired': 'flow.logKindActionFired',
'automation.error':'flow.logKindActionError', 'automation.blocked_by_zone':'flow.logKindBlockedZone', 'automation.error': 'flow.logKindActionError', 'automation.blocked_by_zone': 'flow.logKindBlockedZone',
'automation.blocked_by_manual_override':'flow.logKindBlockedManual', 'automation.blocked_by_local_thermostat':'flow.logKindBlockedThermostat', 'automation.blocked_by_manual_override': 'flow.logKindBlockedManual', 'automation.blocked_by_local_thermostat': 'flow.logKindBlockedThermostat',
'automation.blocked_by_temporary_thermostat':'flow.logKindBlockedTemporary', 'automation.blocked_by_thermostat_owner':'flow.logKindBlockedThermostatOwner', 'automation.blocked_by_temporary_thermostat': 'flow.logKindBlockedTemporary', 'automation.blocked_by_thermostat_owner': 'flow.logKindBlockedThermostatOwner',
'automation.blocked_by_fresh_ownership':'flow.logKindBlockedOwnership', 'automation.conflict':'flow.logKindConflict', 'automation.blocked_by_fresh_ownership': 'flow.logKindBlockedOwnership', 'automation.conflict': 'flow.logKindConflict',
}[kind]; }[kind];
return key ? tr(key) : kind; return key ? tr(key) : kind;
} }
function flowLogMessage(event = {}) { function flowLogMessage(event = {}) {
const name = app.flowDraft?.name || tr('nav.flows'); const name = app.flowDraft?.name || tr('nav.flows');
const key = { const key = {
'flow.created':'flow.logMessageCreated', 'flow.updated':'flow.logMessageUpdated', 'flow.deleted':'flow.logMessageDeleted', 'flow.created': 'flow.logMessageCreated', 'flow.updated': 'flow.logMessageUpdated', 'flow.deleted': 'flow.logMessageDeleted',
'flow.imported':'flow.logMessageImported', 'flow.dry_run':'flow.logMessageDryRun', 'flow.condition_error':'flow.logMessageConditionError', 'flow.imported': 'flow.logMessageImported', 'flow.dry_run': 'flow.logMessageDryRun', 'flow.condition_error': 'flow.logMessageConditionError',
'flow.action_suppressed':'flow.logMessageActionSuppressed', 'automation.fired':'flow.logMessageActionFired', 'flow.action_suppressed': 'flow.logMessageActionSuppressed', 'automation.fired': 'flow.logMessageActionFired',
'automation.error':'flow.logMessageActionError', 'automation.blocked_by_zone':'flow.logMessageBlockedZone', 'automation.error': 'flow.logMessageActionError', 'automation.blocked_by_zone': 'flow.logMessageBlockedZone',
'automation.blocked_by_manual_override':'flow.logMessageBlockedManual', 'automation.blocked_by_local_thermostat':'flow.logMessageBlockedThermostat', 'automation.blocked_by_manual_override': 'flow.logMessageBlockedManual', 'automation.blocked_by_local_thermostat': 'flow.logMessageBlockedThermostat',
'automation.blocked_by_temporary_thermostat':'flow.logMessageBlockedTemporary', 'automation.blocked_by_thermostat_owner':'flow.logMessageBlockedThermostatOwner', 'automation.blocked_by_temporary_thermostat': 'flow.logMessageBlockedTemporary', 'automation.blocked_by_thermostat_owner': 'flow.logMessageBlockedThermostatOwner',
'automation.blocked_by_fresh_ownership':'flow.logMessageBlockedOwnership', 'automation.conflict':'flow.logMessageConflict', 'automation.blocked_by_fresh_ownership': 'flow.logMessageBlockedOwnership', 'automation.conflict': 'flow.logMessageConflict',
}[event.kind]; }[event.kind];
return key ? tr(key, { name }) : (event.message || '—'); return key ? tr(key, { name }) : (event.message || '—');
} }
@@ -1027,10 +1027,10 @@ async function saveFlow() {
await applySavedFlow(saved, 'flow.saved'); await applySavedFlow(saved, 'flow.saved');
} catch (error) { } catch (error) {
if (error.status !== 400) return toast(error.message, true); if (error.status !== 400) return toast(error.message, true);
const saveDraft = confirm(tr('flow.saveAsDraftConfirm', { reason:error.message })); const saveDraft = confirm(tr('flow.saveAsDraftConfirm', { reason: error.message }));
if (!saveDraft) return toast(error.message, true); if (!saveDraft) return toast(error.message, true);
try { try {
const saved = await persistFlowDraft({ ...body, enabled:false, draft:true }); const saved = await persistFlowDraft({ ...body, enabled: false, draft: true });
await applySavedFlow(saved, 'flow.savedAsDraft'); await applySavedFlow(saved, 'flow.savedAsDraft');
} catch (draftError) { toast(draftError.message, true); } } catch (draftError) { toast(draftError.message, true); }
} }
@@ -1047,9 +1047,9 @@ async function toggleFlowEnabled(id, enabled) {
if (flow.draft) return toast(tr('flow.draftCannotEnable'), true); if (flow.draft) return toast(tr('flow.draftCannotEnable'), true);
const toggle = $(`[data-action="toggle-flow-enabled"][data-id="${CSS.escape(id)}"]`); if (toggle?.disabled) return; const toggle = $(`[data-action="toggle-flow-enabled"][data-id="${CSS.escape(id)}"]`); if (toggle?.disabled) return;
if (toggle) toggle.disabled = true; if (toggle) toggle.disabled = true;
const body = { name:flow.name, enabled, draft:false, description:flow.description || '', nodes:flow.nodes || [], edges:flow.edges || [], expected_revision:Number(flow.revision || 0) }; const body = { name: flow.name, enabled, draft: false, description: flow.description || '', nodes: flow.nodes || [], edges: flow.edges || [], expected_revision: Number(flow.revision || 0) };
try { try {
const saved = await api(`/api/flows/${encodeURIComponent(id)}`, { method:'PUT', body }); const saved = await api(`/api/flows/${encodeURIComponent(id)}`, { method: 'PUT', body });
const index = app.flows.findIndex(item => item.id === id); if (index >= 0) app.flows[index] = saved; const index = app.flows.findIndex(item => item.id === id); if (index >= 0) app.flows[index] = saved;
renderFlows(); await loadBootstrap(); toast(enabled ? tr('flow.quickEnabled') : tr('flow.quickDisabled')); renderFlows(); await loadBootstrap(); toast(enabled ? tr('flow.quickEnabled') : tr('flow.quickDisabled'));
} catch (error) { renderFlows(); toast(error.message, true); } } catch (error) { renderFlows(); toast(error.message, true); }
@@ -1097,22 +1097,22 @@ function updateFlowConfig(input) {
} }
else if (node.kind === 'shared_input' && key === 'value') { else if (node.kind === 'shared_input' && key === 'value') {
const item = (app.flowSharedInputs || []).find(value => value.id === node.config.input_id); const item = (app.flowSharedInputs || []).find(value => value.id === node.config.input_id);
const numeric = ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(item?.kind); const numeric = ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(item?.kind);
node.config.value = numeric ? (input.value === '' ? null : Number(input.value)) : input.value; node.config.value = numeric ? (input.value === '' ? null : Number(input.value)) : input.value;
} }
else if (['power','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep'].includes(key)) node.config[key] = input.value === '' ? null : input.value === 'true'; else if (['power', 'swing_vertical', 'swing_horizontal', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep'].includes(key)) node.config[key] = input.value === '' ? null : input.value === 'true';
else if (key === 'value' && node.kind === 'constant') node.config[key] = input.value === 'true'; else if (key === 'value' && node.kind === 'constant') node.config[key] = input.value === 'true';
else if (key === 'data_json' && node.kind === 'ha_service_action') { try { node.config.data = JSON.parse(input.value || '{}'); } catch { toast(tr('flow.invalidJson'), true); return; } } else if (key === 'data_json' && node.kind === 'ha_service_action') { try { node.config.data = JSON.parse(input.value || '{}'); } catch { toast(tr('flow.invalidJson'), true); return; } }
else if (key === 'seconds' && ['stable_for','delay'].includes(node.kind)) node.config.seconds = Math.max(1, Number(input.value || 1)); else if (key === 'seconds' && ['stable_for', 'delay'].includes(node.kind)) node.config.seconds = Math.max(1, Number(input.value || 1));
else if (key === 'min_seconds' && node.kind === 'state_duration') node.config.min_seconds = Math.max(0, Number(input.value || 0)); else if (key === 'min_seconds' && node.kind === 'state_duration') node.config.min_seconds = Math.max(0, Number(input.value || 0));
else if (key === 'max_seconds' && node.kind === 'state_duration') node.config.max_seconds = input.value === '' ? null : Math.max(0, Number(input.value)); else if (key === 'max_seconds' && node.kind === 'state_duration') node.config.max_seconds = input.value === '' ? null : Math.max(0, Number(input.value));
else if (key === 'max_count' && node.kind === 'rate_limit') node.config.max_count = Math.max(1, Math.floor(Number(input.value || 1))); else if (key === 'max_count' && node.kind === 'rate_limit') node.config.max_count = Math.max(1, Math.floor(Number(input.value || 1)));
else if (key === 'period_seconds' && node.kind === 'rate_limit') node.config.period_seconds = Math.max(1, Math.floor(Number(input.value || 1))); else if (key === 'period_seconds' && node.kind === 'rate_limit') node.config.period_seconds = Math.max(1, Math.floor(Number(input.value || 1)));
else if (key === 'window_seconds' && ['rolling_stat','oscillates'].includes(node.kind)) node.config.window_seconds = Math.max(10, Number(input.value || 10)); else if (key === 'window_seconds' && ['rolling_stat', 'oscillates'].includes(node.kind)) node.config.window_seconds = Math.max(10, Number(input.value || 10));
else if (key === 'value' && node.kind === 'rolling_stat') node.config.value = Number(input.value || 0); else if (key === 'value' && node.kind === 'rolling_stat') node.config.value = Number(input.value || 0);
else if (key === 'min_span' && node.kind === 'oscillates') node.config.min_span = Math.max(0.001, Number(input.value || 0.001)); else if (key === 'min_span' && node.kind === 'oscillates') node.config.min_span = Math.max(0.001, Number(input.value || 0.001));
else if (key === 'min_direction_changes' && node.kind === 'oscillates') node.config.min_direction_changes = Math.max(1, Math.floor(Number(input.value || 1))); else if (key === 'min_direction_changes' && node.kind === 'oscillates') node.config.min_direction_changes = Math.max(1, Math.floor(Number(input.value || 1)));
else if (['value','setpoint','target_temperature','cooldown_seconds','fan_speed'].includes(key) && ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric','zone_thermostat','device_action','group_action','ha_service_action'].includes(node.kind)) node.config[key] = input.value === '' ? null : Number(input.value); else if (['value', 'setpoint', 'target_temperature', 'cooldown_seconds', 'fan_speed'].includes(key) && ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric', 'zone_thermostat', 'device_action', 'group_action', 'ha_service_action'].includes(node.kind)) node.config[key] = input.value === '' ? null : Number(input.value);
else node.config[key] = input.value; else node.config[key] = input.value;
app.flowDirty = true; renderFlowEditor(); app.flowDirty = true; renderFlowEditor();
} }
@@ -1134,8 +1134,8 @@ document.addEventListener('pointerdown', event => {
if (!selected.includes(node.id)) { setFlowSelection(selected, selected[selected.length - 1] || null); event.preventDefault(); return; } if (!selected.includes(node.id)) { setFlowSelection(selected, selected[selected.length - 1] || null); event.preventDefault(); return; }
} else if (!selected.includes(node.id)) selected = [node.id]; } else if (!selected.includes(node.id)) selected = [node.id];
app.flowSelectedNodeIds = selected; app.flowSelectedNodeId = node.id; renderFlowEditor(); app.flowSelectedNodeIds = selected; app.flowSelectedNodeId = node.id; renderFlowEditor();
const starts = selected.map(id => flowNodeById(id)).filter(Boolean).map(item => ({ id:item.id, left:Number(item.x || 0), top:Number(item.y || 0) })); const starts = selected.map(id => flowNodeById(id)).filter(Boolean).map(item => ({ id: item.id, left: Number(item.x || 0), top: Number(item.y || 0) }));
flowDrag = { x:event.clientX, y:event.clientY, starts }; flowDrag = { x: event.clientX, y: event.clientY, starts };
nodeEl.setPointerCapture?.(event.pointerId); event.preventDefault(); nodeEl.setPointerCapture?.(event.pointerId); event.preventDefault();
}); });
document.addEventListener('pointermove', event => { document.addEventListener('pointermove', event => {
@@ -1208,7 +1208,7 @@ document.addEventListener('click', event => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const target = $('#flowSharedInputsSettings'); const target = $('#flowSharedInputsSettings');
if (!target) return; if (!target) return;
target.scrollIntoView({ behavior:'smooth', block:'start' }); target.scrollIntoView({ behavior: 'smooth', block: 'start' });
target.classList.add('is-linked-target'); target.classList.add('is-linked-target');
setTimeout(() => target.classList.remove('is-linked-target'), 1800); setTimeout(() => target.classList.remove('is-linked-target'), 1800);
}); });
+1 -1
View File
@@ -67,6 +67,6 @@ function connectWebSocket() {
ws.onerror = () => updateConnectionIndicator('connectionError'); ws.onerror = () => updateConnectionIndicator('connectionError');
let messageQueue = Promise.resolve(); let messageQueue = Promise.resolve();
ws.onmessage = event => { ws.onmessage = event => {
messageQueue = messageQueue.then(() => handleWebSocketMessage(event)).catch(() => {}); messageQueue = messageQueue.then(() => handleWebSocketMessage(event)).catch(() => { });
}; };
} }
+7 -7
View File
@@ -76,10 +76,10 @@ function sharedFlowInputSourceSummary(item) {
if (item.kind === 'device_state') return `${app.devices.find(value => value.id === c.device_id)?.name || tr('common.noDevice')} · ${c.field || 'state'}`; if (item.kind === 'device_state') return `${app.devices.find(value => value.id === c.device_id)?.name || tr('common.noDevice')} · ${c.field || 'state'}`;
if (item.kind === 'zone_state') return `${app.zones.find(value => value.id === c.zone_id)?.name || tr('common.noZone')} · ${c.field || 'state'}`; if (item.kind === 'zone_state') return `${app.zones.find(value => value.id === c.zone_id)?.name || tr('common.noZone')} · ${c.field || 'state'}`;
if (item.kind === 'group_state') return `${app.groups.find(value => value.id === c.group_id)?.name || tr('groups.group')} · ${c.field || 'power_enabled'}`; if (item.kind === 'group_state') return `${app.groups.find(value => value.id === c.group_id)?.name || tr('groups.group')} · ${c.field || 'power_enabled'}`;
return flowNodeSummary({ kind:item.kind, config:c }); return flowNodeSummary({ kind: item.kind, config: c });
} }
function isHaSharedInputKind(kind) { return ['ha_state','ha_numeric','ha_attribute','ha_available'].includes(kind); } function isHaSharedInputKind(kind) { return ['ha_state', 'ha_numeric', 'ha_attribute', 'ha_available'].includes(kind); }
function flowSharedInputUsages(id) { function flowSharedInputUsages(id) {
return (app.flows || []).filter(flow => (flow.nodes || []).some(node => node.kind === 'shared_input' && node.config?.input_id === id)); return (app.flows || []).filter(flow => (flow.nodes || []).some(node => node.kind === 'shared_input' && node.config?.input_id === id));
@@ -91,10 +91,10 @@ function renderFlowSharedInputs() {
host.innerHTML = items.length ? items.map(item => { host.innerHTML = items.length ? items.map(item => {
const usages = flowSharedInputUsages(item.id); const usages = flowSharedInputUsages(item.id);
const usageMarkup = usages.length const usageMarkup = usages.length
? `<div class="flow-shared-usage"><small>${esc(tr('flow.sharedInputUsedBy', { count:usages.length }))}</small><div>${usages.slice(0, 4).map(flow => `<button type="button" class="link-button" data-open-shared-flow="${esc(flow.id)}" title="${esc(tr('flow.openReferencedFlow', { name:flow.name }))}">${esc(flow.name)}</button>`).join('')}${usages.length > 4 ? `<span class="muted">+${usages.length - 4}</span>` : ''}</div></div>` ? `<div class="flow-shared-usage"><small>${esc(tr('flow.sharedInputUsedBy', { count: usages.length }))}</small><div>${usages.slice(0, 4).map(flow => `<button type="button" class="link-button" data-open-shared-flow="${esc(flow.id)}" title="${esc(tr('flow.openReferencedFlow', { name: flow.name }))}">${esc(flow.name)}</button>`).join('')}${usages.length > 4 ? `<span class="muted">+${usages.length - 4}</span>` : ''}</div></div>`
: `<small class="flow-shared-unused">${esc(tr('flow.sharedInputUnused'))}</small>`; : `<small class="flow-shared-unused">${esc(tr('flow.sharedInputUnused'))}</small>`;
return `<div class="flow-shared-input-row"> return `<div class="flow-shared-input-row">
<div><strong>${esc(item.name)}</strong><small>${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title:item.kind }))} · ${esc(sharedFlowInputSourceSummary(item))}</small><code>${esc(item.id)}</code>${usageMarkup}</div> <div><strong>${esc(item.name)}</strong><small>${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title: item.kind }))} · ${esc(sharedFlowInputSourceSummary(item))}</small><code>${esc(item.id)}</code>${usageMarkup}</div>
<div class="flow-shared-input-actions"><button type="button" class="secondary" data-flow-shared-edit="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button type="button" class="danger" data-flow-shared-delete="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div> <div class="flow-shared-input-actions"><button type="button" class="secondary" data-flow-shared-edit="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button type="button" class="danger" data-flow-shared-delete="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div>
</div>`; </div>`;
}).join('') : `<div class="empty compact"><strong>${esc(tr('flow.sharedInputsEmpty'))}</strong><span>${esc(tr('flow.sharedInputsEmptyHint'))}</span></div>`; }).join('') : `<div class="empty compact"><strong>${esc(tr('flow.sharedInputsEmpty'))}</strong><span>${esc(tr('flow.sharedInputsEmptyHint'))}</span></div>`;
@@ -119,8 +119,8 @@ function renderFlowSharedInputFields(kind, config = {}) {
else if (kind === 'device_temperature') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label>`; else if (kind === 'device_temperature') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label>`;
else if (kind === 'zone_temperature') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label>`; else if (kind === 'zone_temperature') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label>`;
else if (kind === 'house_mode') fields = `<p class="field-note">${esc(tr('flow.sharedInputSourceOnlyHint'))}</p>`; else if (kind === 'house_mode') fields = `<p class="field-note">${esc(tr('flow.sharedInputSourceOnlyHint'))}</p>`;
else if (kind === 'device_state') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled','online','power','mode','fan_speed','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>`; else if (kind === 'device_state') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled', 'online', 'power', 'mode', 'fan_speed', 'swing_vertical', 'swing_horizontal', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>`;
else if (kind === 'zone_state') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled','mode','active_preset','demand','control_owner','device_manual_override','local_thermostat_power'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>`; else if (kind === 'zone_state') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled', 'mode', 'active_preset', 'demand', 'control_owner', 'device_manual_override', 'local_thermostat_power'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>`;
else if (kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-shared-config="group_id">${sharedFlowOptions(app.groups, c.group_id)}</select></label><input type="hidden" data-shared-config="field" value="power_enabled">`; else if (kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-shared-config="group_id">${sharedFlowOptions(app.groups, c.group_id)}</select></label><input type="hidden" data-shared-config="field" value="power_enabled">`;
else if (kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`; else if (kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`;
host.innerHTML = fields; host.innerHTML = fields;
@@ -133,7 +133,7 @@ function openFlowSharedInputEditor(id = '') {
const form = $('#flowSharedInputForm'), dialog = $('#flowSharedInputDialog'); if (!form || !dialog) return; const form = $('#flowSharedInputForm'), dialog = $('#flowSharedInputDialog'); if (!form || !dialog) return;
const item = id ? (app.flowSharedInputs || []).find(value => value.id === id) : null; const item = id ? (app.flowSharedInputs || []).find(value => value.id === id) : null;
const kindSelect = form.kind; const kindSelect = form.kind;
kindSelect.innerHTML = flowSharedInputKinds().map(([kind,key]) => `<option value="${kind}">${esc(tr(key))}</option>`).join(''); kindSelect.innerHTML = flowSharedInputKinds().map(([kind, key]) => `<option value="${kind}">${esc(tr(key))}</option>`).join('');
form.id.value = item?.id || ''; form.id.value = item?.id || '';
form.name.value = item?.name || ''; form.name.value = item?.name || '';
form.kind.value = item?.kind || 'constant'; form.kind.value = item?.kind || 'constant';
+6 -6
View File
@@ -356,16 +356,16 @@ $('#flowSharedInputKind')?.addEventListener('change', event => {
renderFlowSharedInputFields(event.target.value, flowSharedInputDefaultConfig(event.target.value)); renderFlowSharedInputFields(event.target.value, flowSharedInputDefaultConfig(event.target.value));
}); });
function evaluateFlowSharedHaTest(kind, config, result) { function evaluateFlowSharedHaTest(kind, config, result) {
if (kind === 'ha_available') return { actual:result.available === true, valid:true }; if (kind === 'ha_available') return { actual: result.available === true, valid: true };
if (kind === 'ha_attribute') { if (kind === 'ha_attribute') {
const actual = result.attributes?.[config.attribute]; const actual = result.attributes?.[config.attribute];
return { actual, valid:actual !== undefined }; return { actual, valid: actual !== undefined };
} }
if (kind === 'ha_numeric') { if (kind === 'ha_numeric') {
const actual = Number(result.state); const actual = Number(result.state);
return { actual, valid:Number.isFinite(actual) }; return { actual, valid: Number.isFinite(actual) };
} }
return { actual:result.state, valid:true }; return { actual: result.state, valid: true };
} }
$('#flowSharedInputTest')?.addEventListener('click', async event => { $('#flowSharedInputTest')?.addEventListener('click', async event => {
@@ -384,7 +384,7 @@ $('#flowSharedInputTest')?.addEventListener('click', async event => {
button.disabled = true; button.textContent = tr('flow.sharedInputTesting'); button.disabled = true; button.textContent = tr('flow.sharedInputTesting');
resultHost.hidden = false; resultHost.innerHTML = `<span>${esc(tr('flow.sharedInputTesting'))}</span>`; resultHost.hidden = false; resultHost.innerHTML = `<span>${esc(tr('flow.sharedInputTesting'))}</span>`;
try { try {
const result = await api('/api/integrations/home-assistant/entity', { method:'POST', body:{ entity_id:entityId } }); const result = await api('/api/integrations/home-assistant/entity', { method: 'POST', body: { entity_id: entityId } });
const evaluation = evaluateFlowSharedHaTest(kind, config, result); const evaluation = evaluateFlowSharedHaTest(kind, config, result);
const actual = evaluation.actual == null ? 'null' : (typeof evaluation.actual === 'object' ? JSON.stringify(evaluation.actual) : String(evaluation.actual)); const actual = evaluation.actual == null ? 'null' : (typeof evaluation.actual === 'object' ? JSON.stringify(evaluation.actual) : String(evaluation.actual));
const success = evaluation.valid; const success = evaluation.valid;
@@ -424,7 +424,7 @@ document.addEventListener('click', event => {
const id = remove.dataset.flowSharedDelete; const id = remove.dataset.flowSharedDelete;
const item = app.flowSharedInputs.find(value => value.id === id); if (!item) return; const item = app.flowSharedInputs.find(value => value.id === id); if (!item) return;
const uses = app.flows.reduce((count, flow) => count + (flow.nodes || []).filter(node => node.kind === 'shared_input' && node.config?.input_id === id).length, 0); const uses = app.flows.reduce((count, flow) => count + (flow.nodes || []).filter(node => node.kind === 'shared_input' && node.config?.input_id === id).length, 0);
const message = uses ? tr('flow.sharedInputDeleteUsed', { name:item.name, count:uses }) : tr('flow.sharedInputDeleteConfirm', { name:item.name }); const message = uses ? tr('flow.sharedInputDeleteUsed', { name: item.name, count: uses }) : tr('flow.sharedInputDeleteConfirm', { name: item.name });
if (!confirm(message)) return; if (!confirm(message)) return;
app.flowSharedInputs = app.flowSharedInputs.filter(value => value.id !== id); app.flowSharedInputs = app.flowSharedInputs.filter(value => value.id !== id);
renderFlowSharedInputs(); renderFlowSharedInputs();
+2855 -616
View File
File diff suppressed because it is too large Load Diff