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, pub password: Option, 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 { dirs::config_dir().map(|d| d.join("plasma-org.kde.plasma.desktop-appletsrc")) } fn find_applet_section_id(ini: &Ini) -> Option { 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::() { 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 = None; let mut password: Option = 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::() { 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>, 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. }