Files
gree-controller/src/notifications.rs
T
2026-09-17 08:52:02 +02:00

358 lines
11 KiB
Rust

use crate::{models::NotificationSettings, state::AppState};
use chrono::Utc;
use serde_json::{json, Value};
use std::{
collections::HashMap,
sync::{Mutex, OnceLock},
};
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 alert_type_enabled(cfg: &NotificationSettings, kind: &str) -> bool {
let types = &cfg.alert_types;
if kind == "ha.sensor_stale" {
return types.stale_sensor;
}
if kind.starts_with("ha.sensor_") {
return types.sensor_errors;
}
if matches!(
kind,
"device.offline" | "device.communication_error" | "device.command_unconfirmed"
) {
return types.communication;
}
if kind == "zone.target_timeout" {
return types.target_timeout;
}
if kind.starts_with("automation.") {
return types.automation;
}
if kind == "zone.sensor_discrepancy" {
return types.sensor_discrepancy;
}
if matches!(
kind,
"zone.action_error"
| "zone.mode_change_off_error"
| "zone.local_power_error"
| "zone.device_missing"
| "zone.temporary_quick_thermostat_cancelled"
| "zone.temporary_quick_thermostat_poweroff_error"
| "group.power_error"
) {
return types.control_errors;
}
if important_kind(kind) {
return types.important_events;
}
types.other
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DeliveryDecision {
NotApplicable,
Silent(&'static str),
Send,
}
fn delivery_decision(cfg: &NotificationSettings, level: &str, kind: &str) -> DeliveryDecision {
let candidate = level == "error" || level == "warn" || important_kind(kind);
if !candidate {
return DeliveryDecision::NotApplicable;
}
if !cfg.enabled {
return DeliveryDecision::Silent("notifications_disabled");
}
if !alert_type_enabled(cfg, kind) {
return DeliveryDecision::Silent("alert_type_disabled");
}
if level != "error" && level != "warn" && cfg.mode != "important" {
return DeliveryDecision::Silent("mode_filtered");
}
DeliveryDecision::Send
}
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
}
fn with_notification_status(
metadata: &Value,
status: &str,
reason: Option<&str>,
provider: &str,
) -> Value {
let mut updated = match metadata {
Value::Object(map) => Value::Object(map.clone()),
other => json!({"data": other}),
};
let mut notification = serde_json::Map::new();
notification.insert("status".into(), Value::String(status.into()));
if let Some(reason) = reason {
notification.insert("reason".into(), Value::String(reason.into()));
}
if !provider.trim().is_empty() {
notification.insert("provider".into(), Value::String(provider.into()));
}
if let Some(object) = updated.as_object_mut() {
object.insert("notification".into(), Value::Object(notification));
}
updated
}
fn persist_notification_status(
state: &AppState,
event_id: Option<i64>,
metadata: &Value,
status: &str,
reason: Option<&str>,
provider: &str,
) {
let Some(event_id) = event_id else {
return;
};
let updated = with_notification_status(metadata, status, reason, provider);
match state.db.update_event_metadata(event_id, &updated) {
Ok(true) => state.broadcast("log.updated", json!({"id": event_id, "metadata": updated})),
Ok(false) => tracing::warn!(event_id, "cannot update missing event log metadata"),
Err(err) => tracing::warn!(event_id, error=?err, "cannot update event log metadata"),
}
}
pub async fn dispatch(
state: AppState,
event_id: Option<i64>,
level: String,
kind: String,
message: String,
metadata: Value,
) {
let cfg = state.settings.read().await.notifications.clone();
match delivery_decision(&cfg, &level, &kind) {
DeliveryDecision::NotApplicable => return,
DeliveryDecision::Silent(reason) => {
persist_notification_status(
&state,
event_id,
&metadata,
"silent",
Some(reason),
&cfg.provider,
);
return;
}
DeliveryDecision::Send => {}
}
if !take_cooldown(&cfg, &kind, &metadata) {
persist_notification_status(
&state,
event_id,
&metadata,
"silent",
Some("cooldown"),
&cfg.provider,
);
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(
&cfg.slack_webhook_url,
json!({"text": format!("*{}*\\n{}", title, message)}),
)
.await
}
"discord" => {
send_webhook(
&cfg.discord_webhook_url,
json!({"content": format!("**{}**\\n{}", title, message)}),
)
.await
}
_ => Err("unsupported notification provider".into()),
};
match result {
Ok(()) => {
persist_notification_status(&state, event_id, &metadata, "sent", None, &cfg.provider)
}
Err(err) => {
persist_notification_status(
&state,
event_id,
&metadata,
"failed",
Some("delivery_failed"),
&cfg.provider,
);
tracing::warn!(provider=%cfg.provider, error=%err, "notification delivery failed");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sensor_discrepancy_has_its_own_notification_toggle() {
let mut cfg = NotificationSettings::default();
cfg.alert_types.control_errors = true;
cfg.alert_types.sensor_discrepancy = false;
assert!(!alert_type_enabled(&cfg, "zone.sensor_discrepancy"));
assert!(alert_type_enabled(&cfg, "zone.action_error"));
cfg.alert_types.control_errors = false;
cfg.alert_types.sensor_discrepancy = true;
assert!(alert_type_enabled(&cfg, "zone.sensor_discrepancy"));
assert!(!alert_type_enabled(&cfg, "zone.action_error"));
}
#[test]
fn disabled_alert_type_is_recorded_as_silent() {
let mut cfg = NotificationSettings::default();
cfg.enabled = true;
cfg.mode = "important".into();
cfg.alert_types.sensor_discrepancy = false;
assert_eq!(
delivery_decision(&cfg, "warn", "zone.sensor_discrepancy"),
DeliveryDecision::Silent("alert_type_disabled")
);
}
#[test]
fn ordinary_info_event_has_no_notification_status() {
let cfg = NotificationSettings::default();
assert_eq!(
delivery_decision(&cfg, "info", "zone.quick_control"),
DeliveryDecision::NotApplicable
);
}
}
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(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(
&cfg.slack_webhook_url,
json!({"text":"GREE Controller - test notification"}),
)
.await
}
"discord" => {
send_webhook(
&cfg.discord_webhook_url,
json!({"content":"GREE Controller - test notification"}),
)
.await
}
_ => Err("unsupported notification provider".into()),
}
}