diff --git a/Cargo.lock b/Cargo.lock index 90db504..319a10e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -688,7 +688,6 @@ dependencies = [ "json", "lazy_static", "log", - "mac_address", "notify-rust", "rand", "regex", @@ -918,16 +917,6 @@ dependencies = [ "time", ] -[[package]] -name = "mac_address" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b238e3235c8382b7653c6408ed1b08dd379bdb9fdf990fb0bbae3db2cc0ae963" -dependencies = [ - "nix 0.23.2", - "winapi", -] - [[package]] name = "mach" version = "0.3.2" @@ -1018,19 +1007,6 @@ dependencies = [ "libc", ] -[[package]] -name = "nix" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c" -dependencies = [ - "bitflags", - "cc", - "cfg-if", - "libc", - "memoffset 0.6.5", -] - [[package]] name = "nix" version = "0.25.1" diff --git a/Cargo.toml b/Cargo.toml index c67dd4b..2e29db9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,6 @@ image = "0.24.5" json = "0.12.4" lazy_static = "1.4.0" log = "0.4.17" -mac_address = "1.1.4" notify-rust = { version = "4.8.0", features = ["images"] } rand = "0.8.5" regex = "1.7.1" diff --git a/README.md b/README.md index c62bb24..38e98b2 100644 --- a/README.md +++ b/README.md @@ -4,35 +4,116 @@ ## Features -- [ ] Fallback MQTT broker address - [x] Command buttons - [x] Notifications - [x] Actions -- [x] System stats - - [x] CPU usage - - [x] RAM usage - - [x] Battery: level, charging status +- [x] System information reporting (CPU usage, battery status, …) - [ ] Media control (MPRIS) -- [ ] PipeWire +- [ ] PipeWire control - [ ] File watcher +- [ ] Fallback MQTT broker address Ideas: - Camera video stream - Idle time (→ libseat) -## Installation +## Installation and configuration TBD +If the configuration file does not exist, an example file is created and the program exits. + +The default path is `$XDG_CONFIG_HOME/hassliebe/config.toml`. +It can be changed using the `HASSLIEBE_CONFIG` environment variable. + +Example: + +```toml +friendly_id = "my_pc" # Used as prefix for MQTT topics and Home Assistant IDs +display_name = "My PC" + +[mqtt] +host = "127.0.0.1" # You probably need to change this +port = 1883 + +[modules.buttons] +enabled = true + +[[modules.buttons.buttons]] +id = "reboot" +name = "Reboot" +command = "shutdown -r now" + +[modules.info] +enabled = true +cpu_usage = 10 # update interval in seconds +ram_usage = 0 # 0 disables reporting +battery = 60 + +[modules.notifications] +enabled = true +``` + +### Machine identification + +Hassliebe needs a way to uniquely identify a machine. +`friendly_id` is not suited well for this because the user may want to change it from time to time. + +If `/etc/machine-id` exists (as is the case with systemd-based systems), it will be used. +Otherwise, a random ID will be generated on the first run and stored in `$XDG_DATA_HOME/hassliebe/machine_id`. +The latter always takes precedence. + ## Modules +### Buttons + +You can define MQTT buttons in your configuration file like this: + +```toml +[[modules.buttons.buttons]] +id = "shutdown" # must be unique among all the buttons in the file +name = "Shutdown" +command = "shutdown -h now" +run_in_shell = true # defaults to false +``` + +When `run_in_shell` is set to `true`, the command will be run with `/bin/sh`. + +### Info + +Hassliebe currently supports reporting the following system stats: + +- CPU usage (%) +- RAM usage (%) +- Battery level (%) and status (`/sys/class/power_supply/[name]/status`) + +For each of these, the update interval can be set to a number of seconds or `0` to disable it: + +```toml +[modules.info] +enabled = true +cpu_usage = 10 # updates every 10 seconds +ram_usage = 0 # disabled +battery = 60 # updates every 60 seconds +``` + ### Notifications +The notifications module allows sending notifications according to the +[Desktop Notifications Specification](https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html#markup). + +Remember to enable the module: + +```toml +[modules.notifications] +enabled = true +``` + Two MQTT topics can be used for sending notifications: -- `[unique_id]/notifications/simple` — for plain-text messages -- `[unique_id]/notifications/json` — for JSON-encoded messages matching the schema described below +- `[friendly_id]/notifications/simple` — for plain-text messages +- `[friendly_id]/notifications/json` — for JSON-encoded messages matching the schema described below Complex messages have these properties: @@ -53,7 +134,7 @@ Complex messages have these properties: #### Actions When a notification action is invoked, the ID of the action is sent to the MQTT topic with the following name: -`[unique_id]/notifications/actions/[notification_id]`. +`[friendly_id]/notifications/actions/[notification_id]`. When a notification is dismissed, `closed` is sent into the topic. diff --git a/src/config.rs b/src/config.rs index cd96b7a..ea684de 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,12 +5,10 @@ use std::io::{ErrorKind, Read, Write}; use std::path::Path; use anyhow::{bail, Context, Result}; -use regex::Regex; use serde::{Deserialize, Serialize}; -use validator::{Validate, ValidationError}; +use validator::Validate; use crate::modules; -use crate::util::generate_alphanumeric_id; #[derive(Serialize, Deserialize, Validate)] pub struct Mqtt { @@ -37,45 +35,28 @@ pub struct Internal { #[derive(Serialize, Deserialize, Validate)] pub struct Config { - #[validate(custom = "validate_unique_id")] - pub unique_id: String, + #[validate(custom = "crate::util::validate_hass_id")] + pub friendly_id: String, #[validate(length(min = 1))] pub display_name: String, - pub announce_mac_address: bool, #[validate] pub mqtt: Mqtt, #[validate] pub modules: modules::Config, - - #[serde(rename = "DO_NOT_CHANGE")] - #[validate] - pub internal: Internal, -} - -pub fn validate_unique_id(value: &str) -> Result<(), ValidationError> { - if Regex::new(r"^[a-zA-Z0-9]+(_[a-zA-Z0-9]+)*$").unwrap().is_match(value) { - Ok(()) - } else { - Err(ValidationError::new("does not match regex")) - } } fn create_example_config() -> Config { Config { - unique_id: "my_pc".to_owned(), + friendly_id: "my_pc".to_owned(), display_name: "My PC".to_owned(), - announce_mac_address: true, mqtt: Mqtt { host: "".to_owned(), port: 1883, credentials: None, }, - internal: Internal { - stable_id: generate_alphanumeric_id(12), - }, modules: modules::Config::default(), } } diff --git a/src/main.rs b/src/main.rs index 5c56a50..e9f1d48 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use anyhow::{anyhow, Result}; use crate::modules::{InitializationContext, ModuleContext, ModuleContextMqtt}; use crate::mqtt::OwnedTopicsService; +use crate::util::generate_alphanumeric_id; mod config; mod modules; @@ -44,19 +45,54 @@ async fn get_paths_and_create_directories() -> Result { Ok(paths) } +fn initialize_logger() { + let mut builder = env_logger::builder(); + builder.parse_filters("warn,hassliebe=info"); + builder.parse_default_env(); + builder.init(); +} + +async fn load_machine_id(paths: &Paths) -> Result { + let overwrite_path = paths.data_directory.join("machine_id"); + let mut value = tokio::fs::read_to_string(&overwrite_path).await.unwrap_or("".to_owned()).trim().to_owned(); + + if value.is_empty() { + log::debug!("{} does not exist or is empty", overwrite_path.to_string_lossy()); + + match tokio::fs::read_to_string("/etc/machine-id").await { + Ok(id) => { + value = id.trim().to_owned(); + log::debug!("Found machine ID in /etc/machine-id: {}", value); + } + Err(_) => { + log::debug!("Could not read /etc/machine-id"); + + value = generate_alphanumeric_id(32); + log::info!("Generated new machine ID: {}", value); + tokio::fs::write(overwrite_path, &value).await?; + } + } + } else { + log::debug!("Found machine ID in {}: {}", overwrite_path.to_string_lossy(), value); + } + + Ok(value) +} + #[tokio::main] async fn main() -> Result<()> { - env_logger::init(); + initialize_logger(); let paths = get_paths_and_create_directories().await?; + let machine_id = load_machine_id(&paths).await?; let Some(config) = config::load(&paths.config)? else { exit(exitcode::CONFIG); }; - let availability_topic = config.unique_id.to_owned() + "/availability"; - let (mqtt_client, event_loop) = mqtt::create_client(&config, &availability_topic).await?; - let discovery_device_object = mqtt::create_discovery_device_object(&config); + let availability_topic = config.friendly_id.to_owned() + "/availability"; + let (mqtt_client, event_loop) = mqtt::create_client(&config, &machine_id, &availability_topic).await?; + let discovery_device_object = mqtt::create_discovery_device_object(&config, &machine_id); let owned_topics_service = OwnedTopicsService::new(&paths.data_directory).await?; @@ -66,6 +102,7 @@ async fn main() -> Result<()> { full: Arc::new(ModuleContext { config, + machine_id, mqtt: ModuleContextMqtt { client: mqtt_client, availability_topic, diff --git a/src/modules/buttons.rs b/src/modules/buttons.rs index 3f6c80e..371e800 100644 --- a/src/modules/buttons.rs +++ b/src/modules/buttons.rs @@ -3,7 +3,6 @@ use serde::{Deserialize, Serialize}; use tokio::process::Command; use validator::Validate; -use crate::config::validate_unique_id; use crate::modules::InitializationContext; const MODULE_ID: &str = "buttons"; @@ -11,7 +10,7 @@ const BUTTON_TRIGGER_TEXT: &str = "press"; #[derive(Serialize, Deserialize, Validate, Clone)] pub struct ButtonConfig { - #[validate(custom = "validate_unique_id")] + #[validate(custom = "crate::util::validate_hass_id")] pub id: String, #[validate(length(min = 1))] diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 895f1d3..a5b2199 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -63,12 +63,13 @@ impl InitializationContext { pub struct ModuleContext { pub config: super::config::Config, pub mqtt: ModuleContextMqtt, + pub machine_id: String, // pub dbus_session_connection: zbus::Connection, } impl ModuleContext { fn get_entity_id(&self, module_id: &str, sub_id: &str) -> String { - format!("{}_{}_{}", self.config.unique_id, module_id, sub_id) + format!("{}_{}_{}", self.config.friendly_id, module_id, sub_id) } } diff --git a/src/modules/notifications.rs b/src/modules/notifications.rs index c103b04..8f6b7e6 100644 --- a/src/modules/notifications.rs +++ b/src/modules/notifications.rs @@ -92,7 +92,7 @@ pub async fn init(context: &mut InitializationContext) -> Result<()> { log::info!("Initializing…"); let full_context = context.get_full(); - context.subscribe_mqtt_topic(format!("{}/{}/simple", context.full.config.unique_id, MODULE_ID), move |text| { + context.subscribe_mqtt_topic(format!("{}/{}/simple", context.full.config.friendly_id, MODULE_ID), move |text| { let (summary_text, long_content) = text.split_once('\n').unwrap_or((text, "")); tokio::spawn(handle_notification_message( @@ -113,7 +113,7 @@ pub async fn init(context: &mut InitializationContext) -> Result<()> { }); let full_context = context.get_full(); - context.subscribe_mqtt_topic(format!("{}/{}/json", context.full.config.unique_id, MODULE_ID), move |text| { + context.subscribe_mqtt_topic(format!("{}/{}/json", context.full.config.friendly_id, MODULE_ID), move |text| { let message = serde_json::from_str::(text); match message { @@ -127,7 +127,7 @@ pub async fn init(context: &mut InitializationContext) -> Result<()> { Ok(()) }); - context.subscribe_mqtt_topic(format!("{}/{}/close", context.full.config.unique_id, MODULE_ID), move |id| { + context.subscribe_mqtt_topic(format!("{}/{}/close", context.full.config.friendly_id, MODULE_ID), move |id| { spawn_nonessential(close_notification(id.to_owned())); Ok(()) @@ -210,7 +210,7 @@ async fn send_notification_action_message(context: Arc, notificat .mqtt .client .publish( - format!("{}/{}/action/{}", context.config.unique_id, MODULE_ID, notification_id), + format!("{}/{}/action/{}", context.config.friendly_id, MODULE_ID, notification_id), QoS::ExactlyOnce, false, action_id, diff --git a/src/mqtt.rs b/src/mqtt.rs index 54ef9ae..2ac1a5b 100644 --- a/src/mqtt.rs +++ b/src/mqtt.rs @@ -16,27 +16,27 @@ use crate::modules::InitializationContext; use super::config; -pub async fn create_client(config: &config::Config, availability_topic: &str) -> Result<(MqttClient, EventLoop)> { - let mut options = MqttOptions::new(&config.internal.stable_id, config.mqtt.host.to_owned(), config.mqtt.port); +pub async fn create_client(config: &config::Config, machine_id: &str, availability_topic: &str) -> Result<(MqttClient, EventLoop)> { + let mut options = MqttOptions::new(machine_id, config.mqtt.host.to_owned(), config.mqtt.port); options.set_clean_session(true); options.set_keep_alive(Duration::from_secs(5)); options.set_last_will(LastWill::new(availability_topic, "offline", QoS::AtLeastOnce, true)); - options.set_max_packet_size(usize::MAX, usize::MAX); + options.set_inflight(30); + options.set_max_packet_size( + 30 * 1000 * 1000, // 30 MB + usize::MAX, + ); - let (mqtt_client, event_loop) = MqttClient::new(options, 10); + let (mqtt_client, event_loop) = MqttClient::new(options, 100); mqtt_client.publish(availability_topic, QoS::AtLeastOnce, true, "online").await?; Ok((mqtt_client, event_loop)) } -pub fn create_discovery_device_object(config: &config::Config) -> JsonValue { +pub fn create_discovery_device_object(config: &config::Config, machine_id: &str) -> JsonValue { json::object! { - "connections": if config.announce_mac_address { - mac_address::get_mac_address().unwrap_or(None).map(|a| json::array![["mac", a.to_string()]]).unwrap_or(json::array![]) - } else { - json::array![] - }, + "identifiers": [machine_id], "name": config.display_name.as_str() } } @@ -75,6 +75,8 @@ impl OwnedTopicsService { mqtt_client.publish(topic, QoS::AtLeastOnce, true, Vec::new()).await?; } + log::trace!("Writing owned_topics file"); + let mut new_content = String::new(); new_content.push_str("# DO NOT EDIT THIS FILE. It is automatically generated at each run of Hassliebe.\n"); @@ -99,8 +101,8 @@ enum ConnectionState { } const FAST_RETRYING_INTERVAL_MS: u64 = 500; -const FAST_RETRYING_LIMIT_SECONDS: u64 = 15; -const SLOW_RETRYING_INTERVAL_SECONDS: u64 = 5; +const FAST_RETRYING_LIMIT_SECONDS: u64 = 10; +const SLOW_RETRYING_INTERVAL_SECONDS: u64 = 10; pub async fn start_communication(context: &InitializationContext, mut event_loop: EventLoop, owned_topics_service: OwnedTopicsService) -> Result<()> { log::info!( diff --git a/src/util.rs b/src/util.rs index a6ccc1f..8dfb92d 100644 --- a/src/util.rs +++ b/src/util.rs @@ -5,7 +5,9 @@ use std::hash::{Hash, Hasher}; use anyhow::Result; use rand::distributions::Alphanumeric; use rand::Rng; +use regex::Regex; use tokio::task::JoinHandle; +use validator::ValidationError; #[inline] pub fn spawn_nonessential(future: impl Future> + Send + 'static) -> JoinHandle<()> { @@ -28,6 +30,14 @@ pub fn log_error(error: anyhow::Error) { log::error!("{:#}", error); } +pub fn validate_hass_id(value: &str) -> Result<(), ValidationError> { + if Regex::new(r"^[a-zA-Z0-9]+(_[a-zA-Z0-9]+)*$").unwrap().is_match(value) { + Ok(()) + } else { + Err(ValidationError::new("does not match regex")) + } +} + pub fn generate_alphanumeric_id(length: usize) -> String { rand::thread_rng().sample_iter(&Alphanumeric).take(length).map(char::from).collect() }