fix, fmt, refactor, document

This commit is contained in:
Moritz Ruth 2023-03-09 11:31:54 +01:00
parent a171f9ffed
commit 603fff0094
Signed by: moritzruth
GPG key ID: C9BBAB79405EE56D
10 changed files with 167 additions and 81 deletions

24
Cargo.lock generated
View file

@ -688,7 +688,6 @@ dependencies = [
"json", "json",
"lazy_static", "lazy_static",
"log", "log",
"mac_address",
"notify-rust", "notify-rust",
"rand", "rand",
"regex", "regex",
@ -918,16 +917,6 @@ dependencies = [
"time", "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]] [[package]]
name = "mach" name = "mach"
version = "0.3.2" version = "0.3.2"
@ -1018,19 +1007,6 @@ dependencies = [
"libc", "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]] [[package]]
name = "nix" name = "nix"
version = "0.25.1" version = "0.25.1"

View file

@ -19,7 +19,6 @@ image = "0.24.5"
json = "0.12.4" json = "0.12.4"
lazy_static = "1.4.0" lazy_static = "1.4.0"
log = "0.4.17" log = "0.4.17"
mac_address = "1.1.4"
notify-rust = { version = "4.8.0", features = ["images"] } notify-rust = { version = "4.8.0", features = ["images"] }
rand = "0.8.5" rand = "0.8.5"
regex = "1.7.1" regex = "1.7.1"

101
README.md
View file

@ -4,35 +4,116 @@
## Features ## Features
- [ ] Fallback MQTT broker address
- [x] Command buttons - [x] Command buttons
- [x] Notifications - [x] Notifications
- [x] Actions - [x] Actions
- [x] System stats - [x] System information reporting (CPU usage, battery status, …)
- [x] CPU usage
- [x] RAM usage
- [x] Battery: level, charging status
- [ ] Media control (MPRIS) - [ ] Media control (MPRIS)
- [ ] PipeWire - [ ] PipeWire control
- [ ] File watcher - [ ] File watcher
- [ ] Fallback MQTT broker address
Ideas: Ideas:
- Camera video stream - Camera video stream
- Idle time (→ libseat) - Idle time (→ libseat)
## Installation ## Installation and configuration
TBD 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 ## 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 ### 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: Two MQTT topics can be used for sending notifications:
- `[unique_id]/notifications/simple` — for plain-text messages - `[friendly_id]/notifications/simple` — for plain-text messages
- `[unique_id]/notifications/json` — for JSON-encoded messages matching the schema described below - `[friendly_id]/notifications/json` — for JSON-encoded messages matching the schema described below
Complex messages have these properties: Complex messages have these properties:
@ -53,7 +134,7 @@ Complex messages have these properties:
#### Actions #### Actions
When a notification action is invoked, the ID of the action is sent to the MQTT topic with the following name: 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. When a notification is dismissed, `closed` is sent into the topic.

View file

@ -5,12 +5,10 @@ use std::io::{ErrorKind, Read, Write};
use std::path::Path; use std::path::Path;
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationError}; use validator::Validate;
use crate::modules; use crate::modules;
use crate::util::generate_alphanumeric_id;
#[derive(Serialize, Deserialize, Validate)] #[derive(Serialize, Deserialize, Validate)]
pub struct Mqtt { pub struct Mqtt {
@ -37,45 +35,28 @@ pub struct Internal {
#[derive(Serialize, Deserialize, Validate)] #[derive(Serialize, Deserialize, Validate)]
pub struct Config { pub struct Config {
#[validate(custom = "validate_unique_id")] #[validate(custom = "crate::util::validate_hass_id")]
pub unique_id: String, pub friendly_id: String,
#[validate(length(min = 1))] #[validate(length(min = 1))]
pub display_name: String, pub display_name: String,
pub announce_mac_address: bool,
#[validate] #[validate]
pub mqtt: Mqtt, pub mqtt: Mqtt,
#[validate] #[validate]
pub modules: modules::Config, 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 { fn create_example_config() -> Config {
Config { Config {
unique_id: "my_pc".to_owned(), friendly_id: "my_pc".to_owned(),
display_name: "My PC".to_owned(), display_name: "My PC".to_owned(),
announce_mac_address: true,
mqtt: Mqtt { mqtt: Mqtt {
host: "".to_owned(), host: "".to_owned(),
port: 1883, port: 1883,
credentials: None, credentials: None,
}, },
internal: Internal {
stable_id: generate_alphanumeric_id(12),
},
modules: modules::Config::default(), modules: modules::Config::default(),
} }
} }

View file

@ -7,6 +7,7 @@ use anyhow::{anyhow, Result};
use crate::modules::{InitializationContext, ModuleContext, ModuleContextMqtt}; use crate::modules::{InitializationContext, ModuleContext, ModuleContextMqtt};
use crate::mqtt::OwnedTopicsService; use crate::mqtt::OwnedTopicsService;
use crate::util::generate_alphanumeric_id;
mod config; mod config;
mod modules; mod modules;
@ -44,19 +45,54 @@ async fn get_paths_and_create_directories() -> Result<Paths> {
Ok(paths) 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<String> {
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] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
env_logger::init(); initialize_logger();
let paths = get_paths_and_create_directories().await?; let paths = get_paths_and_create_directories().await?;
let machine_id = load_machine_id(&paths).await?;
let Some(config) = config::load(&paths.config)? else { let Some(config) = config::load(&paths.config)? else {
exit(exitcode::CONFIG); exit(exitcode::CONFIG);
}; };
let availability_topic = config.unique_id.to_owned() + "/availability"; let availability_topic = config.friendly_id.to_owned() + "/availability";
let (mqtt_client, event_loop) = mqtt::create_client(&config, &availability_topic).await?; let (mqtt_client, event_loop) = mqtt::create_client(&config, &machine_id, &availability_topic).await?;
let discovery_device_object = mqtt::create_discovery_device_object(&config); let discovery_device_object = mqtt::create_discovery_device_object(&config, &machine_id);
let owned_topics_service = OwnedTopicsService::new(&paths.data_directory).await?; let owned_topics_service = OwnedTopicsService::new(&paths.data_directory).await?;
@ -66,6 +102,7 @@ async fn main() -> Result<()> {
full: Arc::new(ModuleContext { full: Arc::new(ModuleContext {
config, config,
machine_id,
mqtt: ModuleContextMqtt { mqtt: ModuleContextMqtt {
client: mqtt_client, client: mqtt_client,
availability_topic, availability_topic,

View file

@ -3,7 +3,6 @@ use serde::{Deserialize, Serialize};
use tokio::process::Command; use tokio::process::Command;
use validator::Validate; use validator::Validate;
use crate::config::validate_unique_id;
use crate::modules::InitializationContext; use crate::modules::InitializationContext;
const MODULE_ID: &str = "buttons"; const MODULE_ID: &str = "buttons";
@ -11,7 +10,7 @@ const BUTTON_TRIGGER_TEXT: &str = "press";
#[derive(Serialize, Deserialize, Validate, Clone)] #[derive(Serialize, Deserialize, Validate, Clone)]
pub struct ButtonConfig { pub struct ButtonConfig {
#[validate(custom = "validate_unique_id")] #[validate(custom = "crate::util::validate_hass_id")]
pub id: String, pub id: String,
#[validate(length(min = 1))] #[validate(length(min = 1))]

View file

@ -63,12 +63,13 @@ impl InitializationContext {
pub struct ModuleContext { pub struct ModuleContext {
pub config: super::config::Config, pub config: super::config::Config,
pub mqtt: ModuleContextMqtt, pub mqtt: ModuleContextMqtt,
pub machine_id: String,
// pub dbus_session_connection: zbus::Connection, // pub dbus_session_connection: zbus::Connection,
} }
impl ModuleContext { impl ModuleContext {
fn get_entity_id(&self, module_id: &str, sub_id: &str) -> String { 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)
} }
} }

View file

@ -92,7 +92,7 @@ pub async fn init(context: &mut InitializationContext) -> Result<()> {
log::info!("Initializing…"); log::info!("Initializing…");
let full_context = context.get_full(); 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, "")); let (summary_text, long_content) = text.split_once('\n').unwrap_or((text, ""));
tokio::spawn(handle_notification_message( tokio::spawn(handle_notification_message(
@ -113,7 +113,7 @@ pub async fn init(context: &mut InitializationContext) -> Result<()> {
}); });
let full_context = context.get_full(); 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::<NotificationMessage>(text); let message = serde_json::from_str::<NotificationMessage>(text);
match message { match message {
@ -127,7 +127,7 @@ pub async fn init(context: &mut InitializationContext) -> Result<()> {
Ok(()) 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())); spawn_nonessential(close_notification(id.to_owned()));
Ok(()) Ok(())
@ -210,7 +210,7 @@ async fn send_notification_action_message(context: Arc<ModuleContext>, notificat
.mqtt .mqtt
.client .client
.publish( .publish(
format!("{}/{}/action/{}", context.config.unique_id, MODULE_ID, notification_id), format!("{}/{}/action/{}", context.config.friendly_id, MODULE_ID, notification_id),
QoS::ExactlyOnce, QoS::ExactlyOnce,
false, false,
action_id, action_id,

View file

@ -16,27 +16,27 @@ use crate::modules::InitializationContext;
use super::config; use super::config;
pub async fn create_client(config: &config::Config, availability_topic: &str) -> Result<(MqttClient, EventLoop)> { pub async fn create_client(config: &config::Config, machine_id: &str, availability_topic: &str) -> Result<(MqttClient, EventLoop)> {
let mut options = MqttOptions::new(&config.internal.stable_id, config.mqtt.host.to_owned(), config.mqtt.port); let mut options = MqttOptions::new(machine_id, config.mqtt.host.to_owned(), config.mqtt.port);
options.set_clean_session(true); options.set_clean_session(true);
options.set_keep_alive(Duration::from_secs(5)); options.set_keep_alive(Duration::from_secs(5));
options.set_last_will(LastWill::new(availability_topic, "offline", QoS::AtLeastOnce, true)); 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?; mqtt_client.publish(availability_topic, QoS::AtLeastOnce, true, "online").await?;
Ok((mqtt_client, event_loop)) 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! { json::object! {
"connections": if config.announce_mac_address { "identifiers": [machine_id],
mac_address::get_mac_address().unwrap_or(None).map(|a| json::array![["mac", a.to_string()]]).unwrap_or(json::array![])
} else {
json::array![]
},
"name": config.display_name.as_str() "name": config.display_name.as_str()
} }
} }
@ -75,6 +75,8 @@ impl OwnedTopicsService {
mqtt_client.publish(topic, QoS::AtLeastOnce, true, Vec::new()).await?; mqtt_client.publish(topic, QoS::AtLeastOnce, true, Vec::new()).await?;
} }
log::trace!("Writing owned_topics file");
let mut new_content = String::new(); 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"); 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_INTERVAL_MS: u64 = 500;
const FAST_RETRYING_LIMIT_SECONDS: u64 = 15; const FAST_RETRYING_LIMIT_SECONDS: u64 = 10;
const SLOW_RETRYING_INTERVAL_SECONDS: u64 = 5; const SLOW_RETRYING_INTERVAL_SECONDS: u64 = 10;
pub async fn start_communication(context: &InitializationContext, mut event_loop: EventLoop, owned_topics_service: OwnedTopicsService) -> Result<()> { pub async fn start_communication(context: &InitializationContext, mut event_loop: EventLoop, owned_topics_service: OwnedTopicsService) -> Result<()> {
log::info!( log::info!(

View file

@ -5,7 +5,9 @@ use std::hash::{Hash, Hasher};
use anyhow::Result; use anyhow::Result;
use rand::distributions::Alphanumeric; use rand::distributions::Alphanumeric;
use rand::Rng; use rand::Rng;
use regex::Regex;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use validator::ValidationError;
#[inline] #[inline]
pub fn spawn_nonessential(future: impl Future<Output=Result<()>> + Send + 'static) -> JoinHandle<()> { pub fn spawn_nonessential(future: impl Future<Output=Result<()>> + Send + 'static) -> JoinHandle<()> {
@ -28,6 +30,14 @@ pub fn log_error(error: anyhow::Error) {
log::error!("{:#}", 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 { pub fn generate_alphanumeric_id(length: usize) -> String {
rand::thread_rng().sample_iter(&Alphanumeric).take(length).map(char::from).collect() rand::thread_rng().sample_iter(&Alphanumeric).take(length).map(char::from).collect()
} }