v0.6.0
This commit is contained in:
+96
-8
@@ -2,9 +2,9 @@ use std::{net::IpAddr, sync::atomic::Ordering, time::{Duration, Instant}};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}},
|
||||
http::{header, HeaderValue, StatusCode},
|
||||
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::{Html, Response},
|
||||
response::{Redirect, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
@@ -15,20 +15,22 @@ use rand::{rngs::OsRng, RngCore};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use serde_json::{json, Value};
|
||||
use tower_http::{compression::CompressionLayer, cors::CorsLayer, trace::TraceLayer};
|
||||
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
|
||||
use uuid::Uuid;
|
||||
use crate::{
|
||||
engine,
|
||||
error::AppError,
|
||||
home_assistant,
|
||||
influxdb,
|
||||
models::{ApiTokenInfo, Automation, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
|
||||
notifications,
|
||||
models::{ApiTokenInfo, Automation, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
|
||||
protocol::merge_discovered,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const INDEX_HTML: &str = include_str!("../web/index.html");
|
||||
const APP_JS: &str = include_str!("../web/app.js");
|
||||
const THEME_INIT_JS: &str = include_str!("../web/theme-init.js");
|
||||
const STYLES_CSS: &str = include_str!("../web/styles.css");
|
||||
const MANIFEST: &str = include_str!("../web/manifest.webmanifest");
|
||||
const SERVICE_WORKER: &str = include_str!("../web/sw.js");
|
||||
@@ -67,6 +69,7 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/access-tokens", get(list_access_tokens).post(create_access_token))
|
||||
.route("/api/access-tokens/:id", axum::routing::delete(delete_access_token))
|
||||
.route("/api/integrations/home-assistant/test", post(test_home_assistant))
|
||||
.route("/api/integrations/notifications/test", post(test_notifications))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth));
|
||||
|
||||
let home_assistant_api = Router::new()
|
||||
@@ -76,12 +79,13 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/integrations/home-assistant/zones/:id/control", post(update_zone_control))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth));
|
||||
|
||||
Router::new()
|
||||
let app = Router::new()
|
||||
.route("/api/health", get(health))
|
||||
.route("/ws", get(websocket))
|
||||
.route("/", get(index))
|
||||
.route("/index.html", get(index))
|
||||
.route("/app.js", get(app_js))
|
||||
.route("/theme-init.js", get(theme_init_js))
|
||||
.route("/styles.css", get(styles_css))
|
||||
.route("/manifest.webmanifest", get(manifest))
|
||||
.route("/sw.js", get(service_worker))
|
||||
@@ -91,9 +95,25 @@ pub fn router(state: AppState) -> Router {
|
||||
.merge(protected)
|
||||
.merge(home_assistant_api)
|
||||
.fallback(index)
|
||||
;
|
||||
|
||||
let app = if state.config.base_path.is_empty() {
|
||||
app
|
||||
} else {
|
||||
let base = state.config.base_path.clone();
|
||||
let redirect_to = format!("{base}/");
|
||||
Router::new()
|
||||
.route(&base, get(move || {
|
||||
let redirect_to = redirect_to.clone();
|
||||
async move { Redirect::permanent(&redirect_to) }
|
||||
}))
|
||||
.nest(&base, app)
|
||||
};
|
||||
|
||||
app
|
||||
.layer(CompressionLayer::new())
|
||||
.layer(CorsLayer::permissive())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(middleware::from_fn(security_headers))
|
||||
.layer(middleware::from_fn_with_state(state.clone(), debug_api_requests))
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -466,7 +486,7 @@ impl ZoneInput {
|
||||
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
|
||||
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None,
|
||||
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
|
||||
demand: false, last_action_at: None,
|
||||
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None,
|
||||
created_at, updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
@@ -503,6 +523,8 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
||||
zone.effective_setpoint = existing.effective_setpoint;
|
||||
zone.device_setpoint = existing.device_setpoint;
|
||||
zone.demand = existing.demand;
|
||||
zone.demand_since = existing.demand_since;
|
||||
zone.target_alerted_at = existing.target_alerted_at;
|
||||
zone.last_action_at = existing.last_action_at;
|
||||
let settings = state.settings.read().await.clone();
|
||||
canonicalize_zone_ha_entity(&mut zone, &settings);
|
||||
@@ -1129,6 +1151,15 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
|
||||
}
|
||||
if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; }
|
||||
if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; }
|
||||
if input.notifications.pushover_app_token.trim().is_empty() { input.notifications.pushover_app_token = old.notifications.pushover_app_token; }
|
||||
if input.notifications.pushover_user_key.trim().is_empty() { input.notifications.pushover_user_key = old.notifications.pushover_user_key; }
|
||||
if input.notifications.slack_webhook_url.trim().is_empty() { input.notifications.slack_webhook_url = old.notifications.slack_webhook_url; }
|
||||
if input.notifications.discord_webhook_url.trim().is_empty() { input.notifications.discord_webhook_url = old.notifications.discord_webhook_url; }
|
||||
input.notifications.cooldown_seconds = input.notifications.cooldown_seconds.clamp(30, 86_400);
|
||||
input.notifications.communication_failure_threshold = input.notifications.communication_failure_threshold.clamp(2, 100);
|
||||
input.notifications.target_timeout_minutes = input.notifications.target_timeout_minutes.clamp(5, 24 * 60);
|
||||
if !matches!(input.notifications.mode.as_str(), "problems" | "important") { return Err(AppError::BadRequest("notification mode must be problems or important".into())); }
|
||||
if !matches!(input.notifications.provider.as_str(), "pushover" | "slack" | "discord") { return Err(AppError::BadRequest("unsupported notification provider".into())); }
|
||||
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
|
||||
input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650);
|
||||
normalize_sensor_aliases(&mut input);
|
||||
@@ -1316,6 +1347,29 @@ async fn test_home_assistant(State(state): State<AppState>, Json(input): Json<Ha
|
||||
Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id})))
|
||||
}
|
||||
|
||||
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)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn security_headers(request: Request, next: Next) -> Response {
|
||||
let is_api = request.uri().path().contains("/api/");
|
||||
let mut response = next.run(request).await;
|
||||
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("x-frame-options"), HeaderValue::from_static("SAMEORIGIN"));
|
||||
headers.insert(header::HeaderName::from_static("referrer-policy"), HeaderValue::from_static("same-origin"));
|
||||
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=()"));
|
||||
if is_api { headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); }
|
||||
response
|
||||
}
|
||||
|
||||
fn public_settings(settings: &RuntimeSettings) -> Value {
|
||||
json!({
|
||||
"controller_id": settings.controller_id,
|
||||
@@ -1333,6 +1387,21 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
|
||||
"suppress_device_beep": settings.suppress_device_beep,
|
||||
"debug": settings.debug,
|
||||
"night_mode": settings.night_mode,
|
||||
"notifications": {
|
||||
"enabled": settings.notifications.enabled,
|
||||
"mode": settings.notifications.mode,
|
||||
"provider": settings.notifications.provider,
|
||||
"pushover_app_token": "",
|
||||
"pushover_user_key": "",
|
||||
"pushover_configured": !settings.notifications.pushover_app_token.trim().is_empty() && !settings.notifications.pushover_user_key.trim().is_empty(),
|
||||
"slack_webhook_url": "",
|
||||
"slack_configured": !settings.notifications.slack_webhook_url.trim().is_empty(),
|
||||
"discord_webhook_url": "",
|
||||
"discord_configured": !settings.notifications.discord_webhook_url.trim().is_empty(),
|
||||
"cooldown_seconds": settings.notifications.cooldown_seconds,
|
||||
"communication_failure_threshold": settings.notifications.communication_failure_threshold,
|
||||
"target_timeout_minutes": settings.notifications.target_timeout_minutes,
|
||||
},
|
||||
"influxdb": {
|
||||
"enabled": settings.influxdb.enabled,
|
||||
"version": settings.influxdb.version,
|
||||
@@ -1399,8 +1468,27 @@ async fn websocket_loop(state: AppState, mut socket: WebSocket) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn index() -> Html<&'static str> { Html(INDEX_HTML) }
|
||||
async fn index(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
let base = if !state.config.base_path.is_empty() {
|
||||
state.config.base_path.clone()
|
||||
} else {
|
||||
forwarded_prefix(&headers).unwrap_or_default()
|
||||
};
|
||||
let body = INDEX_HTML.replace("__GREE_BASE_PATH__", &base);
|
||||
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("no-cache"));
|
||||
response
|
||||
}
|
||||
|
||||
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; }
|
||||
Some(format!("/{}", raw.trim_matches('/')))
|
||||
}
|
||||
async fn app_js() -> 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=86400") }
|
||||
async fn styles_css() -> 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 { static_response(SERVICE_WORKER, "application/javascript; charset=utf-8", "no-cache") }
|
||||
|
||||
+15
-2
@@ -1,7 +1,7 @@
|
||||
use std::{env, net::SocketAddr, path::PathBuf};
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings, RuntimeSettings};
|
||||
use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings, NotificationSettings, RuntimeSettings};
|
||||
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(author, version, about)]
|
||||
@@ -12,6 +12,8 @@ pub struct Config {
|
||||
pub database: PathBuf,
|
||||
#[arg(long, env = "GREE_CONTROLLER_APP_TOKEN", default_value = "")]
|
||||
pub app_token: String,
|
||||
#[arg(long, env = "GREE_CONTROLLER_BASE_PATH", default_value = "")]
|
||||
pub base_path: String,
|
||||
#[arg(long, env = "GREE_CONTROLLER_SIMULATE", default_value_t = false)]
|
||||
pub simulate: bool,
|
||||
#[arg(long, env = "GREE_CONTROLLER_AUTO_SEED", default_value_t = false)]
|
||||
@@ -33,7 +35,8 @@ pub struct Config {
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
dotenvy::dotenv().ok();
|
||||
let config = Self::parse();
|
||||
let mut config = Self::parse();
|
||||
config.base_path = normalize_base_path(&config.base_path)?;
|
||||
if let Some(parent) = config.database.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("cannot create database directory {}", parent.display()))?;
|
||||
@@ -61,6 +64,7 @@ impl Config {
|
||||
overlay_enabled: env_bool("GREE_CONTROLLER_DEBUG_OVERLAY").unwrap_or(false),
|
||||
gree_frames: env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES").unwrap_or(false),
|
||||
},
|
||||
notifications: NotificationSettings::default(),
|
||||
night_mode: NightModeSettings {
|
||||
enabled: env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED").unwrap_or(false),
|
||||
start_time: env::var("GREE_CONTROLLER_NIGHT_MODE_START").unwrap_or_else(|_| "22:00".into()),
|
||||
@@ -128,6 +132,15 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_base_path(value: &str) -> Result<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value == "/" { return Ok(String::new()); }
|
||||
if value.contains('?') || value.contains('#') || value.split('/').any(|part| matches!(part, "." | "..")) {
|
||||
anyhow::bail!("GREE_CONTROLLER_BASE_PATH must be a simple URL path without '.', '..', query or fragment");
|
||||
}
|
||||
Ok(format!("/{}", value.trim_matches('/')))
|
||||
}
|
||||
|
||||
fn env_bool(name: &str) -> Option<bool> {
|
||||
env::var(name).ok().map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||
}
|
||||
|
||||
+33
-1
@@ -198,6 +198,7 @@ async fn poll_device(state: &AppState, device: &mut Device) {
|
||||
simulate_tick(device);
|
||||
return;
|
||||
}
|
||||
let previous_failures = device.communication_failures;
|
||||
if device.key.as_deref().unwrap_or_default().is_empty() {
|
||||
match state.gree.bind(device).await {
|
||||
Ok(bound) => {
|
||||
@@ -207,6 +208,7 @@ async fn poll_device(state: &AppState, device: &mut Device) {
|
||||
}
|
||||
Err(err) => {
|
||||
record_poll_failure(device, &err.to_string());
|
||||
log_poll_health_transition(state, device, previous_failures).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -225,6 +227,18 @@ async fn poll_device(state: &AppState, device: &mut Device) {
|
||||
Err(_) => record_poll_failure(device, &first_err.to_string()),
|
||||
}
|
||||
}
|
||||
log_poll_health_transition(state, device, previous_failures).await;
|
||||
}
|
||||
|
||||
async fn log_poll_health_transition(state: &AppState, device: &Device, previous_failures: u32) {
|
||||
let threshold = state.settings.read().await.notifications.communication_failure_threshold.max(2);
|
||||
if device.communication_failures >= threshold && previous_failures < threshold {
|
||||
state.log("warn", "device.offline", &format!("{} did not respond {} times in a row", device.name, device.communication_failures), json!({
|
||||
"device_id": device.id, "consecutive_failures": device.communication_failures, "threshold": threshold
|
||||
}));
|
||||
} else if device.communication_failures == 0 && previous_failures >= threshold {
|
||||
state.log("info", "device.recovered", &format!("{} is responding again", device.name), json!({"device_id": device.id}));
|
||||
}
|
||||
}
|
||||
|
||||
fn simulate_tick(device: &mut Device) {
|
||||
@@ -472,6 +486,24 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
else { zone.demand }
|
||||
}
|
||||
};
|
||||
if zone.demand && !previous_demand {
|
||||
zone.demand_since = Some(Utc::now());
|
||||
zone.target_alerted_at = None;
|
||||
} else if !zone.demand {
|
||||
zone.demand_since = None;
|
||||
zone.target_alerted_at = None;
|
||||
}
|
||||
if zone.demand && zone.target_alerted_at.is_none() {
|
||||
let timeout_minutes = settings.notifications.target_timeout_minutes.max(5) as i64;
|
||||
if let Some(since) = zone.demand_since {
|
||||
if (Utc::now() - since).num_minutes() >= timeout_minutes {
|
||||
state.log("warn", "zone.target_timeout", &format!("Zone {} has not reached {:.1} C within {} minutes", zone.name, target, timeout_minutes), json!({
|
||||
"zone_id": zone.id, "room_temperature": temp, "target_temperature": target, "minutes": timeout_minutes
|
||||
}));
|
||||
zone.target_alerted_at = Some(Utc::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor
|
||||
// stop naturally when we move the target to the satisfied side of room temperature.
|
||||
@@ -1117,7 +1149,7 @@ mod tests {
|
||||
external_sensor_weight: 0.4, max_sensor_difference: 3.0, device_temperature: None, external_temperature: None,
|
||||
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
|
||||
manual_preset: None, manual_setpoint: None, manual_override_until: None, effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
|
||||
demand: false, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
|
||||
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ mod error;
|
||||
mod home_assistant;
|
||||
mod influxdb;
|
||||
mod models;
|
||||
mod notifications;
|
||||
mod protocol;
|
||||
mod queries;
|
||||
mod state;
|
||||
|
||||
@@ -326,6 +326,10 @@ pub struct Zone {
|
||||
#[serde(default)]
|
||||
pub demand: bool,
|
||||
#[serde(default)]
|
||||
pub demand_since: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub target_alerted_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub last_action_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
@@ -512,6 +516,52 @@ impl Default for InfluxDbSettings {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationSettings {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// problems = warnings/errors/anomalies, important = problems plus important state changes.
|
||||
#[serde(default = "default_notification_mode")]
|
||||
pub mode: String,
|
||||
/// pushover, slack, discord
|
||||
#[serde(default = "default_notification_provider")]
|
||||
pub provider: String,
|
||||
#[serde(default)]
|
||||
pub pushover_app_token: String,
|
||||
#[serde(default)]
|
||||
pub pushover_user_key: String,
|
||||
#[serde(default)]
|
||||
pub slack_webhook_url: String,
|
||||
#[serde(default)]
|
||||
pub discord_webhook_url: String,
|
||||
#[serde(default = "default_notification_cooldown")]
|
||||
pub cooldown_seconds: u64,
|
||||
#[serde(default = "default_notification_failure_threshold")]
|
||||
pub communication_failure_threshold: u32,
|
||||
#[serde(default = "default_notification_target_timeout")]
|
||||
pub target_timeout_minutes: u32,
|
||||
}
|
||||
|
||||
fn default_notification_mode() -> String { "problems".into() }
|
||||
fn default_notification_provider() -> String { "pushover".into() }
|
||||
fn default_notification_cooldown() -> u64 { 300 }
|
||||
fn default_notification_failure_threshold() -> u32 { 3 }
|
||||
fn default_notification_target_timeout() -> u32 { 60 }
|
||||
|
||||
impl Default for NotificationSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false, mode: default_notification_mode(), provider: default_notification_provider(),
|
||||
pushover_app_token: String::new(), pushover_user_key: String::new(),
|
||||
slack_webhook_url: String::new(), discord_webhook_url: String::new(),
|
||||
cooldown_seconds: default_notification_cooldown(),
|
||||
communication_failure_threshold: default_notification_failure_threshold(),
|
||||
target_timeout_minutes: default_notification_target_timeout(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct DebugSettings {
|
||||
#[serde(default)]
|
||||
@@ -661,6 +711,8 @@ pub struct RuntimeSettings {
|
||||
pub debug: DebugSettings,
|
||||
#[serde(default)]
|
||||
pub night_mode: NightModeSettings,
|
||||
#[serde(default)]
|
||||
pub notifications: NotificationSettings,
|
||||
pub home_assistant: HomeAssistantSettings,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
use std::{collections::HashMap, sync::{Mutex, OnceLock}};
|
||||
use chrono::Utc;
|
||||
use serde_json::{json, Value};
|
||||
use crate::{models::NotificationSettings, state::AppState};
|
||||
|
||||
static LAST_SENT: OnceLock<Mutex<HashMap<String, i64>>> = OnceLock::new();
|
||||
|
||||
fn important_kind(kind: &str) -> bool {
|
||||
matches!(kind,
|
||||
"device.offline" | "device.recovered" | "automation.fired" | "automation.error" |
|
||||
"zone.target_timeout" | "zone.action_error" | "house.mode" | "house.preset" |
|
||||
"device.communication_error")
|
||||
}
|
||||
|
||||
fn should_send(cfg: &NotificationSettings, level: &str, kind: &str) -> bool {
|
||||
if !cfg.enabled { return false; }
|
||||
if level == "error" || level == "warn" { return true; }
|
||||
cfg.mode == "important" && important_kind(kind)
|
||||
}
|
||||
|
||||
fn cooldown_key(cfg: &NotificationSettings, kind: &str, metadata: &Value) -> String {
|
||||
let entity = metadata.get("device_id").or_else(|| metadata.get("zone_id")).or_else(|| metadata.get("automation_id"))
|
||||
.and_then(Value::as_str).unwrap_or("global");
|
||||
format!("{}:{}:{}", cfg.provider, kind, entity)
|
||||
}
|
||||
|
||||
fn take_cooldown(cfg: &NotificationSettings, kind: &str, metadata: &Value) -> bool {
|
||||
let now = Utc::now().timestamp();
|
||||
let key = cooldown_key(cfg, kind, metadata);
|
||||
let map = LAST_SENT.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let Ok(mut map) = map.lock() else { return false; };
|
||||
if let Some(last) = map.get(&key) {
|
||||
if now - *last < cfg.cooldown_seconds.max(30) as i64 { return false; }
|
||||
}
|
||||
map.insert(key, now);
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn dispatch(state: AppState, level: String, kind: String, message: String, metadata: Value) {
|
||||
let cfg = state.settings.read().await.notifications.clone();
|
||||
if !should_send(&cfg, &level, &kind) || !take_cooldown(&cfg, &kind, &metadata) { return; }
|
||||
let title = format!("GREE Controller - {}", if level == "error" { "error" } else if level == "warn" { "warning" } else { "event" });
|
||||
let result = match cfg.provider.as_str() {
|
||||
"pushover" => send_pushover(&state, &cfg, &title, &message).await,
|
||||
"slack" => send_webhook(&state, &cfg.slack_webhook_url, json!({"text": format!("*{}*\\n{}", title, message)})).await,
|
||||
"discord" => send_webhook(&state, &cfg.discord_webhook_url, json!({"content": format!("**{}**\\n{}", title, message)})).await,
|
||||
_ => Err("unsupported notification provider".into()),
|
||||
};
|
||||
if let Err(err) = result {
|
||||
tracing::warn!(provider=%cfg.provider, error=%err, "notification delivery failed");
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_pushover(state: &AppState, cfg: &NotificationSettings, title: &str, message: &str) -> Result<(), String> {
|
||||
if cfg.pushover_app_token.trim().is_empty() || cfg.pushover_user_key.trim().is_empty() { return Err("Pushover credentials are incomplete".into()); }
|
||||
let response = state.http.post("https://api.pushover.net/1/messages.json")
|
||||
.form(&[("token", cfg.pushover_app_token.as_str()), ("user", cfg.pushover_user_key.as_str()), ("title", title), ("message", message)])
|
||||
.send().await.map_err(|e| e.to_string())?;
|
||||
if response.status().is_success() { Ok(()) } else { Err(format!("Pushover HTTP {}", response.status())) }
|
||||
}
|
||||
|
||||
async fn send_webhook(state: &AppState, url: &str, body: Value) -> Result<(), String> {
|
||||
let parsed = url::Url::parse(url).map_err(|_| "invalid webhook URL".to_string())?;
|
||||
if parsed.scheme() != "https" { return Err("webhook URL must use HTTPS".into()); }
|
||||
let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
|
||||
let allowed = host == "hooks.slack.com" || host == "discord.com" || host == "discordapp.com";
|
||||
if !allowed { return Err("webhook host is not supported".into()); }
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build().map_err(|e| e.to_string())?;
|
||||
let response = client.post(parsed).json(&body).send().await.map_err(|e| e.to_string())?;
|
||||
if response.status().is_success() { Ok(()) } else { Err(format!("webhook HTTP {}", response.status())) }
|
||||
}
|
||||
|
||||
pub async fn test(state: &AppState, cfg: NotificationSettings) -> Result<(), String> {
|
||||
match cfg.provider.as_str() {
|
||||
"pushover" => send_pushover(state, &cfg, "GREE Controller", "Test notification").await,
|
||||
"slack" => send_webhook(state, &cfg.slack_webhook_url, json!({"text":"GREE Controller - test notification"})).await,
|
||||
"discord" => send_webhook(state, &cfg.discord_webhook_url, json!({"content":"GREE Controller - test notification"})).await,
|
||||
_ => Err("unsupported notification provider".into()),
|
||||
}
|
||||
}
|
||||
@@ -36,5 +36,12 @@ impl AppState {
|
||||
"message": message,
|
||||
"metadata": metadata,
|
||||
}));
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
let state = self.clone();
|
||||
let level = level.to_string();
|
||||
let kind = kind.to_string();
|
||||
let message = message.to_string();
|
||||
handle.spawn(async move { crate::notifications::dispatch(state, level, kind, message, metadata).await; });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user