diff --git a/Cargo.lock b/Cargo.lock index 160a6f6..9065251 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,6 +50,7 @@ dependencies = [ "rumqttc", "serde", "serde_json", + "serde_repr", "tokio", "toml", ] @@ -1080,6 +1081,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a5ec9fa74a20ebbe5d9ac23dac1fc96ba0ecfe9f50f2843b52e537b10fbcb4e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" diff --git a/Cargo.toml b/Cargo.toml index edb03cf..86d54a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ dotenv = "0.15.0" anyhow = "1.0.68" reqwest = "0.11.13" axum = "0.6.1" +serde_repr = "0.1.10" [profile.release] lto=true diff --git a/config/zeus.dev.toml b/config/zeus.dev.toml index 965f8ed..c747c63 100644 --- a/config/zeus.dev.toml +++ b/config/zeus.dev.toml @@ -4,11 +4,10 @@ port=8883 username="mqtt" [fullfillment] -port=7878 username="Dreaded_X" [presence] -topic = "automation/presence" +topic = "automation_dev/presence" [devices.kitchen_kettle] type = "IkeaOutlet" diff --git a/src/config.rs b/src/config.rs index efa4d81..e98a96a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,10 +6,14 @@ use serde::Deserialize; use crate::devices::{DeviceBox, IkeaOutlet, WakeOnLAN}; +// @TODO Configure more defaults + #[derive(Debug, Deserialize)] pub struct Config { pub mqtt: MqttConfig, pub fullfillment: FullfillmentConfig, + #[serde(default)] + pub ntfy: NtfyConfig, pub presence: MqttDeviceConfig, #[serde(default)] pub devices: HashMap @@ -25,10 +29,32 @@ pub struct MqttConfig { #[derive(Debug, Deserialize)] pub struct FullfillmentConfig { + #[serde(default = "default_fullfillment_port")] pub port: u16, pub username: String, } +fn default_fullfillment_port() -> u16 { + 7878 +} + +#[derive(Debug, Deserialize)] +pub struct NtfyConfig { + #[serde(default = "default_ntfy_url")] + pub url: String, + pub topic: Option, +} + +fn default_ntfy_url() -> String { + "https://ntfy.sh".into() +} + +impl Default for NtfyConfig { + fn default() -> Self { + Self { url: default_ntfy_url(), topic: None } + } +} + #[derive(Debug, Deserialize)] pub struct InfoConfig { pub name: String, @@ -66,7 +92,8 @@ impl Config { let file = fs::read_to_string(filename)?; let mut config: Self = toml::from_str(&file)?; - config.mqtt.password = Some(std::env::var("MQTT_PASSWORD").or(config.mqtt.password.ok_or("MQTT password needs to be set in either config or the environment!"))?); + config.mqtt.password = Some(std::env::var("MQTT_PASSWORD").or(config.mqtt.password.ok_or("MQTT password needs to be set in either config [mqtt.password] or the environment [MQTT_PASSWORD]"))?); + config.ntfy.topic = Some(std::env::var("NTFY_TOPIC").or(config.ntfy.topic.ok_or("ntfy.sh topic needs to be set in either config [ntfy.url] or the environment [NTFY_TOPIC]!"))?); Ok(config) } diff --git a/src/lib.rs b/src/lib.rs index 089d821..54d61f3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,3 +3,4 @@ pub mod devices; pub mod mqtt; pub mod config; pub mod presence; +pub mod ntfy; diff --git a/src/main.rs b/src/main.rs index fdf7c3f..e78bb74 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,7 @@ use std::{time::Duration, sync::{Arc, RwLock}, process, net::SocketAddr}; use axum::{Router, Json, routing::post, http::StatusCode}; -use automation::{config::Config, presence::Presence}; +use automation::{config::Config, presence::Presence, ntfy::Ntfy}; use dotenv::dotenv; use rumqttc::{MqttOptions, Transport, AsyncClient}; use env_logger::Builder; @@ -49,10 +49,14 @@ async fn main() { // Register devices as presence listener presence.add_listener(Arc::downgrade(&devices)); + let ntfy = Arc::new(RwLock::new(Ntfy::new(config.ntfy))); + presence.add_listener(Arc::downgrade(&ntfy)); + // Register presence as mqtt listener let presence = Arc::new(RwLock::new(presence)); mqtt.add_listener(Arc::downgrade(&presence)); + // Start mqtt, this spawns a seperate async task mqtt.start(); diff --git a/src/ntfy.rs b/src/ntfy.rs new file mode 100644 index 0000000..f343366 --- /dev/null +++ b/src/ntfy.rs @@ -0,0 +1,137 @@ +use std::collections::HashMap; + +use log::{warn, error}; +use reqwest::StatusCode; +use serde::Serialize; +use serde_repr::*; + +use crate::{presence::OnPresence, config::NtfyConfig}; + +pub struct Ntfy { + base_url: String, + topic: String +} + +#[derive(Serialize_repr)] +#[repr(u8)] +enum Priority { + Min = 1, + Low, + Default, + High, + Max, +} + +#[derive(Serialize)] +#[serde(rename_all = "snake_case", tag = "action")] +enum ActionType { + Broadcast { + #[serde(skip_serializing_if = "HashMap::is_empty")] + extras: HashMap + }, + // View, + // Http +} + +#[derive(Serialize)] +struct Action { + #[serde(flatten)] + action: ActionType, + label: String, + clear: Option, +} + +#[derive(Serialize)] +struct Notification { + topic: String, + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + tags: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + priority: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + actions: Vec, +} + +impl Notification { + fn new(topic: &str) -> Self { + Self { topic: topic.to_owned(), title: None, message: None, tags: Vec::new(), priority: None, actions: Vec::new() } + } + + fn set_title(mut self, title: &str) -> Self { + self.title = Some(title.to_owned()); + self + } + + fn set_message(mut self, message: &str) -> Self { + self.message = Some(message.to_owned()); + self + } + + fn add_tag(mut self, tag: &str) -> Self { + self.tags.push(tag.to_owned()); + self + } + + fn set_priority(mut self, priority: Priority) -> Self { + self.priority = Some(priority); + self + } + + fn add_action(mut self, action: Action) -> Self { + self.actions.push(action); + self + } +} + +impl Ntfy { + pub fn new(config: NtfyConfig) -> Self { + Self { base_url: config.url, topic: config.topic.unwrap() } + } +} + +impl OnPresence for Ntfy { + fn on_presence(&mut self, presence: bool) { + // Setup extras for the broadcast + let extras = HashMap::from([ + ("cmd".into(), "presence".into()), + ("state".into(), if presence { "0" } else { "1" }.into()), + ]); + + // Create broadcast action + let action = Action { + action: ActionType::Broadcast { extras }, + label: if presence { "Set away" } else { "Set home" }.to_owned(), + clear: Some(true) + }; + + // Create the notification + let notification = Notification::new(&self.topic) + .set_title("Presence") + .set_message(if presence { "Home" } else { "Away" }) + .add_tag("house") + .add_action(action) + .set_priority(Priority::Low); + + // Create the request + let req = reqwest::Client::new() + .post(self.base_url.clone()) + .body(serde_json::to_string(¬ification).unwrap()); + + // Send the notification + tokio::spawn(async move { + let res = req.send().await; + if let Err(err) = res { + error!("Something went wrong while sending the notifcation: {err}"); + } else if let Ok(res) = res { + let status = res.status(); + if status != StatusCode::OK { + warn!("Received status {status} when sending notification"); + } + } + }); + } +}