v0.12.0-preety_code
This commit is contained in:
@@ -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
@@ -1168,4 +1168,4 @@
|
|||||||
"schedules.enabledState": "Enabled",
|
"schedules.enabledState": "Enabled",
|
||||||
"schedules.disabledState": "Disabled"
|
"schedules.disabledState": "Disabled"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1168,4 +1168,4 @@
|
|||||||
"schedules.enabledState": "Włączony",
|
"schedules.enabledState": "Włączony",
|
||||||
"schedules.disabledState": "Wyłączony"
|
"schedules.disabledState": "Wyłączony"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+176
-54
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(¤t_notifications, NotificationSettingsUpdate {
|
settings.notifications = apply_notification_update(
|
||||||
enabled: current_notifications.enabled,
|
¤t_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(¤t.id).map(String::as_str) != Some(current.device_id.as_str()))
|
.iter()
|
||||||
|
.filter(|current| {
|
||||||
|
imported_zone_map.get(¤t.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, ¤t_zones, ¤t_devices, &export).await;
|
let _resource_guards =
|
||||||
|
lock_configuration_resources(&state, ¤t_zones, ¤t_devices, &export).await;
|
||||||
|
|
||||||
power_off_detached_devices(&state, ¤t_zones, &export).await?;
|
power_off_detached_devices(&state, ¤t_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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+168
-61
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -61,4 +61,3 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+130
-34
@@ -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
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+192
-63
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,4 +49,3 @@ pub struct EventLog {
|
|||||||
pub message: String,
|
pub message: String,
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-12
@@ -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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(() => { });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user