diff options
| author | Dawid Rycerz <dawid@rycerz.xyz> | 2026-02-07 17:29:48 +0100 |
|---|---|---|
| committer | Dawid Rycerz <dawid@rycerz.xyz> | 2026-02-07 17:29:48 +0100 |
| commit | 2eda97537b63d68b2e9ba06500e3fb491894d10c (patch) | |
| tree | 52873ad380cd97f4327765aac24659a2b00079b1 /service/src/config.rs | |
feat: camper van energy monitoring widget for Plasma 6main
Pure QML KPackage widget with Rust background service for real-time
Victron energy system monitoring via MQTT and D-Bus.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'service/src/config.rs')
| -rw-r--r-- | service/src/config.rs | 244 |
1 files changed, 244 insertions, 0 deletions
diff --git a/service/src/config.rs b/service/src/config.rs new file mode 100644 index 0000000..dd98ec7 --- /dev/null +++ b/service/src/config.rs @@ -0,0 +1,244 @@ +use anyhow::Result; +use configparser::ini::Ini; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; +use zbus::Connection; + +#[derive(Clone, Debug)] +pub struct Config { + pub endpoints: Vec<(String, u16)>, + pub username: Option<String>, + pub password: Option<String>, + pub client_id: String, + pub refresh_interval_seconds: u64, +} + +fn default_port() -> u16 { + 1883 +} +fn default_client_id() -> String { + "camper-widget-refresh".to_string() +} +fn default_refresh_interval() -> u64 { + 60 +} +// log level is now controlled via RUST_LOG in main; no per-config default needed + +fn plasma_applet_src_path() -> Option<PathBuf> { + dirs::config_dir().map(|d| d.join("plasma-org.kde.plasma.desktop-appletsrc")) +} + +fn find_applet_section_id(ini: &Ini) -> Option<String> { + for section in ini.sections() { + if let Some(plugin) = ini.get(§ion, "plugin") + && plugin == "craftknight.camper_widget" + { + return Some(section); + } + } + warn!("No applet section id found"); + None +} + +fn unescape_kde_value(s: &str) -> String { + s.replace("\\s", " ") + .replace("\\t", "\t") + .replace("\\n", "\n") + .replace("\\\\", "\\") +} + +fn parse_mqtt_hosts(raw: &str) -> Vec<(String, u16)> { + let unescaped = unescape_kde_value(raw); + let mut out: Vec<(String, u16)> = Vec::new(); + for entry in unescaped.split(',') { + let part = entry.trim(); + if part.is_empty() { + continue; + } + if let Some((host, port_str)) = part.rsplit_once(':') + && let Ok(port) = port_str.parse::<u16>() + { + out.push((host.trim().to_string(), port)); + continue; + } + out.push((part.to_string(), default_port())); + } + if out.is_empty() { + out.push(("127.0.0.1".to_string(), default_port())); + } + out +} + +pub async fn load_config() -> Config { + // Defaults + let mut endpoints: Vec<(String, u16)> = vec![("127.0.0.1".to_string(), default_port())]; + let mut username: Option<String> = None; + let mut password: Option<String> = None; + let client_id = default_client_id(); + let mut refresh_interval_seconds = default_refresh_interval(); + + if let Some(path) = plasma_applet_src_path() { + debug!("Reading Plasma INI config from {:?}", path); + match tokio::fs::read_to_string(&path).await { + Ok(contents) => { + let mut ini = Ini::new(); + if let Err(e) = ini.read(contents) { + warn!("Failed to parse Plasma INI config at {:?}: {}", path, e); + } + let sections = ini.sections(); + debug!("Found {} INI sections", sections.len()); + if let Some(base) = find_applet_section_id(&ini) { + debug!("Matched applet section id: {}", base); + // Build the [Configuration][General] section name from the found applet base + let sec_general = format!("{}][configuration][general", base); + if ini.sections().contains(&sec_general) { + debug!("Using section: {}", sec_general); + debug!("Looking under primary section: {}", sec_general); + let mut read_any = false; + let section = &sec_general; + debug!("Attempt reading values from section: {}", section); + if let Some(raw_hosts) = ini.get(section, "mqttHosts") { + debug!("Found mqttHosts='{}'", raw_hosts); + endpoints = parse_mqtt_hosts(&raw_hosts); + debug!("Parsed endpoints: {:?}", endpoints); + read_any = true; + } + if let Some(u) = ini.get(section, "mqttUsername") + && !u.is_empty() + { + username = Some(u); + debug!("Found mqttUsername set"); + read_any = true; + } + if let Some(p) = ini.get(section, "mqttPassword") + && !p.is_empty() + { + password = Some(p); + debug!("Found mqttPassword set (redacted)"); + read_any = true; + } + if let Some(sec) = ini.get(section, "refreshIntervalSeconds") + && let Ok(v) = sec.trim().parse::<u64>() + { + refresh_interval_seconds = v; + debug!("Found refreshIntervalSeconds={}", v); + read_any = true; + } + if !read_any { + warn!("No configuration keys found under {}", section); + } + } else { + warn!("Could not find target configuration section"); + } + } else { + warn!("No applet section id found"); + } + } + Err(e) => { + warn!("Failed to read Plasma INI config at {:?}: {}", path, e); + } + } + } else { + warn!("No config dir found (dirs::config_dir returned None)"); + } + + Config { + endpoints, + username, + password, + client_id, + refresh_interval_seconds, + } +} + +pub async fn listen_for_config_changes( + _conn: &Connection, + config_state: Arc<RwLock<Config>>, + shared_state: &crate::dbus::SharedState, + token: CancellationToken, +) -> Result<()> { + info!("Starting config reload monitor"); + + loop { + tokio::select! { + _ = shared_state.config_reload_notify.notified() => {} + _ = token.cancelled() => { + info!("Config reload monitor shutting down"); + return Ok(()); + } + } + + info!("Config reload triggered, reloading configuration"); + + let new_config = load_config().await; + + { + let mut config_guard = config_state.write().await; + *config_guard = new_config; + } + + info!("Configuration reloaded successfully"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_mqtt_hosts_various() { + assert_eq!( + parse_mqtt_hosts("127.0.0.1"), + vec![("127.0.0.1".to_string(), 1883)] + ); + assert_eq!( + parse_mqtt_hosts("127.0.0.1:1883"), + vec![("127.0.0.1".to_string(), 1883)] + ); + assert_eq!( + parse_mqtt_hosts("127.0.0.1,192.168.1.111"), + vec![ + ("127.0.0.1".to_string(), 1883), + ("192.168.1.111".to_string(), 1883) + ] + ); + assert_eq!( + parse_mqtt_hosts("127.0.0.1:1883,192.168.1.111"), + vec![ + ("127.0.0.1".to_string(), 1883), + ("192.168.1.111".to_string(), 1883) + ] + ); + assert_eq!( + parse_mqtt_hosts("127.0.0.1:1883,192.168.1.111:1833"), + vec![ + ("127.0.0.1".to_string(), 1883), + ("192.168.1.111".to_string(), 1833) + ] + ); + // KDE config escapes leading spaces as \s + assert_eq!( + parse_mqtt_hosts("\\s192.168.10.202 :1883"), + vec![("192.168.10.202".to_string(), 1883)] + ); + } + + #[test] + fn test_find_applet_section_id() { + let mut ini = Ini::new(); + // Base section like [Containments][85][Applets][133] + ini.set( + "Containments][85][Applets][133", + "plugin", + Some("craftknight.camper_widget".to_string()), + ); + let found = find_applet_section_id(&ini); + // configparser normalizes section names to lowercase + assert_eq!(found, Some("containments][85][applets][133".to_string())); + } + + // We avoid testing load_config() directly because it depends on user's plasma file. +} |
