v0.12.0-preety_code
This commit is contained in:
+136
-30
@@ -1,15 +1,31 @@
|
||||
async fn not_found(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
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
|
||||
.replace("__GREE_BASE_PATH__", &base)
|
||||
.replace("__GREE_HOME_PATH__", &home)
|
||||
.replace("__GREE_THEME_INIT_ASSET__", &format!("{base}{THEME_INIT_ASSET_PATH}"))
|
||||
.replace("__GREE_STYLES_ASSET__", &format!("{base}{STYLES_CSS_ASSET_PATH}"));
|
||||
.replace(
|
||||
"__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));
|
||||
*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(header::CACHE_CONTROL, HeaderValue::from_static("private, no-store, no-cache, must-revalidate"));
|
||||
response.headers_mut().insert(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -18,11 +34,23 @@ async fn index(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
let body = INDEX_HTML
|
||||
.replace("__GREE_BASE_PATH__", &base)
|
||||
.replace("__GREE_APP_ASSET__", &format!("{base}{APP_JS_ASSET_PATH}"))
|
||||
.replace("__GREE_THEME_INIT_ASSET__", &format!("{base}{THEME_INIT_ASSET_PATH}"))
|
||||
.replace("__GREE_STYLES_ASSET__", &format!("{base}{STYLES_CSS_ASSET_PATH}"));
|
||||
.replace(
|
||||
"__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));
|
||||
response.headers_mut().insert(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.headers_mut().insert(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -35,18 +63,65 @@ fn request_base_path(state: &AppState, headers: &HeaderMap) -> String {
|
||||
}
|
||||
|
||||
fn forwarded_prefix(headers: &HeaderMap) -> Option<String> {
|
||||
let raw = headers.get("x-forwarded-prefix")?.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; }
|
||||
let raw = headers
|
||||
.get("x-forwarded-prefix")?
|
||||
.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('/')))
|
||||
}
|
||||
async fn app_js() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "public, max-age=31536000, immutable") }
|
||||
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 app_js() -> Response {
|
||||
static_response(
|
||||
APP_JS,
|
||||
"application/javascript; charset=utf-8",
|
||||
"public, max-age=31536000, immutable",
|
||||
)
|
||||
}
|
||||
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 {
|
||||
let body = SERVICE_WORKER
|
||||
.replace("__GREE_ASSET_CACHE__", ASSET_BUILD_ID)
|
||||
@@ -55,22 +130,38 @@ async fn service_worker() -> Response {
|
||||
.replace("__GREE_STYLES_ASSET__", STYLES_CSS_ASSET_PATH);
|
||||
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 {
|
||||
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 {
|
||||
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");
|
||||
}
|
||||
let mut response = Response::new(Body::from("Language 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
|
||||
}
|
||||
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 {
|
||||
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"));
|
||||
*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
|
||||
}
|
||||
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));
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
|
||||
response
|
||||
}
|
||||
|
||||
fn owned_response(body: String, content_type: &'static str, cache: &'static str) -> Response {
|
||||
let mut response = Response::new(Body::from(body));
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
|
||||
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 {
|
||||
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 started = Instant::now();
|
||||
let response = next.run(request).await;
|
||||
state.broadcast("api.request", json!({
|
||||
"method": method.as_str(),
|
||||
"path": path,
|
||||
"status": response.status().as_u16(),
|
||||
"duration_ms": started.elapsed().as_millis(),
|
||||
}));
|
||||
state.broadcast(
|
||||
"api.request",
|
||||
json!({
|
||||
"method": method.as_str(),
|
||||
"path": path,
|
||||
"status": response.status().as_u16(),
|
||||
"duration_ms": started.elapsed().as_millis(),
|
||||
}),
|
||||
);
|
||||
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();
|
||||
if expected.is_empty() {
|
||||
return Ok(next.run(request).await);
|
||||
@@ -44,10 +55,17 @@ async fn home_assistant_auth(
|
||||
}
|
||||
|
||||
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.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)
|
||||
}
|
||||
|
||||
@@ -61,4 +79,3 @@ fn generate_access_token() -> String {
|
||||
rng.fill_bytes(&mut bytes);
|
||||
format!("gree_controller_{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
|
||||
+213
-53
@@ -21,38 +21,75 @@ struct AutomationInput {
|
||||
#[serde(default = "automation_cooldown")]
|
||||
cooldown_seconds: u64,
|
||||
}
|
||||
fn automation_cooldown() -> u64 { 300 }
|
||||
fn automation_cooldown() -> u64 {
|
||||
300
|
||||
}
|
||||
impl AutomationInput {
|
||||
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() {
|
||||
"temperature_above" | "temperature_below" => {
|
||||
if self.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()));
|
||||
if self
|
||||
.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" => {
|
||||
let at = self.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()))?;
|
||||
let at = self
|
||||
.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() {
|
||||
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() {
|
||||
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") {
|
||||
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 let Some(mode) = self.action.mode.as_deref() {
|
||||
if !matches!(mode, "auto" | "house" | "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("group automation mode must be house, cool or heat".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"group automation mode must be house, cool or heat".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.action.target_temperature.is_some()
|
||||
@@ -67,48 +104,121 @@ impl AutomationInput {
|
||||
|| self.action.health.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() {
|
||||
return Err(AppError::BadRequest("group automation action cannot be empty".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()
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"group automation action cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
engine::validate_command(&self.action)?;
|
||||
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(())
|
||||
}
|
||||
fn into_automation(self, id: String, created_at: chrono::DateTime<Utc>, last_fired_at: Option<chrono::DateTime<Utc>>) -> Automation {
|
||||
Automation { id, name: self.name.trim().into(), enabled: self.enabled,
|
||||
trigger_kind: self.trigger_kind, trigger_device_id: self.trigger_device_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||
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() }
|
||||
}
|
||||
}
|
||||
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()));
|
||||
fn into_automation(
|
||||
self,
|
||||
id: String,
|
||||
created_at: chrono::DateTime<Utc>,
|
||||
last_fired_at: Option<chrono::DateTime<Utc>>,
|
||||
) -> Automation {
|
||||
Automation {
|
||||
id,
|
||||
name: self.name.trim().into(),
|
||||
enabled: self.enabled,
|
||||
trigger_kind: self.trigger_kind,
|
||||
trigger_device_id: self
|
||||
.trigger_device_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
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() {
|
||||
return Err(AppError::BadRequest("automation action group does not exist".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"automation action group does not exist".into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let device_id = input.action_device_id.trim();
|
||||
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)
|
||||
&& 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(
|
||||
"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(())
|
||||
}
|
||||
|
||||
async fn list_automations(State(state): State<AppState>) -> Result<Json<Vec<Automation>>, AppError> { Ok(Json(state.db.list_automations()?)) }
|
||||
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 list_automations(
|
||||
State(state): State<AppState>,
|
||||
) -> 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 _automation_guard = state.lock_automation_operation().await;
|
||||
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() {
|
||||
Some(state.lock_group_operation(group_id).await)
|
||||
} else { None };
|
||||
} else {
|
||||
None
|
||||
};
|
||||
validate_automation_references(&state, &input)?;
|
||||
let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None);
|
||||
state.db.save_automation(&item)?;
|
||||
state.broadcast("automation.created", serde_json::to_value(&item)?);
|
||||
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 _automation_guard = state.lock_automation_operation().await;
|
||||
input.validate()?;
|
||||
let existing = state.db.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 existing = state
|
||||
.db
|
||||
.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() {
|
||||
Some(state.lock_group_operation(group_id).await)
|
||||
} else { None };
|
||||
} else {
|
||||
None
|
||||
};
|
||||
validate_automation_references(&state, &input)?;
|
||||
let item = input.into_automation(id, existing.created_at, existing.last_fired_at);
|
||||
state.db.save_automation(&item)?;
|
||||
state.broadcast("automation.updated", serde_json::to_value(&item)?);
|
||||
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 _automation_guard = state.lock_automation_operation().await;
|
||||
let existing = state.db.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}"))); }
|
||||
let existing = state
|
||||
.db
|
||||
.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}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
|
||||
+347
-111
@@ -11,7 +11,9 @@ struct ConfigurationResourceGuards {
|
||||
_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 mut export = state.db.export_configuration(settings)?;
|
||||
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()));
|
||||
}
|
||||
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") {
|
||||
return Err(AppError::BadRequest("import contains an invalid house mode".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"import contains an invalid house mode".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_configuration_ids(export: &ConfigurationExport) -> Result<ConfigurationIds<'_>, AppError> {
|
||||
fn collect_configuration_ids(
|
||||
export: &ConfigurationExport,
|
||||
) -> Result<ConfigurationIds<'_>, AppError> {
|
||||
let ids = ConfigurationIds {
|
||||
devices: export.devices.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(),
|
||||
automations: export.automations.iter().map(|item| item.id.as_str()).collect(),
|
||||
schedules: export
|
||||
.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(),
|
||||
};
|
||||
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.flows.contains("");
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
.map(|item| item.id.as_str())
|
||||
.collect();
|
||||
let executable_draft = export.flows.iter().any(|item| item.draft
|
||||
&& (item.enabled || !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)));
|
||||
let executable_draft = export.flows.iter().any(|item| {
|
||||
item.draft
|
||||
&& (item.enabled
|
||||
|| !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 {
|
||||
return Err(AppError::BadRequest("import contains an executable Flow draft".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"import contains an executable Flow draft".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -75,23 +108,44 @@ fn validate_configuration_devices_and_zones(
|
||||
export: &ConfigurationExport,
|
||||
ids: &ConfigurationIds<'_>,
|
||||
) -> 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() {
|
||||
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())) {
|
||||
return Err(AppError::BadRequest("import contains a zone referencing a missing device".into()));
|
||||
if export
|
||||
.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();
|
||||
for zone in &export.zones {
|
||||
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") {
|
||||
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") {
|
||||
return Err(AppError::BadRequest("import contains an invalid zone sensor source".into()));
|
||||
if !matches!(
|
||||
zone.sensor_source.as_str(),
|
||||
"device" | "home_assistant" | "combined"
|
||||
) {
|
||||
return Err(AppError::BadRequest(
|
||||
"import contains an invalid zone sensor source".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -101,25 +155,48 @@ fn validate_configuration_schedules(
|
||||
export: &ConfigurationExport,
|
||||
ids: &ConfigurationIds<'_>,
|
||||
) -> Result<(), AppError> {
|
||||
if export.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()));
|
||||
if export
|
||||
.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 {
|
||||
if item.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
|
||||
.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)) {
|
||||
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")
|
||||
.map_err(|_| 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()))?;
|
||||
if !matches!(item.preset.as_str(), "comfort" | "sleep" | "away" | "custom") {
|
||||
return Err(AppError::BadRequest("import contains an invalid schedule preset".into()));
|
||||
NaiveTime::parse_from_str(&item.start_time, "%H:%M").map_err(|_| {
|
||||
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())
|
||||
})?;
|
||||
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) {
|
||||
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)?;
|
||||
@@ -131,17 +208,27 @@ fn validate_configuration_groups<'a>(
|
||||
ids: &ConfigurationIds<'_>,
|
||||
) -> Result<std::collections::HashSet<&'a str>, AppError> {
|
||||
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.zone_ids.is_empty()
|
||||
|| 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() {
|
||||
return Err(AppError::BadRequest("import contains duplicate group IDs".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"import contains duplicate group IDs".into(),
|
||||
));
|
||||
}
|
||||
Ok(groups)
|
||||
}
|
||||
@@ -153,42 +240,78 @@ fn validate_configuration_automation_trigger(
|
||||
match item.trigger_kind.as_str() {
|
||||
"temperature_above" | "temperature_below" => {
|
||||
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() {
|
||||
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" => {
|
||||
let at = item.at_time.as_deref()
|
||||
.ok_or_else(|| 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()))?;
|
||||
let at = item.at_time.as_deref().ok_or_else(|| {
|
||||
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())
|
||||
})?;
|
||||
}
|
||||
"flow" => {
|
||||
if item.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()));
|
||||
if item
|
||||
.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(())
|
||||
}
|
||||
|
||||
fn validate_configuration_zone_automation(item: &Automation, ids: &ConfigurationIds<'_>) -> Result<(), AppError> {
|
||||
let Some(zone_id) = item.action_zone_id.as_deref().filter(|value| !value.is_empty()) else { return Ok(()); };
|
||||
fn validate_configuration_zone_automation(
|
||||
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) {
|
||||
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 !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")
|
||||
&& 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(())
|
||||
}
|
||||
@@ -197,30 +320,51 @@ fn validate_configuration_group_automation(
|
||||
item: &Automation,
|
||||
groups: &std::collections::HashSet<&str>,
|
||||
) -> 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) {
|
||||
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 !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 !matches!(preset, "auto" | "comfort" | "sleep" | "away") && !(flow_custom_group && preset == "custom") {
|
||||
return Err(AppError::BadRequest("import contains an invalid group automation preset".into()));
|
||||
if !matches!(preset, "auto" | "comfort" | "sleep" | "away")
|
||||
&& !(flow_custom_group && preset == "custom")
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"import contains an invalid group automation preset".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if flow_custom_group {
|
||||
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) {
|
||||
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() {
|
||||
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()
|
||||
|| item.action.swing_vertical.is_some()
|
||||
@@ -233,13 +377,21 @@ fn validate_configuration_group_automation(
|
||||
|| item.action.health.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()
|
||||
&& 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(())
|
||||
}
|
||||
@@ -250,14 +402,18 @@ fn validate_configuration_shared_inputs(
|
||||
groups: &std::collections::HashSet<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
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 {
|
||||
SharedInputResourceReference::Device(id) => ids.devices.contains(id.as_str()),
|
||||
SharedInputResourceReference::Zone(id) => ids.zones.contains(id.as_str()),
|
||||
SharedInputResourceReference::Group(id) => groups.contains(id.as_str()),
|
||||
};
|
||||
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(())
|
||||
@@ -270,17 +426,29 @@ fn validate_configuration_automations(
|
||||
) -> Result<(), AppError> {
|
||||
for item in &export.automations {
|
||||
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)?;
|
||||
} 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)?;
|
||||
} else {
|
||||
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)?;
|
||||
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) {
|
||||
let now = Utc::now();
|
||||
for device in &mut export.devices { sanitize_imported_device(device, now.clone()); }
|
||||
for zone in &mut export.zones { sanitize_imported_zone(zone, now.clone()); }
|
||||
for device in &mut export.devices {
|
||||
sanitize_imported_device(device, now.clone());
|
||||
}
|
||||
for zone in &mut export.zones {
|
||||
sanitize_imported_zone(zone, now.clone());
|
||||
}
|
||||
for automation in &mut export.automations {
|
||||
automation.last_fired_at = None;
|
||||
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.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()))?;
|
||||
|
||||
let current_notifications = settings.notifications.clone();
|
||||
settings.notifications = apply_notification_update(¤t_notifications, NotificationSettingsUpdate {
|
||||
enabled: current_notifications.enabled,
|
||||
mode: current_notifications.mode.clone(),
|
||||
provider: current_notifications.provider.clone(),
|
||||
pushover_app_token: Some(current_notifications.pushover_app_token.clone()),
|
||||
pushover_user_key: Some(current_notifications.pushover_user_key.clone()),
|
||||
slack_webhook_url: Some(current_notifications.slack_webhook_url.clone()),
|
||||
discord_webhook_url: Some(current_notifications.discord_webhook_url.clone()),
|
||||
cooldown_seconds: current_notifications.cooldown_seconds,
|
||||
communication_failure_threshold: current_notifications.communication_failure_threshold,
|
||||
target_timeout_minutes: current_notifications.target_timeout_minutes,
|
||||
alert_types: current_notifications.alert_types.clone(),
|
||||
})?;
|
||||
settings.notifications = apply_notification_update(
|
||||
¤t_notifications,
|
||||
NotificationSettingsUpdate {
|
||||
enabled: current_notifications.enabled,
|
||||
mode: current_notifications.mode.clone(),
|
||||
provider: current_notifications.provider.clone(),
|
||||
pushover_app_token: Some(current_notifications.pushover_app_token.clone()),
|
||||
pushover_user_key: Some(current_notifications.pushover_user_key.clone()),
|
||||
slack_webhook_url: Some(current_notifications.slack_webhook_url.clone()),
|
||||
discord_webhook_url: Some(current_notifications.discord_webhook_url.clone()),
|
||||
cooldown_seconds: current_notifications.cooldown_seconds,
|
||||
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_flow_shared_inputs(&mut settings.home_assistant)?;
|
||||
canonicalize_home_assistant_entities(&mut settings.home_assistant);
|
||||
@@ -428,23 +607,34 @@ async fn lock_configuration_resources(
|
||||
current_devices: &[Device],
|
||||
export: &ConfigurationExport,
|
||||
) -> 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()))
|
||||
.collect();
|
||||
zone_ids.sort();
|
||||
zone_ids.dedup();
|
||||
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()))
|
||||
.collect();
|
||||
device_ids.sort();
|
||||
device_ids.dedup();
|
||||
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(
|
||||
@@ -452,37 +642,71 @@ async fn power_off_detached_devices(
|
||||
current_zones: &[Zone],
|
||||
export: &ConfigurationExport,
|
||||
) -> 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()))
|
||||
.collect();
|
||||
let detach_devices: std::collections::HashSet<String> = current_zones.iter()
|
||||
.filter(|current| imported_zone_map.get(¤t.id).map(String::as_str) != Some(current.device_id.as_str()))
|
||||
let detach_devices: std::collections::HashSet<String> = current_zones
|
||||
.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())
|
||||
.collect();
|
||||
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 {
|
||||
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?;
|
||||
state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({
|
||||
"device_id": device.id, "source": "configuration.import"
|
||||
}));
|
||||
state.log(
|
||||
"info",
|
||||
"zone.detach_power_off",
|
||||
&format!(
|
||||
"Powered off {} before detaching thermostat ownership",
|
||||
device.name
|
||||
),
|
||||
json!({
|
||||
"device_id": device.id, "source": "configuration.import"
|
||||
}),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reconcile_imported_devices(state: &AppState, export: &ConfigurationExport) -> Result<(), AppError> {
|
||||
let controllable_devices: std::collections::HashSet<String> = export.zones.iter()
|
||||
async fn reconcile_imported_devices(
|
||||
state: &AppState,
|
||||
export: &ConfigurationExport,
|
||||
) -> Result<(), AppError> {
|
||||
let controllable_devices: std::collections::HashSet<String> = export
|
||||
.zones
|
||||
.iter()
|
||||
.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"
|
||||
})
|
||||
.map(|zone| zone.device_id.clone())
|
||||
.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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -504,18 +728,30 @@ async fn import_configuration(
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let current_zones = state.db.list_zones()?;
|
||||
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?;
|
||||
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.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();
|
||||
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.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()}));
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
+19
-6
@@ -3,7 +3,9 @@ struct CreateAccessTokenRequest {
|
||||
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()?))
|
||||
}
|
||||
|
||||
@@ -11,9 +13,15 @@ async fn create_access_token(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CreateAccessTokenRequest>,
|
||||
) -> 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 {
|
||||
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();
|
||||
@@ -30,10 +38,16 @@ async fn create_access_token(
|
||||
"Created a Home Assistant access token",
|
||||
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)? {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+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 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 protocol_version = request.protocol_version.unwrap_or(0).min(2);
|
||||
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()))?;
|
||||
let mut saved = 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) => {
|
||||
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)?;
|
||||
if is_new { new_device_ids.push(merged.id.clone()); }
|
||||
if is_new {
|
||||
new_device_ids.push(merged.id.clone());
|
||||
}
|
||||
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.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> {
|
||||
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;
|
||||
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()));
|
||||
}
|
||||
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() {
|
||||
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 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,
|
||||
};
|
||||
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)?);
|
||||
Ok((StatusCode::CREATED, Json(device)))
|
||||
}
|
||||
|
||||
async fn get_device(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 get_device(
|
||||
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;
|
||||
if patch.enabled == Some(false) {
|
||||
engine::disable_device_safely(&state, &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}")))?;
|
||||
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; }
|
||||
let mut device = state
|
||||
.db
|
||||
.get_device(&id)?
|
||||
.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 {
|
||||
let v = v.min(2);
|
||||
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;
|
||||
}
|
||||
}
|
||||
if let Some(v) = patch.key { device.key = v.filter(|x| !x.trim().is_empty()); }
|
||||
if let Some(v) = patch.enabled { device.enabled = v; }
|
||||
if let Some(v) = patch.key {
|
||||
device.key = v.filter(|x| !x.trim().is_empty());
|
||||
}
|
||||
if let Some(v) = patch.enabled {
|
||||
device.enabled = v;
|
||||
}
|
||||
device.updated_at = Utc::now();
|
||||
state.db.save_device(&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))
|
||||
}
|
||||
|
||||
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;
|
||||
// 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.
|
||||
@@ -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 _schedule_guard = state.lock_schedule_operation().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| {
|
||||
item.trigger_device_id.as_deref() == Some(id.as_str())
|
||||
|| (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)
|
||||
.map(|zone| zone.id)
|
||||
.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_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);
|
||||
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}));
|
||||
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 _device_guard = state.lock_device_operation(&id).await;
|
||||
let mut device = state.db.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()))?;
|
||||
let mut device = state
|
||||
.db
|
||||
.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.protocol_version = bound.protocol_version;
|
||||
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.updated_at = Utc::now();
|
||||
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))
|
||||
}
|
||||
|
||||
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?))
|
||||
}
|
||||
|
||||
async fn probe_device(State(state): State<AppState>, 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()))?;
|
||||
async fn probe_device(
|
||||
State(state): State<AppState>,
|
||||
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!({
|
||||
"device_id": device.id,
|
||||
"response_time_ms": response_time_ms,
|
||||
@@ -226,23 +337,36 @@ struct ManualDeviceCommandRequest {
|
||||
manual_override: bool,
|
||||
}
|
||||
|
||||
async fn command_device(State(state): State<AppState>, Path(id): Path<String>, Json(request): Json<ManualDeviceCommandRequest>) -> Result<Json<Device>, AppError> {
|
||||
Ok(Json(engine::send_manual_command(
|
||||
&state,
|
||||
&id,
|
||||
request.command,
|
||||
"device.manual_control",
|
||||
request.manual_override,
|
||||
).await?))
|
||||
async fn command_device(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(request): Json<ManualDeviceCommandRequest>,
|
||||
) -> Result<Json<Device>, AppError> {
|
||||
Ok(Json(
|
||||
engine::send_manual_command(
|
||||
&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> {
|
||||
Ok(Json(engine::send_manual_command(
|
||||
&state,
|
||||
&id,
|
||||
command,
|
||||
"home_assistant.device_manual_control",
|
||||
false,
|
||||
).await?))
|
||||
async fn command_home_assistant_device(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(command): Json<DeviceCommand>,
|
||||
) -> Result<Json<Device>, AppError> {
|
||||
Ok(Json(
|
||||
engine::send_manual_command(
|
||||
&state,
|
||||
&id,
|
||||
command,
|
||||
"home_assistant.device_manual_control",
|
||||
false,
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
+10
-3
@@ -1,5 +1,12 @@
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EventsQuery { limit: Option<u32> }
|
||||
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))?})))
|
||||
struct EventsQuery {
|
||||
limit: Option<u32>,
|
||||
}
|
||||
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> {
|
||||
let mut values: Vec<String> = zone_ids.into_iter()
|
||||
let mut values: Vec<String> = zone_ids
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.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());
|
||||
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 {
|
||||
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)
|
||||
@@ -37,11 +42,21 @@ async fn list_groups(State(state): State<AppState>) -> Result<Json<Vec<ClimateGr
|
||||
Ok(Json(state.db.list_groups()?))
|
||||
}
|
||||
|
||||
async fn get_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<ClimateGroup>, AppError> {
|
||||
state.db.get_group(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("group {id}")))
|
||||
async fn get_group(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ClimateGroup>, AppError> {
|
||||
state
|
||||
.db
|
||||
.get_group(&id)?
|
||||
.map(Json)
|
||||
.ok_or_else(|| AppError::NotFound(format!("group {id}")))
|
||||
}
|
||||
|
||||
async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInput>) -> Result<(StatusCode, Json<ClimateGroup>), AppError> {
|
||||
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 _reference_guard = state.lock_automation_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)))
|
||||
}
|
||||
|
||||
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;
|
||||
// Membership changes alter the target set of group automations, so serialize them with
|
||||
// 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 _cycle_guard = state.lock_zone_control_cycle().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 group = ClimateGroup {
|
||||
id,
|
||||
@@ -86,46 +108,90 @@ async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Jso
|
||||
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 _automation_guard = state.lock_automation_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().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())) {
|
||||
return Err(AppError::BadRequest("group is used by an automation; remove or retarget that automation first".into()));
|
||||
if state
|
||||
.db
|
||||
.list_automations()?
|
||||
.iter()
|
||||
.any(|item| item.action_group_id.as_deref() == Some(id.as_str()))
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"group is used by an automation; remove or retarget that automation first".into(),
|
||||
));
|
||||
}
|
||||
if !state.db.delete_group(&id)? {
|
||||
return Err(AppError::NotFound(format!("group {id}")));
|
||||
}
|
||||
if !state.db.delete_group(&id)? { return Err(AppError::NotFound(format!("group {id}"))); }
|
||||
state.broadcast("group.deleted", json!({"id": id}));
|
||||
state.wake_zone_control();
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
fn ensure_zone_removal_safe(state: &AppState, 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()
|
||||
fn ensure_zone_removal_safe(
|
||||
state: &AppState,
|
||||
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)
|
||||
.collect();
|
||||
for group in state.db.list_groups()? {
|
||||
let remaining = group.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) {
|
||||
let remaining = group
|
||||
.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)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_zone_ids_from_groups_locked(state: &AppState, 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();
|
||||
async fn remove_zone_ids_from_groups_locked(
|
||||
state: &AppState,
|
||||
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.dedup();
|
||||
for group_id in group_ids {
|
||||
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();
|
||||
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() {
|
||||
state.db.delete_group(&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(())
|
||||
}
|
||||
|
||||
async fn update_group_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<GroupControlPatch>) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(engine::control_group(&state, &id, patch, "group.quick_control").await?))
|
||||
async fn update_group_control(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(patch): Json<GroupControlPatch>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(
|
||||
engine::control_group(&state, &id, patch, "group.quick_control").await?,
|
||||
))
|
||||
}
|
||||
|
||||
fn home_assistant_group_mode(zones: &[&Zone]) -> String {
|
||||
let mut value: Option<&str> = None;
|
||||
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") {
|
||||
return "mixed".into();
|
||||
}
|
||||
if let Some(previous) = value {
|
||||
if previous != current { return "mixed".into(); }
|
||||
if previous != current {
|
||||
return "mixed".into();
|
||||
}
|
||||
} else {
|
||||
value = Some(current);
|
||||
}
|
||||
@@ -166,7 +244,9 @@ fn home_assistant_group_preset(zones: &[&Zone]) -> String {
|
||||
return "mixed".into();
|
||||
}
|
||||
if let Some(previous) = value {
|
||||
if previous != current { return "mixed".into(); }
|
||||
if previous != current {
|
||||
return "mixed".into();
|
||||
}
|
||||
} else {
|
||||
value = Some(current);
|
||||
}
|
||||
@@ -174,14 +254,17 @@ fn home_assistant_group_preset(zones: &[&Zone]) -> String {
|
||||
value.unwrap_or("mixed").to_string()
|
||||
}
|
||||
|
||||
|
||||
fn home_assistant_group_custom_setpoint(zones: &[&Zone]) -> Option<f64> {
|
||||
let mut value: Option<f64> = None;
|
||||
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)?;
|
||||
if let Some(previous) = value {
|
||||
if (previous - current).abs() > 0.05 { return None; }
|
||||
if (previous - current).abs() > 0.05 {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
value = Some(current);
|
||||
}
|
||||
@@ -189,7 +272,9 @@ fn home_assistant_group_custom_setpoint(zones: &[&Zone]) -> Option<f64> {
|
||||
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 zones = state.db.list_zones()?;
|
||||
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());
|
||||
|
||||
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))
|
||||
.collect::<Vec<_>>();
|
||||
let planned_members = plan.zones.iter()
|
||||
.filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.zone_id))
|
||||
let planned_members = plan
|
||||
.zones
|
||||
.iter()
|
||||
.filter(|zone| {
|
||||
group
|
||||
.zone_ids
|
||||
.iter()
|
||||
.any(|zone_id| zone_id == &zone.zone_id)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let zone_names = members.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()
|
||||
let zone_names = members
|
||||
.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)
|
||||
.count();
|
||||
let current_temperatures = planned_members.iter()
|
||||
let current_temperatures = planned_members
|
||||
.iter()
|
||||
.filter_map(|zone| zone.current_temperature)
|
||||
.collect::<Vec<_>>();
|
||||
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.truncate(8);
|
||||
let member_states = planned_members.iter().map(|zone| json!({
|
||||
"zone_id": zone.zone_id,
|
||||
"zone_name": zone.zone_name,
|
||||
"device_id": zone.device_id,
|
||||
"device_name": zone.device_name,
|
||||
"enabled": zone.enabled,
|
||||
"effective_enabled": zone.effective_enabled,
|
||||
"mode": zone.mode,
|
||||
"configured_mode": zone.configured_mode,
|
||||
"inherit_house_mode": zone.inherit_house_mode,
|
||||
"preset": zone.preset,
|
||||
"current_temperature": zone.current_temperature,
|
||||
"target_temperature": zone.target_temperature,
|
||||
"demand": zone.demand,
|
||||
"control_source": zone.control_source,
|
||||
"current_schedule": zone.current_schedule_name,
|
||||
"local_thermostat_power": zone.local_thermostat_power,
|
||||
"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<_>>();
|
||||
let member_states = planned_members
|
||||
.iter()
|
||||
.map(|zone| {
|
||||
json!({
|
||||
"zone_id": zone.zone_id,
|
||||
"zone_name": zone.zone_name,
|
||||
"device_id": zone.device_id,
|
||||
"device_name": zone.device_name,
|
||||
"enabled": zone.enabled,
|
||||
"effective_enabled": zone.effective_enabled,
|
||||
"mode": zone.mode,
|
||||
"configured_mode": zone.configured_mode,
|
||||
"inherit_house_mode": zone.inherit_house_mode,
|
||||
"preset": zone.preset,
|
||||
"current_temperature": zone.current_temperature,
|
||||
"target_temperature": zone.target_temperature,
|
||||
"demand": zone.demand,
|
||||
"control_source": zone.control_source,
|
||||
"current_schedule": zone.current_schedule_name,
|
||||
"local_thermostat_power": zone.local_thermostat_power,
|
||||
"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!({
|
||||
"id": group.id,
|
||||
@@ -281,6 +387,7 @@ async fn update_home_assistant_group_control(
|
||||
Path(id): Path<String>,
|
||||
Json(patch): Json<GroupControlPatch>,
|
||||
) -> 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)]
|
||||
struct ReadingsQuery { 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> {
|
||||
struct ReadingsQuery {
|
||||
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 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})))
|
||||
}
|
||||
|
||||
@@ -29,24 +40,27 @@ fn history_bucket_seconds(hours: i64) -> i64 {
|
||||
}
|
||||
|
||||
fn fallback_zone_rows(zone: &Zone, device: &Device, readings: Vec<Reading>) -> Vec<ZoneReading> {
|
||||
readings.into_iter().map(|reading| ZoneReading {
|
||||
id: reading.id,
|
||||
zone_id: zone.id.clone(),
|
||||
device_id: zone.device_id.clone(),
|
||||
timestamp: reading.timestamp,
|
||||
gree_temperature: reading.indoor_temperature,
|
||||
external_temperature: None,
|
||||
control_temperature: reading.indoor_temperature,
|
||||
target_temperature: Some(reading.target_temperature),
|
||||
device_setpoint: Some(reading.target_temperature),
|
||||
outdoor_temperature: reading.outdoor_temperature,
|
||||
power: reading.power,
|
||||
mode: device.mode.clone(),
|
||||
fan_speed: device.fan_speed,
|
||||
demand: false,
|
||||
control_source: "gree_history_fallback".into(),
|
||||
active_preset: "history".into(),
|
||||
}).collect()
|
||||
readings
|
||||
.into_iter()
|
||||
.map(|reading| ZoneReading {
|
||||
id: reading.id,
|
||||
zone_id: zone.id.clone(),
|
||||
device_id: zone.device_id.clone(),
|
||||
timestamp: reading.timestamp,
|
||||
gree_temperature: reading.indoor_temperature,
|
||||
external_temperature: None,
|
||||
control_temperature: reading.indoor_temperature,
|
||||
target_temperature: Some(reading.target_temperature),
|
||||
device_setpoint: Some(reading.target_temperature),
|
||||
outdoor_temperature: reading.outdoor_temperature,
|
||||
power: reading.power,
|
||||
mode: device.mode.clone(),
|
||||
fan_speed: device.fan_speed,
|
||||
demand: false,
|
||||
control_source: "gree_history_fallback".into(),
|
||||
active_preset: "history".into(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn zone_history_with_fallback(
|
||||
@@ -56,23 +70,43 @@ fn zone_history_with_fallback(
|
||||
bucket_seconds: i64,
|
||||
limit: u32,
|
||||
) -> 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 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)? {
|
||||
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);
|
||||
}
|
||||
}
|
||||
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()? {
|
||||
if existing.contains(&zone.id) { continue; }
|
||||
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)?;
|
||||
if existing.contains(&zone.id) {
|
||||
continue;
|
||||
}
|
||||
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.sort_by(|left, right| left.timestamp.cmp(&right.timestamp));
|
||||
@@ -90,33 +124,68 @@ fn sensor_history_with_fallback(
|
||||
limit: u32,
|
||||
outdoor_entity: &str,
|
||||
) -> Result<Vec<HaReading>, AppError> {
|
||||
let mut values = state.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();
|
||||
let mut values = state
|
||||
.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()? {
|
||||
let Some(entity_id) = zone.ha_entity_id.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 Some(entity_id) = zone
|
||||
.ha_entity_id
|
||||
.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;
|
||||
for row in rows {
|
||||
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;
|
||||
}
|
||||
}
|
||||
if added { existing.insert(entity_id.to_string()); }
|
||||
if added {
|
||||
existing.insert(entity_id.to_string());
|
||||
}
|
||||
}
|
||||
let outdoor_entity = outdoor_entity.trim();
|
||||
if !outdoor_entity.is_empty() && !existing.contains(outdoor_entity) {
|
||||
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;
|
||||
for row in rows {
|
||||
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;
|
||||
}
|
||||
}
|
||||
if added { break; }
|
||||
if added {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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 cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
|
||||
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 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,
|
||||
Err(err) => {
|
||||
warning = Some(err.to_string());
|
||||
state.log("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)?
|
||||
state.log(
|
||||
"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() {
|
||||
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);
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -167,23 +267,52 @@ async fn combined_zone_history(
|
||||
let influx = state.settings.read().await.influxdb.clone();
|
||||
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
|
||||
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 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,
|
||||
Err(err) => {
|
||||
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)?
|
||||
}
|
||||
};
|
||||
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);
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -198,18 +327,38 @@ async fn combined_sensor_history(
|
||||
let influx = state.settings.read().await.influxdb.clone();
|
||||
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
|
||||
let local = |start| -> Result<Vec<HaReading>, AppError> {
|
||||
if entity_id.is_some() { 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 entity_id.is_some() {
|
||||
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 {
|
||||
return Ok((local(since)?, "sqlite".into(), 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,
|
||||
Err(err) => {
|
||||
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)?
|
||||
}
|
||||
};
|
||||
@@ -218,7 +367,11 @@ async fn combined_sensor_history(
|
||||
}
|
||||
values.sort_by_key(|row| row.timestamp);
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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 since = Utc::now() - ChronoDuration::hours(hours);
|
||||
let bucket_seconds = history_bucket_seconds(hours);
|
||||
let limit = query.limit.unwrap_or(12_000).clamp(1, 20_000);
|
||||
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()?;
|
||||
|
||||
match scope {
|
||||
"devices" => {
|
||||
let device_id = query.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?;
|
||||
let device_id = query
|
||||
.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!({
|
||||
"scope": "devices", "readings": readings, "bucket_seconds": bucket_seconds,
|
||||
"storage": storage, "storage_warning": warning,
|
||||
@@ -249,8 +415,19 @@ async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery
|
||||
})))
|
||||
}
|
||||
"sensors" => {
|
||||
let entity_id = query.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?;
|
||||
let entity_id = query
|
||||
.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!({
|
||||
"scope": "sensors", "readings": readings, "bucket_seconds": bucket_seconds,
|
||||
"storage": storage, "storage_warning": warning,
|
||||
@@ -258,9 +435,19 @@ async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery
|
||||
})))
|
||||
}
|
||||
"overview" => {
|
||||
let (zones, zone_storage, zone_warning) = combined_zone_history(&state, None, since, bucket_seconds, limit).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 (zones, zone_storage, zone_warning) =
|
||||
combined_zone_history(&state, None, since, bucket_seconds, limit).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]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -274,24 +461,31 @@ async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery
|
||||
})))
|
||||
}
|
||||
"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 state.db.get_zone(zone_id)?.is_none() {
|
||||
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!({
|
||||
"scope": "zones", "readings": readings, "bucket_seconds": bucket_seconds,
|
||||
"storage": storage, "storage_warning": warning,
|
||||
"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> {
|
||||
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)]
|
||||
struct HouseControlPatch { mode: String }
|
||||
|
||||
struct HouseControlPatch {
|
||||
mode: String,
|
||||
}
|
||||
|
||||
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.dedup();
|
||||
for zone_id in zone_ids {
|
||||
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
|
||||
|| zone.local_thermostat_power.is_some()
|
||||
|| zone.control_source.starts_with("group:")
|
||||
|| engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
|
||||
if scoped_manual { 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; }
|
||||
if scoped_manual {
|
||||
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);
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
@@ -25,15 +40,24 @@ async fn rearm_house_automation_compressor_queues(state: &AppState) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
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.dedup();
|
||||
let mut changed = 0usize;
|
||||
for zone_id in zone_ids {
|
||||
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);
|
||||
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);
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
@@ -43,10 +67,16 @@ async fn set_all_thermostat_power_state(state: &AppState, power: bool) -> Result
|
||||
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();
|
||||
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.
|
||||
// OFF is immediate; ON still respects compressor protection.
|
||||
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
|
||||
};
|
||||
if let Err(err) = result {
|
||||
state.log("error", "house.power_all_error", &err.to_string(), json!({
|
||||
"device_id": device.id,
|
||||
"device_name": device.name,
|
||||
"power": power,
|
||||
"source": source,
|
||||
}));
|
||||
state.log(
|
||||
"error",
|
||||
"house.power_all_error",
|
||||
&err.to_string(),
|
||||
json!({
|
||||
"device_id": device.id,
|
||||
"device_name": device.name,
|
||||
"power": power,
|
||||
"source": source,
|
||||
}),
|
||||
);
|
||||
failed.push(json!({
|
||||
"device_id": device.id,
|
||||
"device_name": device.name,
|
||||
@@ -71,14 +106,19 @@ async fn command_all_enabled_devices_power(state: &AppState, power: bool, source
|
||||
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;
|
||||
// Serialize the ownership/configuration transition against an already-running thermostat
|
||||
// cycle. Otherwise a cycle that captured the previous house mode could send one stale
|
||||
// climate command after this interactive change.
|
||||
let cycle_guard = state.lock_zone_control_cycle().await;
|
||||
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 activate_all = mode != "off";
|
||||
@@ -101,14 +141,24 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
|
||||
} else {
|
||||
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))
|
||||
}
|
||||
|
||||
#[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 _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 groups = state.db.list_groups()?;
|
||||
state.log("info", "house.power_all", if input.power {
|
||||
"Whole-house ON sent; local OFF state released and house thermostat intent armed"
|
||||
} else {
|
||||
"Whole-house OFF sent; all thermostats left locally OFF until explicitly re-enabled"
|
||||
}, json!({
|
||||
"power": input.power,
|
||||
"failed": failed.len(),
|
||||
"changed_zones": changed_zones,
|
||||
"one_shot": true,
|
||||
"persistent_global_gate": false,
|
||||
}));
|
||||
state.log(
|
||||
"info",
|
||||
"house.power_all",
|
||||
if input.power {
|
||||
"Whole-house ON sent; local OFF state released and house thermostat intent armed"
|
||||
} else {
|
||||
"Whole-house OFF sent; all thermostats left locally OFF until explicitly re-enabled"
|
||||
},
|
||||
json!({
|
||||
"power": input.power,
|
||||
"failed": failed.len(),
|
||||
"changed_zones": changed_zones,
|
||||
"one_shot": true,
|
||||
"persistent_global_gate": false,
|
||||
}),
|
||||
);
|
||||
Ok(Json(json!({
|
||||
"power": input.power,
|
||||
"one_shot": true,
|
||||
@@ -146,13 +201,20 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
|
||||
}
|
||||
|
||||
#[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 cycle_guard = state.lock_zone_control_cycle().await;
|
||||
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
|
||||
@@ -160,7 +222,12 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
|
||||
rearm_house_automation_compressor_queues(&state).await?;
|
||||
|
||||
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.dedup();
|
||||
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());
|
||||
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 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
|
||||
|| zone.local_thermostat_power.is_some()
|
||||
|| zone.control_source.starts_with("group:")
|
||||
@@ -188,7 +259,8 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
|
||||
} else {
|
||||
zone.manual_preset = Some(input.preset.clone());
|
||||
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();
|
||||
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.
|
||||
let mut failed: Vec<Value> = Vec::new();
|
||||
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()}));
|
||||
}
|
||||
let devices = state.db.list_devices()?;
|
||||
state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({
|
||||
"preset": input.preset,
|
||||
"failed": failed.len(),
|
||||
}));
|
||||
state.log(
|
||||
"info",
|
||||
"house.preset",
|
||||
&format!("House preset set to {}", input.preset),
|
||||
json!({
|
||||
"preset": input.preset,
|
||||
"failed": failed.len(),
|
||||
}),
|
||||
);
|
||||
Ok(Json(json!({
|
||||
"preset": input.preset,
|
||||
"zones": zones,
|
||||
@@ -222,22 +304,41 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
|
||||
}
|
||||
|
||||
#[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 _schedule_guard = state.lock_schedule_operation().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 add = |name: &str, days: Vec<u32>, start: &str, end: &str, preset: &str| {
|
||||
items.push(Schedule {
|
||||
id: Uuid::new_v4().to_string(), zone_id: id.clone(), 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,
|
||||
id: Uuid::new_v4().to_string(),
|
||||
zone_id: id.clone(),
|
||||
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() {
|
||||
"family" => {
|
||||
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");
|
||||
}
|
||||
"workday" => {
|
||||
let weekdays = vec![1,2,3,4,5];
|
||||
let weekend = vec![6,7];
|
||||
let weekdays = vec![1, 2, 3, 4, 5];
|
||||
let weekend = vec![6, 7];
|
||||
add("Morning", weekdays.clone(), "06:30", "08:00", "comfort");
|
||||
add("Away", weekdays.clone(), "08:00", "16:00", "away");
|
||||
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)?;
|
||||
state.db.replace_schedules_for_zone(&id, &items)?;
|
||||
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();
|
||||
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> {
|
||||
Ok(Json(apply_zone_control_patch(&state, &id, patch, "home_assistant.zone_thermostat").await?))
|
||||
async fn update_home_assistant_zone_control(
|
||||
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 _automation_guard = state.lock_automation_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().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();
|
||||
removed.insert(id.clone());
|
||||
ensure_zone_removal_safe(&state, &removed)?;
|
||||
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 locks so deletion cannot form the inverse zone -> group lock order.
|
||||
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}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
|
||||
+62
-22
@@ -1,25 +1,53 @@
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HaTestRequest { entity_id: Option<String> }
|
||||
async fn test_home_assistant(State(state): State<AppState>, Json(input): Json<HaTestRequest>) -> Result<Json<Value>, AppError> {
|
||||
struct HaTestRequest {
|
||||
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 resolved_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, input.entity_id.as_deref());
|
||||
let temperature = home_assistant::read_temperature(&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})))
|
||||
let resolved_entity_id =
|
||||
home_assistant::resolve_entity_id(&settings.home_assistant, input.entity_id.as_deref());
|
||||
let temperature = home_assistant::read_temperature(
|
||||
&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)]
|
||||
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 entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(input.entity_id.as_str()))
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| AppError::BadRequest("Home Assistant entity_id is required".into()))?;
|
||||
let payload = home_assistant::read_entity(&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 entity_id =
|
||||
home_assistant::resolve_entity_id(&settings.home_assistant, Some(input.entity_id.as_str()))
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| AppError::BadRequest("Home Assistant entity_id is required".into()))?;
|
||||
let payload = home_assistant::read_entity(
|
||||
&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" | "");
|
||||
Ok(Json(json!({
|
||||
"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();
|
||||
if input.pushover_app_token.trim().is_empty() { input.pushover_app_token = old.pushover_app_token; }
|
||||
if input.pushover_user_key.trim().is_empty() { 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)?;
|
||||
if input.pushover_app_token.trim().is_empty() {
|
||||
input.pushover_app_token = old.pushover_app_token;
|
||||
}
|
||||
if input.pushover_user_key.trim().is_empty() {
|
||||
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})))
|
||||
}
|
||||
|
||||
|
||||
+22
-5
@@ -5,16 +5,33 @@ async fn security_headers(request: Request, next: Next) -> Response {
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.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();
|
||||
headers.insert(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"));
|
||||
headers.insert(
|
||||
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 {
|
||||
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("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 {
|
||||
|
||||
@@ -61,4 +61,3 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+130
-34
@@ -11,39 +11,82 @@ struct ScheduleInput {
|
||||
preset: String,
|
||||
setpoint: f64,
|
||||
}
|
||||
fn schedule_preset() -> String { "custom".into() }
|
||||
fn schedule_preset() -> String {
|
||||
"custom".into()
|
||||
}
|
||||
impl ScheduleInput {
|
||||
fn validate(&self) -> Result<(), AppError> {
|
||||
if self.name.trim().is_empty() { return Err(AppError::BadRequest("schedule name is required".into())); }
|
||||
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())); }
|
||||
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())); }
|
||||
if self.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("schedule name is required".into()));
|
||||
}
|
||||
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(),
|
||||
));
|
||||
}
|
||||
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(())
|
||||
}
|
||||
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,
|
||||
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 }
|
||||
Schedule {
|
||||
id,
|
||||
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> {
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
for other in items.iter().skip(index + 1) {
|
||||
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(())
|
||||
}
|
||||
|
||||
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()? {
|
||||
if exclude_id == Some(existing.id.as_str()) { continue; }
|
||||
if exclude_id == Some(existing.id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
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(())
|
||||
@@ -57,11 +100,21 @@ async fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Resu
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); };
|
||||
let has_temporary_schedule_boundary = zone.temporary_quick_thermostat.as_ref()
|
||||
let Some(mut zone) = state.db.get_zone(zone_id)? else {
|
||||
return Ok(());
|
||||
};
|
||||
let has_temporary_schedule_boundary = zone
|
||||
.temporary_quick_thermostat
|
||||
.as_ref()
|
||||
.map(|session| session.finish_kind == "schedule_boundary")
|
||||
.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 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
|
||||
@@ -79,11 +132,14 @@ async fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Resu
|
||||
zone.control_resume_at = None;
|
||||
}
|
||||
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())
|
||||
.map(|session| session.started_at.with_timezone(&chrono::Local))
|
||||
.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() {
|
||||
session.expires_at = refreshed;
|
||||
}
|
||||
@@ -95,16 +151,30 @@ async fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_schedules(State(state): State<AppState>) -> Result<Json<Vec<Schedule>>, AppError> { Ok(Json(state.db.list_schedules()?)) }
|
||||
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 list_schedules(State(state): State<AppState>) -> Result<Json<Vec<Schedule>>, AppError> {
|
||||
Ok(Json(state.db.list_schedules()?))
|
||||
}
|
||||
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 _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
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());
|
||||
validate_schedule_conflicts(&state, &item, None)?;
|
||||
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();
|
||||
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 _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
input.validate()?;
|
||||
let existing = state.db.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 existing = state
|
||||
.db
|
||||
.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 item = input.into_schedule(id.clone(), existing.created_at);
|
||||
validate_schedule_conflicts(&state, &item, Some(&id))?;
|
||||
state.db.save_schedule(&item)?;
|
||||
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.wake_zone_control();
|
||||
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 _schedule_guard = state.lock_schedule_operation().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}")))?;
|
||||
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}"))); }
|
||||
let existing = state
|
||||
.db
|
||||
.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?;
|
||||
state.broadcast("schedule.deleted", json!({"id": id}));
|
||||
state.wake_zone_control();
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
|
||||
+208
-64
@@ -1,5 +1,7 @@
|
||||
fn application_settings(settings: &RuntimeSettings) -> ApplicationSettings {
|
||||
ApplicationSettings { simulator_enabled: settings.simulator_enabled }
|
||||
ApplicationSettings {
|
||||
simulator_enabled: settings.simulator_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
fn gree_settings(settings: &RuntimeSettings) -> GreeSettings {
|
||||
@@ -83,8 +85,16 @@ async fn update_application_settings(
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
application_settings(&settings)
|
||||
};
|
||||
state.log("info", "settings.application.updated", "Application settings updated", json!({"simulator_enabled": payload.simulator_enabled}));
|
||||
state.broadcast("settings.application.updated", serde_json::to_value(&payload)?);
|
||||
state.log(
|
||||
"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))
|
||||
}
|
||||
|
||||
@@ -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.compressor_protection_seconds = input.compressor_protection_seconds.clamp(30, 1800);
|
||||
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()))?;
|
||||
}
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
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.dedup();
|
||||
for zone_id in zone_ids {
|
||||
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()
|
||||
&& zone.compressor_cancelled_action.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 (payload, compressor_changed) = {
|
||||
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.controller_id = input.controller_id;
|
||||
settings.poll_interval_seconds = input.poll_interval_seconds;
|
||||
@@ -159,10 +182,15 @@ async fn update_gree_settings(
|
||||
if compressor_changed {
|
||||
clear_compressor_runtime_after_settings_change(&state).await?;
|
||||
}
|
||||
state.log("info", "settings.gree.updated", "GREE settings updated", json!({
|
||||
"compressor_protection_enabled": payload.compressor_protection_enabled,
|
||||
"compressor_protection_seconds": payload.compressor_protection_seconds
|
||||
}));
|
||||
state.log(
|
||||
"info",
|
||||
"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.wake_zone_control();
|
||||
Ok(Json(payload))
|
||||
@@ -188,11 +216,16 @@ async fn update_history_settings(
|
||||
history_settings(&settings)
|
||||
};
|
||||
let removed = state.db.prune_events(payload.event_retention_days as i64)?;
|
||||
state.log("info", "settings.history.updated", "History settings updated", json!({
|
||||
"retention_days": payload.retention_days,
|
||||
"event_retention_days": payload.event_retention_days,
|
||||
"event_rows_removed": removed
|
||||
}));
|
||||
state.log(
|
||||
"info",
|
||||
"settings.history.updated",
|
||||
"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)?);
|
||||
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))
|
||||
}
|
||||
|
||||
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 {
|
||||
enabled: input.enabled,
|
||||
version: input.version,
|
||||
@@ -214,8 +250,12 @@ fn apply_influxdb_update(current: &InfluxDbSettings, input: InfluxDbSettingsUpda
|
||||
token: current.token.clone(),
|
||||
history_threshold_days: input.history_threshold_days.clamp(1, 3650),
|
||||
};
|
||||
if let Some(password) = input.password { next.password = password; }
|
||||
if let Some(token) = input.token { next.token = token; }
|
||||
if let Some(password) = input.password {
|
||||
next.password = password;
|
||||
}
|
||||
if let Some(token) = input.token {
|
||||
next.token = token;
|
||||
}
|
||||
influxdb::validate(&next).map_err(|err| AppError::BadRequest(err.to_string()))?;
|
||||
Ok(next)
|
||||
}
|
||||
@@ -232,24 +272,38 @@ async fn update_influxdb_settings(
|
||||
state.db.save_runtime_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)?);
|
||||
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))
|
||||
}
|
||||
|
||||
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.communication_failure_threshold = input.communication_failure_threshold.clamp(2, 100);
|
||||
input.target_timeout_minutes = input.target_timeout_minutes.clamp(5, 24 * 60);
|
||||
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") {
|
||||
return Err(AppError::BadRequest("unsupported notification provider".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"unsupported notification provider".into(),
|
||||
));
|
||||
}
|
||||
let mut next = NotificationSettings {
|
||||
enabled: input.enabled,
|
||||
@@ -264,10 +318,18 @@ fn apply_notification_update(current: &NotificationSettings, mut input: Notifica
|
||||
target_timeout_minutes: input.target_timeout_minutes,
|
||||
alert_types: input.alert_types,
|
||||
};
|
||||
if let Some(value) = input.pushover_app_token { next.pushover_app_token = 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; }
|
||||
if let Some(value) = input.pushover_app_token {
|
||||
next.pushover_app_token = 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)
|
||||
}
|
||||
|
||||
@@ -283,8 +345,16 @@ async fn update_notification_settings(
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
notification_settings(&settings)
|
||||
};
|
||||
state.log("info", "settings.notifications.updated", "Notification settings updated", json!({"enabled": payload.enabled, "provider": payload.provider}));
|
||||
state.broadcast("settings.notifications.updated", serde_json::to_value(&payload)?);
|
||||
state.log(
|
||||
"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))
|
||||
}
|
||||
|
||||
@@ -313,24 +383,37 @@ async fn update_night_settings(
|
||||
settings.night_mode = input.clone();
|
||||
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.wake_zone_control();
|
||||
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))
|
||||
}
|
||||
|
||||
fn normalize_sensor_aliases(settings: &mut HomeAssistantSettings) {
|
||||
settings.sensor_aliases = settings.sensor_aliases
|
||||
settings.sensor_aliases = settings
|
||||
.sensor_aliases
|
||||
.iter()
|
||||
.filter_map(|(entity, alias)| {
|
||||
let entity = entity.trim();
|
||||
let alias = alias.trim();
|
||||
if entity.is_empty() || alias.is_empty() { return None; }
|
||||
Some((entity.chars().take(160).collect::<String>(), alias.chars().take(80).collect::<String>()))
|
||||
if entity.is_empty() || alias.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
entity.chars().take(160).collect::<String>(),
|
||||
alias.chars().take(80).collect::<String>(),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
@@ -338,39 +421,69 @@ fn normalize_sensor_aliases(settings: &mut HomeAssistantSettings) {
|
||||
fn normalize_flow_shared_inputs(settings: &mut HomeAssistantSettings) -> Result<(), AppError> {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
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 {
|
||||
item.id = item.id.trim().chars().take(120).collect();
|
||||
item.name = item.name.trim().chars().take(100).collect();
|
||||
item.kind = item.kind.trim().to_string();
|
||||
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()) {
|
||||
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(),
|
||||
"outdoor_temperature" | "device_temperature" | "zone_temperature" |
|
||||
"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 !matches!(
|
||||
item.kind.as_str(),
|
||||
"outdoor_temperature"
|
||||
| "device_temperature"
|
||||
| "zone_temperature"
|
||||
| "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() {
|
||||
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() {
|
||||
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() {
|
||||
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(())
|
||||
}
|
||||
|
||||
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)?;
|
||||
for item in &settings.flow_inputs {
|
||||
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();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
.map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?;
|
||||
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(())
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
async fn canonicalize_saved_zone_entities(state: &AppState, settings: &HomeAssistantSettings) -> Result<(), AppError> {
|
||||
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
|
||||
async fn canonicalize_saved_zone_entities(
|
||||
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.dedup();
|
||||
for zone_id in zone_ids {
|
||||
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 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();
|
||||
canonicalize_zone_ha_entity(&mut zone, settings);
|
||||
if zone.ha_entity_id != previous {
|
||||
@@ -440,7 +572,9 @@ fn apply_home_assistant_update(
|
||||
sensor_aliases: input.sensor_aliases,
|
||||
flow_inputs: input.flow_inputs,
|
||||
};
|
||||
if let Some(token) = input.token { next.token = token; }
|
||||
if let Some(token) = input.token {
|
||||
next.token = token;
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
@@ -464,11 +598,19 @@ async fn update_home_assistant_settings(
|
||||
};
|
||||
let saved = state.settings.read().await.home_assistant.clone();
|
||||
canonicalize_saved_zone_entities(&state, &saved).await?;
|
||||
state.log("info", "settings.home_assistant.updated", "Home Assistant settings updated", json!({
|
||||
"configured": payload.token_configured,
|
||||
"flow_inputs": payload.flow_inputs.len()
|
||||
}));
|
||||
state.broadcast("settings.home_assistant.updated", serde_json::to_value(&payload)?);
|
||||
state.log(
|
||||
"info",
|
||||
"settings.home_assistant.updated",
|
||||
"Home Assistant settings updated",
|
||||
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();
|
||||
Ok(Json(payload))
|
||||
}
|
||||
@@ -487,7 +629,9 @@ async fn update_debug_settings(
|
||||
settings.debug = input.clone();
|
||||
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)?);
|
||||
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,
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
+21
-6
@@ -1,17 +1,33 @@
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WsQuery { token: Option<String> }
|
||||
async fn websocket(State(state): State<AppState>, Query(query): Query<WsQuery>, ws: WebSocketUpgrade) -> Result<Response, AppError> {
|
||||
struct WsQuery {
|
||||
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();
|
||||
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)))
|
||||
}
|
||||
|
||||
async fn websocket_loop(state: AppState, mut socket: WebSocket) {
|
||||
let initial = match build_bootstrap(&state).await {
|
||||
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();
|
||||
loop {
|
||||
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
Reference in New Issue
Block a user