use std::{fs, net::{Ipv4Addr, SocketAddr}, collections::HashMap}; use async_recursion::async_recursion; use regex::{Regex, Captures}; use tracing::{debug, trace}; use rumqttc::{AsyncClient, has_wildcards}; use serde::Deserialize; use eui48::MacAddress; use crate::{devices::{DeviceBox, IkeaOutlet, WakeOnLAN, AudioSetup, ContactSensor, KasaOutlet, self}, error::{FailedToParseConfig, MissingEnv, MissingWildcard, Error, FailedToCreateDevice}}; #[derive(Debug, Deserialize)] pub struct Config { pub openid: OpenIDConfig, pub mqtt: MqttConfig, #[serde(default)] pub fullfillment: FullfillmentConfig, pub ntfy: Option, pub presence: MqttDeviceConfig, pub light_sensor: LightSensorConfig, pub hue_bridge: Option, pub debug_bridge: Option, #[serde(default)] pub devices: HashMap, } #[derive(Debug, Clone, Deserialize)] pub struct OpenIDConfig { pub base_url: String } #[derive(Debug, Clone, Deserialize)] pub struct MqttConfig { pub host: String, pub port: u16, pub client_name: String, pub username: String, pub password: String, #[serde(default)] pub tls: bool, } #[derive(Debug, Deserialize)] pub struct FullfillmentConfig { #[serde(default = "default_fullfillment_ip")] pub ip: Ipv4Addr, #[serde(default = "default_fullfillment_port")] pub port: u16, } impl From for SocketAddr { fn from(fullfillment: FullfillmentConfig) -> Self { (fullfillment.ip, fullfillment.port).into() } } impl Default for FullfillmentConfig { fn default() -> Self { Self { ip: default_fullfillment_ip(), port: default_fullfillment_port() } } } fn default_fullfillment_ip() -> Ipv4Addr { [0, 0, 0, 0].into() } fn default_fullfillment_port() -> u16 { 7878 } #[derive(Debug, Deserialize)] pub struct NtfyConfig { #[serde(default = "default_ntfy_url")] pub url: String, pub topic: String, } fn default_ntfy_url() -> String { "https://ntfy.sh".into() } #[derive(Debug, Clone, Deserialize)] pub struct LightSensorConfig { #[serde(flatten)] pub mqtt: MqttDeviceConfig, pub min: isize, pub max: isize, } #[derive(Debug, Deserialize)] pub struct Flags { pub presence: isize, pub darkness: isize, } #[derive(Debug, Deserialize)] pub struct HueBridgeConfig { pub ip: Ipv4Addr, pub login: String, pub flags: Flags, } #[derive(Debug, Deserialize)] pub struct DebugBridgeConfig { pub topic: String, } #[derive(Debug, Clone, Deserialize)] pub struct InfoConfig { pub name: String, pub room: Option, } #[derive(Debug, Clone, Deserialize)] pub struct MqttDeviceConfig { pub topic: String, } #[derive(Debug, Clone, Deserialize)] pub struct KettleConfig { pub timeout: Option, // Timeout in seconds } #[derive(Debug, Clone, Deserialize)] pub struct PresenceDeviceConfig { #[serde(flatten)] pub mqtt: Option, // @TODO Maybe make this an option? That way if no timeout is set it will immediately turn the // device off again? pub timeout: u64 // Timeout in seconds } impl PresenceDeviceConfig { /// Set the mqtt topic to an appropriate value if it is not already set fn generate_topic(mut self, class: &str, identifier: &str, config: &Config) -> Result { if self.mqtt.is_none() { if !has_wildcards(&config.presence.topic) { return Err(MissingWildcard::new(&config.presence.topic).into()); } // @TODO This is not perfect, if the topic is some/+/thing/# this will fail let offset = config.presence.topic.find('+').or(config.presence.topic.find('#')).unwrap(); let topic = config.presence.topic[..offset].to_owned() + class + "/" + identifier; trace!("Setting presence mqtt topic: {topic}"); self.mqtt = Some(MqttDeviceConfig { topic }); } Ok(self) } } #[derive(Debug, Clone, Deserialize)] #[serde(tag = "type")] pub enum Device { IkeaOutlet { #[serde(flatten)] info: InfoConfig, #[serde(flatten)] mqtt: MqttDeviceConfig, kettle: Option, }, WakeOnLAN { #[serde(flatten)] info: InfoConfig, #[serde(flatten)] mqtt: MqttDeviceConfig, mac_address: MacAddress, }, KasaOutlet { ip: Ipv4Addr, }, AudioSetup { #[serde(flatten)] mqtt: MqttDeviceConfig, mixer: Box::, speakers: Box::, }, ContactSensor { #[serde(flatten)] mqtt: MqttDeviceConfig, presence: Option, } } impl Config { pub fn parse_file(filename: &str) -> Result { debug!("Loading config: {filename}"); let file = fs::read_to_string(filename) .map_err(|err| FailedToParseConfig::new(filename, err.into()))?; // Substitute in environment variables let re = Regex::new(r"\$\{(.*)\}").unwrap(); let mut missing = MissingEnv::new(); let file = re.replace_all(&file, |caps: &Captures| { let key = caps.get(1).unwrap().as_str(); debug!("Substituting '{key}' in config"); match std::env::var(key) { Ok(value) => value, Err(_) => { missing.add_missing(key); "".to_string() } } }); missing.has_missing() .map_err(|err| FailedToParseConfig::new(filename, err.into()))?; let config: Config = toml::from_str(&file) .map_err(|err| FailedToParseConfig::new(filename, err.into()))?; Ok(config) } } // Quick helper function to box up the devices, // passing in Box::new would be ideal, however the return type is incorrect // Maybe there is a better way to solve this? fn device_box(device: T) -> DeviceBox { let a: DeviceBox = Box::new(device); a } impl Device { #[async_recursion] pub async fn create(self, identifier: &str, config: &Config, client: AsyncClient) -> Result { let device: Result = match self { Device::IkeaOutlet { info, mqtt, kettle } => { trace!(id = identifier, "IkeaOutlet [{} in {:?}]", info.name, info.room); IkeaOutlet::build(&identifier, info, mqtt, kettle, client).await .map(device_box) }, Device::WakeOnLAN { info, mqtt, mac_address } => { trace!(id = identifier, "WakeOnLan [{} in {:?}]", info.name, info.room); WakeOnLAN::build(&identifier, info, mqtt, mac_address, client).await .map(device_box) }, Device::KasaOutlet { ip } => { trace!(id = identifier, "KasaOutlet [{}]", identifier); Ok(Box::new(KasaOutlet::new(&identifier, ip))) } Device::AudioSetup { mqtt, mixer, speakers } => { trace!(id = identifier, "AudioSetup [{}]", identifier); // Create the child devices let mixer_id = identifier.to_owned() + ".mixer"; let mixer = (*mixer).create(&mixer_id, config, client.clone()).await?; let speakers_id = identifier.to_owned() + ".speakers"; let speakers = (*speakers).create(&speakers_id, config, client.clone()).await?; AudioSetup::build(&identifier, mqtt, mixer, speakers, client).await .map(device_box) }, Device::ContactSensor { mqtt, presence } => { trace!(id = identifier, "ContactSensor [{}]", identifier); let presence = presence .map(|p| p.generate_topic("contact", &identifier, &config)) .transpose() .map_err(|err| FailedToCreateDevice::new(&identifier, err.into()))?; ContactSensor::build(&identifier, mqtt, presence, client).await .map(device_box) }, }; return device.map_err(|err| FailedToCreateDevice::new(&identifier, err)); } }