Compare commits

..

8 commits
v1.0.0 ... main

10 changed files with 1376 additions and 712 deletions

1923
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,35 +1,34 @@
[package] [package]
name = "hassliebe" name = "hassliebe"
version = "1.0.0" version = "1.1.1"
edition = "2021" edition = "2021"
license = "BlueOak-1.0.0" license = "BlueOak-1.0.0"
authors = ["Moritz Ruth <dev@moritzruth.de>"] authors = ["Moritz Ruth <dev@moritzruth.de>"]
homepage = "https://git.moritzruth.de/moritzruth/Hassliebe"
repository = "https://git.moritzruth.de/moritzruth/Hassliebe" repository = "https://git.moritzruth.de/moritzruth/Hassliebe"
[features] [features]
dry_run = [] # will prevent some actions like shutting down dry_run = [] # will prevent some actions like shutting down
[dependencies] [dependencies]
anyhow = "1.0.69" anyhow = "1.0.88"
base64 = "0.21.0" base64 = "0.22.1"
battery = "0.7.8" env_logger = "0.11.5"
env_logger = "0.10.0"
exitcode = "1.1.2" exitcode = "1.1.2"
image = "0.24.5" image = "0.25.2"
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"
notify-rust = { version = "4.8.0", features = ["images"] } notify-rust = { version = "4.11.3", features = ["images"] }
rand = "0.8.5" rand = "0.8.5"
regex = "1.7.1" regex = "1.7.1"
rumqttc = "0.20.0" rumqttc = "0.24.0"
serde = { version = "1.0.152", features = ["derive"] } serde = { version = "1.0.152", features = ["derive"] }
serde_json = "1.0.93" serde_json = "1.0.128"
sysinfo = { version = "0.28.2", default-features = false } sysinfo = { version = "0.31.4", default-features = false, features = ["system"] }
tokio = { version = "1.25.0", features = ["full"] } tokio = { version = "1.25.0", features = ["full"] }
toml = "0.7.2" toml = "0.8.19"
users = "0.11.0" users = "0.11.0"
validator = { version = "0.16.0", features = ["derive"] } validator = { version = "0.18.1", features = ["derive"] }
void = "1.0.2"
zbus = { version = "3.10.0", default-features = false, features = ["tokio"] } [profile.release]
strip = true # Automatically strip symbols from the binary.

View file

@ -2,27 +2,31 @@
> Integrates any Linux machine into your Home Assistant ecosystem. > Integrates any Linux machine into your Home Assistant ecosystem.
## Features ## Features
- [x] Command buttons - [x] Command buttons
- [x] Notifications - [x] Notifications
- [x] Actions - [x] Actions
- [x] System information reporting (CPU usage, battery status, …) - [x] System information reporting (CPU usage, RAM usage, battery status)
- [ ] Media control (MPRIS)
The following sound like interesting ideas but as I dont have a use case for them at the moment, I havent implemented them yet.
Feel free to [open an issue and describe your use-case](https://git.moritzruth.de/moritzruth/Hassliebe/issues/new).
- [ ] PipeWire control - [ ] PipeWire control
- [ ] File watcher - [ ] Exposing file contents as sensors
Ideas:
- Camera video stream
- Idle time (→ libseat)
## Installation ## Installation
Hassliebe is a [single executable](https://git.moritzruth.de/moritzruth/Hassliebe/releases). Copy it somewhere and execute it.
For systemd users, a unit file is provided.
### As a systemd _system_ service ### As a systemd _system_ service
- Download [the latest binary](https://git.moritzruth.de/moritzruth/Hassliebe/releases) and put it into `/usr/bin`. - Download [the latest binary](https://git.moritzruth.de/moritzruth/Hassliebe/releases) and put it into `/usr/bin`.
- Download [the unit file](distrib/systemd/system/hassliebe.service) and put it into `/etc/systemd/system`. - Download [the unit file](contrib/systemd/system/hassliebe.service) and put it into `/etc/systemd/system`.
- Create the configuration file (see below) at `/etc/hassliebe/config.toml`. - Create the configuration file (see below) at `/etc/hassliebe/config.toml`.
- Enable and start the unit: - Enable and start the unit:
@ -30,10 +34,11 @@ Ideas:
systemctl enable hassliebe && systemctl start hassliebe systemctl enable hassliebe && systemctl start hassliebe
``` ```
### As a systemd _user_ service ### As a systemd _user_ service
- Download [the latest binary](https://git.moritzruth.de/moritzruth/Hassliebe/releases) and put it into `~/.local/bin`. - Download [the latest binary](https://git.moritzruth.de/moritzruth/Hassliebe/releases) and put it into `~/.local/bin`.
- Download [the unit file](distrib/systemd/user/hassliebe.service) and put it into `~/.local/share/systemd/user`. - Download [the unit file](contrib/systemd/user/hassliebe.service) and put it into `~/.local/share/systemd/user`.
- Create the configuration file (see below) at `$XDG_CONFIG_HOME/hassliebe/config.toml`. - Create the configuration file (see below) at `$XDG_CONFIG_HOME/hassliebe/config.toml`.
- Enable and start the unit: - Enable and start the unit:
@ -41,11 +46,13 @@ systemctl enable hassliebe && systemctl start hassliebe
systemctl --user enable hassliebe && systemctl --user start hassliebe systemctl --user enable hassliebe && systemctl --user start hassliebe
``` ```
## Configuration ## Configuration
Depending on whether Hassliebe is run as root or as a regular user, the configuration is read from Depending on whether Hassliebe is run as root or as a regular user, the configuration is read from
`/etc/hassliebe/config.toml` or `$XDG_CONFIG_HOME/hassliebe/config.toml`. `/etc/hassliebe/config.toml` or `$XDG_CONFIG_HOME/hassliebe/config.toml`.
### Example ### Example
```toml ```toml
@ -56,6 +63,9 @@ display_name = "My PC"
host = "127.0.0.1" # You probably need to change this host = "127.0.0.1" # You probably need to change this
port = 1883 port = 1883
# You can remove the following line if your MQTT broker allows unauthenticated access
credentials = { user = "user", password = "password" }
[modules.buttons] [modules.buttons]
enabled = true enabled = true
@ -74,6 +84,7 @@ battery = 60
enabled = true enabled = true
``` ```
### Machine identification ### Machine identification
Hassliebe needs a way to uniquely identify a machine. Hassliebe needs a way to uniquely identify a machine.
@ -83,6 +94,7 @@ If `/etc/machine-id` exists (as is the case with systemd-based systems), it will
Otherwise, a random ID will be generated on the first run and stored in `[data]/machine_id`. Otherwise, a random ID will be generated on the first run and stored in `[data]/machine_id`.
The latter always takes precedence. The latter always takes precedence.
## Modules ## Modules
### Buttons ### Buttons
@ -99,6 +111,7 @@ run_in_shell = true # defaults to false
When `run_in_shell` is set to `true`, the command will be run with `/bin/sh`. When `run_in_shell` is set to `true`, the command will be run with `/bin/sh`.
### Info ### Info
Hassliebe currently supports reporting the following system stats: Hassliebe currently supports reporting the following system stats:
@ -117,6 +130,7 @@ ram_usage = 0 # disabled
battery = 60 # updates every 60 seconds battery = 60 # updates every 60 seconds
``` ```
### Notifications ### Notifications
**Not available when running as a system service.** **Not available when running as a system service.**
@ -152,6 +166,7 @@ Complex messages have these properties:
- `encoded_image` (optional) — Padded Base64-encoded image attached to the notification. - `encoded_image` (optional) — Padded Base64-encoded image attached to the notification.
- `actions` (optional) — Object with the keys being IDs and the values being labels. - `actions` (optional) — Object with the keys being IDs and the values being labels.
#### 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:
@ -159,6 +174,7 @@ When a notification action is invoked, the ID of the action is sent to the MQTT
When a notification is dismissed, `closed` is sent into the topic. When a notification is dismissed, `closed` is sent into the topic.
## License ## License
Hassliebe is licensed under the [Blue Oak Model License 1.0.0](./LICENSE.md). This project is available under the permissive [Blue Oak Model License 1.0.0](https://blueoakcouncil.org/license/1.0.0).

View file

@ -15,7 +15,7 @@ pub struct Mqtt {
pub port: u16, pub port: u16,
#[serde(default)] #[serde(default)]
#[validate] #[validate(nested)]
pub credentials: Option<MqttCredentials>, pub credentials: Option<MqttCredentials>,
} }
@ -33,16 +33,16 @@ pub struct Internal {
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate)]
pub struct Config { pub struct Config {
#[validate(custom = "crate::util::validate_hass_id")] #[validate(custom(function = "crate::util::validate_hass_id"))]
pub friendly_id: String, pub friendly_id: String,
#[validate(length(min = 1))] #[validate(length(min = 1))]
pub display_name: String, pub display_name: String,
#[validate] #[validate(nested)]
pub mqtt: Mqtt, pub mqtt: Mqtt,
#[validate] #[validate(nested)]
pub modules: modules::Config, pub modules: modules::Config,
} }
@ -55,7 +55,7 @@ pub fn load(config_file_path: &Path) -> Result<Option<Config>> {
file.read_to_string(&mut string_content).context("while reading the configuration file")?; file.read_to_string(&mut string_content).context("while reading the configuration file")?;
let parsed = toml::from_str::<Config>(string_content.as_str()).context("while parsing the configuration file")?; let parsed = toml::from_str::<Config>(string_content.as_str()).context("while parsing the configuration file")?;
parsed.validate().context("while validating the configuration file")?; Validate::validate(&parsed).context("while validating the configuration file")?;
Ok(Some(parsed)) Ok(Some(parsed))
} }

View file

@ -10,7 +10,7 @@ const BUTTON_TRIGGER_TEXT: &str = "press";
#[derive(Deserialize, Validate, Clone)] #[derive(Deserialize, Validate, Clone)]
pub struct ButtonConfig { pub struct ButtonConfig {
#[validate(custom = "crate::util::validate_hass_id")] #[validate(custom(function = "crate::util::validate_hass_id"))]
pub id: String, pub id: String,
#[validate(length(min = 1))] #[validate(length(min = 1))]

View file

@ -3,10 +3,10 @@ use std::time::Duration;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use rumqttc::QoS; use rumqttc::QoS;
use serde::Deserialize; use serde::Deserialize;
use sysinfo::{CpuExt, System, SystemExt};
use tokio::task::spawn_blocking; use tokio::task::spawn_blocking;
use tokio::time::MissedTickBehavior; use tokio::time::MissedTickBehavior;
use validator::Validate; use validator::Validate;
use sysinfo::System;
use crate::modules::InitializationContext; use crate::modules::InitializationContext;
@ -43,14 +43,16 @@ pub async fn init(context: &mut InitializationContext) -> Result<()> {
sub_id: "ram_usage", sub_id: "ram_usage",
display_name: "RAM Usage", display_name: "RAM Usage",
icon: "mdi:memory", icon: "mdi:memory",
suggested_precision: 0, suggested_precision: Some(0),
unit: Some("%"), unit: Some("%"),
force_update: true,
device_class: None
}, },
context, context,
config.ram_usage, config.ram_usage,
move || { move || {
sys.refresh_memory(); sys.refresh_memory();
Ok(format!("{:.2}", (sys.available_memory() as f64) / (sys.total_memory() as f64) * 100.0)) Ok(format!("{:.2}", (sys.used_memory() as f64) / (sys.total_memory() as f64) * 100.0))
}, },
) )
.await?; .await?;
@ -63,14 +65,16 @@ pub async fn init(context: &mut InitializationContext) -> Result<()> {
sub_id: "cpu_usage", sub_id: "cpu_usage",
display_name: "CPU Usage", display_name: "CPU Usage",
icon: "mdi:memory", icon: "mdi:memory",
suggested_precision: 0, suggested_precision: Some(0),
unit: Some("%"), unit: Some("%"),
force_update: true,
device_class: None
}, },
context, context,
config.cpu_usage, config.cpu_usage,
move || { move || {
sys.refresh_cpu(); sys.refresh_cpu_usage();
Ok(format!("{:.2}", sys.global_cpu_info().cpu_usage())) Ok(format!("{:.2}", sys.global_cpu_usage()))
}, },
) )
.await?; .await?;
@ -86,8 +90,7 @@ pub async fn init(context: &mut InitializationContext) -> Result<()> {
}) })
.unwrap_or_default() .unwrap_or_default()
}) })
.await .await?;
.unwrap();
if let Some(dir) = battery_dirs.first() { if let Some(dir) = battery_dirs.first() {
log::debug!( log::debug!(
@ -106,8 +109,10 @@ pub async fn init(context: &mut InitializationContext) -> Result<()> {
sub_id: "battery_level", sub_id: "battery_level",
display_name: "Battery Level", display_name: "Battery Level",
icon: "mdi:battery", icon: "mdi:battery",
suggested_precision: 0, suggested_precision: Some(0),
unit: Some("%"), unit: Some("%"),
force_update: true,
device_class: Some("battery")
}, },
context, context,
config.battery, config.battery,
@ -120,8 +125,10 @@ pub async fn init(context: &mut InitializationContext) -> Result<()> {
sub_id: "battery_state", sub_id: "battery_state",
display_name: "Battery State", display_name: "Battery State",
icon: "mdi:battery", icon: "mdi:battery",
suggested_precision: 0, suggested_precision: None,
unit: None, unit: None,
force_update: false,
device_class: Some("enum")
}, },
context, context,
config.battery, config.battery,
@ -140,8 +147,10 @@ struct InfoEntityOptions<'a> {
sub_id: &'a str, sub_id: &'a str,
display_name: &'a str, display_name: &'a str,
icon: &'a str, icon: &'a str,
suggested_precision: u64, suggested_precision: Option<u64>,
unit: Option<&'a str>, unit: Option<&'a str>,
force_update: bool,
device_class: Option<&'a str>
} }
async fn init_info_value( async fn init_info_value(
@ -167,10 +176,10 @@ async fn init_info_value(
let mut object = json::object! { let mut object = json::object! {
"availability_topic": context.full.mqtt.availability_topic.clone(), "availability_topic": context.full.mqtt.availability_topic.clone(),
"device": context.full.mqtt.discovery_device_object.clone(), "device": context.full.mqtt.discovery_device_object.clone(),
"force_update": true, "device_class": options.device_class,
"force_update": options.force_update,
"icon": options.icon, "icon": options.icon,
"name": options.display_name, "name": options.display_name,
"suggested_display_precision": options.suggested_precision,
"state_class": "measurement", "state_class": "measurement",
"state_topic": state_topic.as_str(), "state_topic": state_topic.as_str(),
"object_id": entity_id.as_str(), "object_id": entity_id.as_str(),
@ -181,6 +190,10 @@ async fn init_info_value(
object.insert("unit_of_measurement", unit).unwrap(); object.insert("unit_of_measurement", unit).unwrap();
} }
if let Some(suggested_precision) = options.suggested_precision {
object.insert("suggested_display_precision", suggested_precision).unwrap();
}
object object
}), }),
) )

View file

@ -13,13 +13,13 @@ mod notifications;
#[derive(Deserialize, Validate, Default)] #[derive(Deserialize, Validate, Default)]
pub struct Config { pub struct Config {
#[validate] #[validate(nested)]
pub buttons: Option<buttons::Config>, pub buttons: Option<buttons::Config>,
#[validate] #[validate(nested)]
pub info: Option<info::Config>, pub info: Option<info::Config>,
#[validate] #[validate(nested)]
pub notifications: Option<notifications::Config>, pub notifications: Option<notifications::Config>,
} }

View file

@ -28,9 +28,9 @@ pub async fn create_client(config: &config::Config, machine_id: &str, availabili
usize::MAX, usize::MAX,
); );
let (mqtt_client, event_loop) = MqttClient::new(options, 100); config.mqtt.credentials.as_ref().map(|c| options.set_credentials(c.user.clone(), c.password.clone()));
mqtt_client.publish(availability_topic, QoS::AtLeastOnce, true, "online").await?;
let (mqtt_client, event_loop) = MqttClient::new(options, 30);
Ok((mqtt_client, event_loop)) Ok((mqtt_client, event_loop))
} }
@ -111,20 +111,6 @@ pub async fn start_communication(context: &InitializationContext, mut event_loop
context.full.config.mqtt.port context.full.config.mqtt.port
); );
if !context.message_handler_by_mqtt_topic.is_empty() {
context
.full
.mqtt
.client
.subscribe_many(
context
.message_handler_by_mqtt_topic
.keys()
.map(|k| SubscribeFilter::new(k.to_owned(), QoS::AtLeastOnce)),
)
.await?;
}
let mut connection_state = ConnectionState::NotConnected; let mut connection_state = ConnectionState::NotConnected;
let mut owned_topics_service = Some(owned_topics_service); let mut owned_topics_service = Some(owned_topics_service);
@ -177,9 +163,30 @@ pub async fn start_communication(context: &InitializationContext, mut event_loop
if connection_state == ConnectionState::NotConnected { if connection_state == ConnectionState::NotConnected {
log::info!("Connection established") log::info!("Connection established")
} else { } else {
log::info!("Connection restored") log::info!("Connection restored");
} }
if !context.message_handler_by_mqtt_topic.is_empty() {
context
.full
.mqtt
.client
.subscribe_many(
context
.message_handler_by_mqtt_topic
.keys()
.map(|k| SubscribeFilter::new(k.to_owned(), QoS::AtLeastOnce)),
)
.await?;
}
context
.full
.mqtt
.client
.publish(&context.full.mqtt.availability_topic, QoS::AtLeastOnce, true, "online")
.await?;
if let Some(service) = owned_topics_service.take() { if let Some(service) = owned_topics_service.take() {
service.clear_old_and_save_new(&context.full.mqtt.client, &context.owned_mqtt_topics).await?; service.clear_old_and_save_new(&context.full.mqtt.client, &context.owned_mqtt_topics).await?;
} }