diff --git a/Cargo.lock b/Cargo.lock index 63b3c9a..90db504 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,23 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a" +[[package]] +name = "battery" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4b624268937c0e0a3edb7c27843f9e547c320d730c610d3b8e6e8e95b2026e4" +dependencies = [ + "cfg-if", + "core-foundation 0.7.0", + "lazycell", + "libc", + "mach", + "nix 0.19.1", + "num-traits", + "uom", + "winapi", +] + [[package]] name = "bit_field" version = "0.10.2" @@ -195,16 +212,32 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "core-foundation" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d24c7a13c43e870e37c1556b74555437870a04514f7685f5b354e090567171" +dependencies = [ + "core-foundation-sys 0.7.0", + "libc", +] + [[package]] name = "core-foundation" version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.3", "libc", ] +[[package]] +name = "core-foundation-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac" + [[package]] name = "core-foundation-sys" version = "0.8.3" @@ -647,6 +680,7 @@ version = "0.1.0" dependencies = [ "anyhow", "base64", + "battery", "directories", "env_logger", "exitcode", @@ -661,10 +695,12 @@ dependencies = [ "rumqttc", "serde", "serde_json", + "sysinfo", "tokio", "toml", "validator", "void", + "zbus", ] [[package]] @@ -826,6 +862,12 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "lebe" version = "0.5.2" @@ -886,6 +928,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "mach" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b823e83b2affd8f40a9ee8c29dbc56404c1e34cd2710921f2801e2cf29527afa" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -955,6 +1006,18 @@ dependencies = [ "getrandom", ] +[[package]] +name = "nix" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ccba0cfe4fdf15982d1674c69b1fd80bad427d293849982668dfe454bd61f2" +dependencies = [ + "bitflags", + "cc", + "cfg-if", + "libc", +] + [[package]] name = "nix" version = "0.23.2" @@ -1006,6 +1069,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "ntapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc51db7b362b205941f71232e56c625156eb9a929f8cf74a428fd5bc094a4afc" +dependencies = [ + "winapi", +] + [[package]] name = "num-integer" version = "0.1.45" @@ -1476,8 +1548,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a332be01508d814fed64bf28f798a146d73792121129962fdf335bb3c49a4254" dependencies = [ "bitflags", - "core-foundation", - "core-foundation-sys", + "core-foundation 0.9.3", + "core-foundation-sys 0.8.3", "libc", "security-framework-sys", ] @@ -1488,7 +1560,7 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31c9bb296072e961fcbd8853511dd39c2d8be2deb1e17c6860b1d30732b323b4" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.3", "libc", ] @@ -1647,6 +1719,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sysinfo" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e847e2de7a137c8c2cede5095872dbb00f4f9bf34d061347e36b43322acd56" +dependencies = [ + "cfg-if", + "core-foundation-sys 0.8.3", + "libc", + "ntapi", + "once_cell", + "winapi", +] + [[package]] name = "tauri-winrt-notification" version = "0.1.0" @@ -1768,6 +1854,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.42.0", ] @@ -1908,6 +1995,16 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" +[[package]] +name = "uom" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e76503e636584f1e10b9b3b9498538279561adcef5412927ba00c2b32c4ce5ed" +dependencies = [ + "num-traits", + "typenum", +] + [[package]] name = "url" version = "2.3.1" @@ -2251,6 +2348,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", + "lazy_static", "nix 0.25.1", "once_cell", "ordered-stream", @@ -2259,6 +2357,7 @@ dependencies = [ "serde_repr", "sha1", "static_assertions", + "tokio", "tracing", "uds_windows", "winapi", diff --git a/Cargo.toml b/Cargo.toml index 5bd7ea5..c67dd4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ dry_run = [] # will prevent some actions like shutting down [dependencies] anyhow = "1.0.69" base64 = "0.21.0" +battery = "0.7.8" directories = "4.0.1" env_logger = "0.10.0" exitcode = "1.1.2" @@ -25,7 +26,9 @@ regex = "1.7.1" rumqttc = "0.20.0" serde = { version = "1.0.152", features = ["derive"] } serde_json = "1.0.93" +sysinfo = { version = "0.28.2", default-features = false } tokio = { version = "1.25.0", features = ["full"] } toml = "0.7.2" validator = { version = "0.16.0", features = ["derive"] } void = "1.0.2" +zbus = { version = "3.10.0", default-features = false, features = ["tokio"] } diff --git a/README.md b/README.md index 2dde45d..7e9fc61 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ - [x] Command buttons - [x] Notifications - [x] Actions -- [ ] System stats - - [ ] CPU usage - - [ ] RAM usage - - [ ] Battery: level, charging status +- [x] System stats + - [x] CPU usage + - [x] RAM usage + - [x] Battery: level, charging status - [ ] Media session - [ ] PipeWire - [ ] File watcher @@ -17,7 +17,7 @@ Ideas: - Camera video stream -- Idle time +- Idle time (→ libseat) ## License diff --git a/src/config.rs b/src/config.rs index 2db9cfa..cd96b7a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -35,15 +35,6 @@ pub struct Internal { pub stable_id: String, } -#[derive(Serialize, Deserialize, Validate)] -pub struct Modules { - #[validate] - pub buttons: Option, - - #[validate] - pub notifications: Option, -} - #[derive(Serialize, Deserialize, Validate)] pub struct Config { #[validate(custom = "validate_unique_id")] @@ -57,7 +48,7 @@ pub struct Config { pub mqtt: Mqtt, #[validate] - pub modules: Modules, + pub modules: modules::Config, #[serde(rename = "DO_NOT_CHANGE")] #[validate] @@ -85,13 +76,7 @@ fn create_example_config() -> Config { internal: Internal { stable_id: generate_alphanumeric_id(12), }, - modules: Modules { - buttons: Some(modules::buttons::Config { - enabled: false, - buttons: Vec::new(), - }), - notifications: Some(modules::notifications::Config { enabled: false }), - }, + modules: modules::Config::default(), } } diff --git a/src/modules/buttons.rs b/src/modules/buttons.rs index 92b93b1..3f6c80e 100644 --- a/src/modules/buttons.rs +++ b/src/modules/buttons.rs @@ -53,13 +53,12 @@ async fn init_command_button(context: &mut InitializationContext, config: Button let command_topic = context.full.mqtt.get_homeassistant_topic("button", &entity_id, "trigger"); context - .send_retained_mqtt_message( - context.full.mqtt.get_homeassistant_topic("button", &entity_id, "config"), + .publish_to_owned_mqtt_topic( + &context.full.mqtt.get_homeassistant_topic("button", &entity_id, "config"), json::stringify(json::object! { "availability_topic": context.full.mqtt.availability_topic.clone(), "command_topic": command_topic.as_str(), "device": context.full.mqtt.discovery_device_object.clone(), - "icon": "mdi:power", "name": config.name, "payload_press": BUTTON_TRIGGER_TEXT, "object_id": entity_id.as_str(), diff --git a/src/modules/info.rs b/src/modules/info.rs new file mode 100644 index 0000000..d397245 --- /dev/null +++ b/src/modules/info.rs @@ -0,0 +1,197 @@ +use std::fs; +use std::time::Duration; + +use anyhow::{anyhow, Result}; +use rumqttc::QoS; +use serde::{Deserialize, Serialize}; +use sysinfo::{CpuExt, System, SystemExt}; +use tokio::time::MissedTickBehavior; +use validator::Validate; + +use crate::modules::InitializationContext; + +const MODULE_ID: &str = "info"; + +#[derive(Serialize, Deserialize, Validate)] +pub struct Config { + #[serde(default)] + enabled: bool, + + #[serde(default)] + ram_usage: u64, + + #[serde(default)] + cpu_usage: u64, + + #[serde(default)] + battery: u64, +} + +pub async fn init(context: &mut InitializationContext) -> Result<()> { + let full_context = context.get_full(); + let config = match &full_context.config.modules.info { + Some(c) if c.enabled => c, + _ => return Ok(()), + }; + + log::info!("Initializing…"); + + { + let mut sys = System::new(); + init_info_value( + InfoEntityOptions { + sub_id: "ram_usage", + display_name: "RAM Usage", + icon: "mdi:memory", + suggested_precision: 0, + unit: Some("%"), + }, + context, + config.ram_usage, + move || { + sys.refresh_memory(); + Ok(format!("{:.2}", (sys.available_memory() as f64) / (sys.total_memory() as f64) * 100.0)) + }, + ) + .await?; + } + + { + let mut sys = System::new(); + init_info_value( + InfoEntityOptions { + sub_id: "cpu_usage", + display_name: "CPU Usage", + icon: "mdi:memory", + suggested_precision: 0, + unit: Some("%"), + }, + context, + config.cpu_usage, + move || { + sys.refresh_cpu(); + Ok(format!("{:.2}", sys.global_cpu_info().cpu_usage())) + }, + ) + .await?; + } + + if config.battery != 0 { + let battery_dirs = fs::read_dir("/sys/class/power_supply")?.filter_map(|d| d.ok()).collect::>(); + + if let Some(dir) = battery_dirs.first() { + log::debug!("Found {} batteries, using {}", battery_dirs.len(), dir.file_name().to_string_lossy()); + let path = dir.path(); + let capacity_path = path.clone().join("capacity"); + let status_path = path.clone().join("status"); + + init_info_value( + InfoEntityOptions { + sub_id: "battery_level", + display_name: "Battery Level", + icon: "mdi:battery", + suggested_precision: 0, + unit: Some("%"), + }, + context, + config.battery, + move || Ok(fs::read_to_string(&capacity_path)?), + ) + .await?; + + init_info_value( + InfoEntityOptions { + sub_id: "battery_state", + display_name: "Battery State", + icon: "mdi:battery", + suggested_precision: 0, + unit: None, + }, + context, + config.battery, + move || Ok(fs::read_to_string(&status_path)?), + ) + .await?; + } else { + log::warn!("No batteries found.") + } + } + + Ok(()) +} + +struct InfoEntityOptions<'a> { + sub_id: &'a str, + display_name: &'a str, + icon: &'a str, + suggested_precision: u64, + unit: Option<&'a str>, +} + +async fn init_info_value( + options: InfoEntityOptions<'_>, + context: &mut InitializationContext, + interval_secs: u64, + mut fetch: impl (FnMut() -> Result) + Send + 'static, +) -> Result<()> { + let interval = if interval_secs == 0 { + return Ok(()); + } else { + Duration::from_secs(interval_secs) + }; + + let entity_id = context.full.get_entity_id(MODULE_ID, options.sub_id); + let state_topic = context.full.mqtt.get_homeassistant_topic("sensor", &entity_id, "state"); + context.register_owned_mqtt_topic(&state_topic); + + context + .publish_to_owned_mqtt_topic( + &context.full.mqtt.get_homeassistant_topic("sensor", &entity_id, "config"), + json::stringify({ + let mut object = json::object! { + "availability_topic": context.full.mqtt.availability_topic.clone(), + "device": context.full.mqtt.discovery_device_object.clone(), + "force_update": true, + "icon": options.icon, + "name": options.display_name, + "suggested_display_precision": options.suggested_precision, + "state_class": "measurement", + "state_topic": state_topic.as_str(), + "object_id": entity_id.as_str(), + "unique_id": entity_id.as_str() + }; + + if let Some(unit) = options.unit { + object.insert("unit_of_measurement", unit).unwrap(); + } + + object + }), + ) + .await?; + + let mqtt_client = context.full.mqtt.client.clone(); + + tokio::spawn(async move { + let mut interval = tokio::time::interval(interval); + interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + + loop { + interval.tick().await; + + let result = fetch(); + + match result { + Err(error) => log::error!("{:#}", anyhow!(error)), + + Ok(value) => { + if let Err(error) = mqtt_client.publish(&state_topic, QoS::ExactlyOnce, true, value).await { + log::error!("{:#}", anyhow!(error)); + }; + } + } + } + }); + + Ok(()) +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index f409ad2..fadb2ff 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -4,9 +4,24 @@ use std::sync::Arc; use anyhow::Result; use json::JsonValue; use rumqttc::{AsyncClient as MqttClient, ClientError, QoS}; +use serde::{Deserialize, Serialize}; +use validator::Validate; -pub mod buttons; -pub mod notifications; +mod buttons; +mod info; +mod notifications; + +#[derive(Serialize, Deserialize, Validate, Default)] +pub struct Config { + #[validate] + pub buttons: Option, + + #[validate] + pub info: Option, + + #[validate] + pub notifications: Option, +} type MqttMessageHandler = dyn Fn(&str) -> Result<()>; @@ -29,14 +44,17 @@ impl InitializationContext { self.message_handler_by_mqtt_topic.insert(topic.into(), Box::new(handler)); } - async fn send_retained_mqtt_message(&mut self, topic: impl Into, message: impl Into) -> std::result::Result<(), ClientError> { - let topic = topic.into(); + async fn publish_to_owned_mqtt_topic(&mut self, topic: &str, message: impl Into) -> std::result::Result<(), ClientError> { let message = message.into(); - self.owned_mqtt_topics.insert(topic.to_owned()); + self.register_owned_mqtt_topic(topic); self.full.mqtt.client.publish(topic, QoS::AtLeastOnce, true, message).await } + fn register_owned_mqtt_topic(&mut self, topic: &str) { + self.owned_mqtt_topics.insert(topic.into()); + } + fn get_full(&self) -> Arc { self.full.clone() } @@ -61,6 +79,7 @@ impl ModuleContextMqtt { pub async fn init_all(context: &mut InitializationContext) -> Result<()> { buttons::init(context).await?; + info::init(context).await?; notifications::init(context).await?; Ok(()) } diff --git a/src/util.rs b/src/util.rs index 2c29408..a6ccc1f 100644 --- a/src/util.rs +++ b/src/util.rs @@ -11,11 +11,23 @@ use tokio::task::JoinHandle; pub fn spawn_nonessential(future: impl Future> + Send + 'static) -> JoinHandle<()> { tokio::spawn(async { if let Err(error) = future.await { - log::error!("{:#}", error) + log_error(error); } }) } +#[inline] +pub fn run_nonessential(block: impl FnOnce() -> Result<()>) { + if let Err(error) = block() { + log_error(error); + } +} + +#[inline] +pub fn log_error(error: anyhow::Error) { + log::error!("{:#}", error); +} + pub fn generate_alphanumeric_id(length: usize) -> String { rand::thread_rng().sample_iter(&Alphanumeric).take(length).map(char::from).collect() }