diff --git a/Cargo.lock b/Cargo.lock index 319a10e..2ec2d75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,15 +342,6 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "directories" -version = "4.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f51c5d4ddabd36886dd3e1438cb358cdcb0d7c499cb99cb4ac2e38e18b5cb210" -dependencies = [ - "dirs-sys", -] - [[package]] name = "dirs" version = "4.0.0" @@ -681,25 +672,25 @@ dependencies = [ "anyhow", "base64", "battery", - "directories", "env_logger", "exitcode", "image", "json", "lazy_static", - "log", - "notify-rust", - "rand", - "regex", - "rumqttc", - "serde", - "serde_json", - "sysinfo", - "tokio", - "toml", - "validator", - "void", - "zbus", + "log", + "notify-rust", + "rand", + "regex", + "rumqttc", + "serde", + "serde_json", + "sysinfo", + "tokio", + "toml", + "users", + "validator", + "void", + "zbus", ] [[package]] @@ -1987,9 +1978,19 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" dependencies = [ - "form_urlencoded", - "idna 0.3.0", - "percent-encoding", + "form_urlencoded", + "idna 0.3.0", + "percent-encoding", +] + +[[package]] +name = "users" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24cc0f6d6f267b73e5a2cadf007ba8f9bc39c6a6f9666f8cf25ea809a153b032" +dependencies = [ + "libc", + "log", ] [[package]] @@ -1998,9 +1999,9 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32ad5bf234c7d3ad1042e5252b7eddb2c4669ee23f32c7dd0e9b7705f07ef591" dependencies = [ - "idna 0.2.3", - "lazy_static", - "regex", + "idna 0.2.3", + "lazy_static", + "regex", "serde", "serde_derive", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 2e29db9..a249565 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "hassliebe" version = "0.1.0" edition = "2021" +homepage = "https://git.moritzruth.de/moritzruth/Hassliebe" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -12,7 +13,6 @@ dry_run = [] # will prevent some actions like shutting down 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" image = "0.24.5" @@ -28,6 +28,7 @@ serde_json = "1.0.93" sysinfo = { version = "0.28.2", default-features = false } tokio = { version = "1.25.0", features = ["full"] } toml = "0.7.2" +users = "0.11.0" 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 38e98b2..74bac2b 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,10 @@ Ideas: TBD -If the configuration file does not exist, an example file is created and the program exits. +Hassliebe uses different paths depending on whether it is run as root or as a normal user: -The default path is `$XDG_CONFIG_HOME/hassliebe/config.toml`. -It can be changed using the `HASSLIEBE_CONFIG` environment variable. +- `[data]`: `/var/lib/hassliebe` / `$XDG_DATA_HOME/hassliebe` +- `[config]`: `/etc/hassliebe/config.toml` / `$XDG_CONFIG_HOME/hassliebe/config.toml` Example: @@ -61,7 +61,7 @@ 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`. +Otherwise, a random ID will be generated on the first run and stored in `[data]/machine_id`. The latter always takes precedence. ## Modules @@ -100,6 +100,8 @@ battery = 60 # updates every 60 seconds ### Notifications +**Not available when running as a system service.** + The notifications module allows sending notifications according to the [Desktop Notifications Specification](https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html#markup). @@ -128,7 +130,7 @@ Complex messages have these properties: - `long_content` (optional) — Optional additional content which [supports a subset of HTML](https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html#markup). - `transient` (optional) — Boolean indicating whether to bypass the notification server’s persistence capability. -- `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 diff --git a/src/config.rs b/src/config.rs index ea684de..232e059 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,16 +1,14 @@ -use std::borrow::ToOwned; -use std::fs; use std::fs::File; -use std::io::{ErrorKind, Read, Write}; +use std::io::{ErrorKind, Read}; use std::path::Path; -use anyhow::{bail, Context, Result}; -use serde::{Deserialize, Serialize}; +use anyhow::{Context, Result}; +use serde::Deserialize; use validator::Validate; use crate::modules; -#[derive(Serialize, Deserialize, Validate)] +#[derive(Deserialize, Validate)] pub struct Mqtt { #[validate(length(min = 1))] pub host: String, @@ -21,19 +19,19 @@ pub struct Mqtt { pub credentials: Option, } -#[derive(Serialize, Deserialize, Validate)] +#[derive(Deserialize, Validate)] pub struct MqttCredentials { pub user: String, pub password: String, } -#[derive(Serialize, Deserialize, Validate)] +#[derive(Deserialize, Validate)] pub struct Internal { #[validate(length(min = 2))] pub stable_id: String, } -#[derive(Serialize, Deserialize, Validate)] +#[derive(Deserialize, Validate)] pub struct Config { #[validate(custom = "crate::util::validate_hass_id")] pub friendly_id: String, @@ -48,49 +46,27 @@ pub struct Config { pub modules: modules::Config, } -fn create_example_config() -> Config { - Config { - friendly_id: "my_pc".to_owned(), - display_name: "My PC".to_owned(), - mqtt: Mqtt { - host: "".to_owned(), - port: 1883, - credentials: None, - }, - modules: modules::Config::default(), - } -} - pub fn load(config_file_path: &Path) -> Result> { match File::open(config_file_path) { Ok(mut file) => { - log::info!("Reading config file: {}", config_file_path.to_string_lossy()); + log::info!("Reading configuration file: {}", config_file_path.to_string_lossy()); let mut string_content = String::new(); - file.read_to_string(&mut string_content).context("while reading the config file")?; + file.read_to_string(&mut string_content).context("while reading the configuration file")?; - let parsed = toml::from_str::(string_content.as_str()).context("while parsing the config file")?; - parsed.validate().context("while validating the config file")?; + let parsed = toml::from_str::(string_content.as_str()).context("while parsing the configuration file")?; + parsed.validate().context("while validating the configuration file")?; Ok(Some(parsed)) } Err(error) if error.kind() == ErrorKind::NotFound => { - if let Some(parent) = config_file_path.parent() { - fs::create_dir_all(parent)?; - } - - let mut file = File::create(config_file_path).context("while creating the default config file")?; - let default = toml::to_string::(&create_example_config()).expect("create_example_config() should be valid"); - - file.write_all(default.as_bytes())?; - - log::warn!("Created an example config file at {}", config_file_path.to_string_lossy()); - log::warn!("Make sure to edit the file before running again."); + log::error!("The configuration file does not exist: {}", config_file_path.to_string_lossy()); + log::info!("See the documentation: {}", env!("CARGO_PKG_HOMEPAGE")); Ok(None) } - Err(error) => bail!("Config file could not be opened: {}", error), + Err(error) => Err(error).context("while opening the configuration file"), } } diff --git a/src/main.rs b/src/main.rs index e9f1d48..bb896fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,10 @@ use std::collections::{HashMap, HashSet}; +use std::env; use std::path::{Path, PathBuf}; use std::process::exit; use std::sync::Arc; -use anyhow::{anyhow, Result}; +use anyhow::{bail, Result}; use crate::modules::{InitializationContext, ModuleContext, ModuleContextMqtt}; use crate::mqtt::OwnedTopicsService; @@ -14,28 +15,46 @@ mod modules; mod mqtt; mod util; +#[derive(Debug)] struct Paths { data_directory: Box, config: Box, } async fn get_paths_and_create_directories() -> Result { - let project_dirs = directories::ProjectDirs::from("", "", "Hassliebe"); + let is_root: bool = users::get_current_uid() == 0; - let paths = Paths { - config: std::env::var_os("HASS_CONFIG") - .map(PathBuf::from) - // I don’t like this clone - .or(project_dirs.clone().map(|d| d.config_dir().to_path_buf().join("config.toml"))) - .ok_or_else(|| anyhow!("Please specify a config file via HASS_CONFIG"))? - .into_boxed_path(), - data_directory: std::env::var_os("HASS_DATA_DIR") - .map(PathBuf::from) - .or(project_dirs.map(|d| d.data_dir().to_path_buf())) - .ok_or_else(|| anyhow!("Please specify a data directory via HASS_DATA_DIR"))? - .into_boxed_path(), + let paths = if is_root { + Paths { + config: PathBuf::from("/etc/hassliebe/config.toml").into_boxed_path(), + data_directory: PathBuf::from("/var/lib/hassliebe").into_boxed_path(), + } + } else { + let home = env::var_os("HOME").map(PathBuf::from); + + if let Some(home) = home { + Paths { + config: env::var_os("XDG_CONFIG_HOME") + .filter(|p| !p.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.clone().join(".config")) + .join("hassliebe/config.toml") + .into_boxed_path(), + + data_directory: env::var_os("XDG_DATA_HOME") + .filter(|p| !p.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.clone().join(".local/share")) + .join("hassliebe") + .into_boxed_path(), + } + } else { + bail!("The HOME environment variable must be set when not running as root.") + } }; + log::debug!("{:?}", &paths); + if let Some(p) = paths.config.parent() { tokio::fs::create_dir_all(p).await?; } @@ -83,6 +102,12 @@ async fn load_machine_id(paths: &Paths) -> Result { async fn main() -> Result<()> { initialize_logger(); + if env::args().any(|a| a == "-v" || a == "--version" || a == "-h" || a == "--help") { + println!("Hassliebe v{} is licensed under the Blue Oak Model License 1.0.0.", env!("CARGO_PKG_VERSION")); + println!("See https://git.moritzruth.de/moritzruth/Hassliebe for more information."); + exit(exitcode::OK); + } + let paths = get_paths_and_create_directories().await?; let machine_id = load_machine_id(&paths).await?; diff --git a/src/modules/buttons.rs b/src/modules/buttons.rs index 371e800..e040b5e 100644 --- a/src/modules/buttons.rs +++ b/src/modules/buttons.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use tokio::process::Command; use validator::Validate; @@ -8,7 +8,7 @@ use crate::modules::InitializationContext; const MODULE_ID: &str = "buttons"; const BUTTON_TRIGGER_TEXT: &str = "press"; -#[derive(Serialize, Deserialize, Validate, Clone)] +#[derive(Deserialize, Validate, Clone)] pub struct ButtonConfig { #[validate(custom = "crate::util::validate_hass_id")] pub id: String, @@ -23,7 +23,7 @@ pub struct ButtonConfig { pub run_in_shell: bool, } -#[derive(Serialize, Deserialize, Validate)] +#[derive(Deserialize, Validate)] pub struct Config { #[serde(default)] pub enabled: bool, diff --git a/src/modules/info.rs b/src/modules/info.rs index d397245..33a912d 100644 --- a/src/modules/info.rs +++ b/src/modules/info.rs @@ -3,7 +3,7 @@ use std::time::Duration; use anyhow::{anyhow, Result}; use rumqttc::QoS; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use sysinfo::{CpuExt, System, SystemExt}; use tokio::time::MissedTickBehavior; use validator::Validate; @@ -12,7 +12,7 @@ use crate::modules::InitializationContext; const MODULE_ID: &str = "info"; -#[derive(Serialize, Deserialize, Validate)] +#[derive(Deserialize, Validate)] pub struct Config { #[serde(default)] enabled: bool, @@ -79,6 +79,8 @@ pub async fn init(context: &mut InitializationContext) -> Result<()> { if config.battery != 0 { let battery_dirs = fs::read_dir("/sys/class/power_supply")?.filter_map(|d| d.ok()).collect::>(); + // TODO: Filter battery_dirs by the existence of "capacity" + 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(); diff --git a/src/modules/mod.rs b/src/modules/mod.rs index a5b2199..6617d74 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -4,14 +4,14 @@ use std::sync::Arc; use anyhow::Result; use json::JsonValue; use rumqttc::{AsyncClient as MqttClient, ClientError, QoS}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use validator::Validate; mod buttons; mod info; mod notifications; -#[derive(Serialize, Deserialize, Validate, Default)] +#[derive(Deserialize, Validate, Default)] pub struct Config { #[validate] pub buttons: Option, diff --git a/src/modules/notifications.rs b/src/modules/notifications.rs index 8f6b7e6..15b7576 100644 --- a/src/modules/notifications.rs +++ b/src/modules/notifications.rs @@ -5,7 +5,7 @@ use anyhow::{Context, Result}; use base64::Engine; use notify_rust::Hint; use rumqttc::QoS; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use tokio::task::spawn_blocking; use validator::Validate; @@ -14,7 +14,7 @@ use crate::util::{generate_alphanumeric_id, hash_string_to_u32, spawn_nonessenti const MODULE_ID: &str = "notifications"; -#[derive(Serialize, Deserialize, Validate)] +#[derive(Deserialize, Validate)] pub struct Config { #[serde(default)] pub enabled: bool,