use crate::models::{ DebugSettings, GreeCloudSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings, NotificationSettings, RuntimeSettings, }; use anyhow::{Context, Result}; use clap::Parser; use std::{env, net::SocketAddr, path::PathBuf}; #[derive(Debug, Clone, Parser)] #[command(author, version, about)] pub struct Config { #[arg(long, env = "GREE_CONTROLLER_BIND", default_value = "0.0.0.0:8787")] pub bind: SocketAddr, #[arg( long, env = "GREE_CONTROLLER_DATABASE", default_value = "./data/gree-controller.db" )] pub database: PathBuf, #[arg(long, env = "GREE_CONTROLLER_APP_TOKEN", default_value = "")] pub app_token: String, #[arg(long, env = "GREE_CONTROLLER_BASE_PATH", default_value = "")] pub base_path: String, #[arg( long, env = "GREE_CONTROLLER_PUBLIC_CHART_BASE_URL", default_value = "" )] pub public_chart_base_url: String, #[arg(long, env = "GREE_CONTROLLER_SIMULATE", default_value_t = false)] pub simulate: bool, #[arg(long, env = "GREE_CONTROLLER_AUTO_SEED", default_value_t = false)] pub auto_seed: bool, #[arg( long, env = "GREE_CONTROLLER_POLL_INTERVAL_SECONDS", default_value_t = 15 )] pub poll_interval_seconds: u64, #[arg( long, env = "GREE_CONTROLLER_ZONE_INTERVAL_SECONDS", default_value_t = 5 )] pub zone_interval_seconds: u64, #[arg( long, env = "GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS", default_value_t = 3000 )] pub discovery_timeout_ms: u64, #[arg( long, env = "GREE_CONTROLLER_DISCOVERY_BROADCAST", default_value = "255.255.255.255:7000" )] pub discovery_broadcast: String, #[arg(long, env = "GREE_CONTROLLER_GREE_INTERFACE", default_value = "")] pub gree_interface: String, #[arg(long, env = "GREE_CONTROLLER_ID", default_value = "gree-controller")] pub controller_id: String, } impl Config { pub fn load() -> Result { dotenvy::dotenv().ok(); let mut config = Self::parse(); config.base_path = normalize_base_path(&config.base_path)?; config.public_chart_base_url = normalize_public_chart_base_url(&config.public_chart_base_url)?; if let Some(parent) = config.database.parent() { std::fs::create_dir_all(parent).with_context(|| { format!("cannot create database directory {}", parent.display()) })?; } Ok(config) } pub fn runtime_defaults(&self) -> RuntimeSettings { RuntimeSettings { controller_id: self.controller_id.clone(), simulator_enabled: self.simulate, poll_interval_seconds: self.poll_interval_seconds.max(2), zone_interval_seconds: self.zone_interval_seconds.max(2), discovery_timeout_ms: self.discovery_timeout_ms.clamp(300, 30_000), discovery_broadcast: self.discovery_broadcast.clone(), ping_metrics_enabled: env_bool("GREE_CONTROLLER_PING_METRICS_ENABLED").unwrap_or(true), ping_interval_seconds: env_u64("GREE_CONTROLLER_PING_INTERVAL_SECONDS") .unwrap_or(60) .clamp(10, 3600), ping_sample_count: env_u32("GREE_CONTROLLER_PING_SAMPLE_COUNT") .unwrap_or(3) .clamp(1, 10), house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()), control_strategy: "setpoint".into(), outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED") .unwrap_or(true), history_retention_days: env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS") .unwrap_or(30) .clamp(1, 3650), history_compaction_enabled: env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED") .unwrap_or(true), event_log_retention_days: env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS") .unwrap_or(30) .clamp(1, 3650), suppress_device_beep: env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP").unwrap_or(false), compressor_protection_enabled: env_bool( "GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED", ) .unwrap_or(true), compressor_protection_seconds: env_u64("GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS") .unwrap_or(180) .clamp(30, 1800), influxdb: influx_settings_from_env(), debug: DebugSettings { overlay_enabled: env_bool("GREE_CONTROLLER_DEBUG_OVERLAY").unwrap_or(false), gree_frames: env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES").unwrap_or(false), cloud_requests: env_bool("GREE_CONTROLLER_DEBUG_CLOUD_REQUESTS").unwrap_or(false), cloud_mqtt: env_bool("GREE_CONTROLLER_DEBUG_CLOUD_MQTT").unwrap_or(false), }, notifications: NotificationSettings::default(), gree_cloud: GreeCloudSettings::default(), night_mode: NightModeSettings { enabled: env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED").unwrap_or(false), start_time: env::var("GREE_CONTROLLER_NIGHT_MODE_START") .unwrap_or_else(|_| "22:00".into()), end_time: env::var("GREE_CONTROLLER_NIGHT_MODE_END") .unwrap_or_else(|_| "06:00".into()), max_fan_speed: env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED") .unwrap_or(1) .clamp(1, 5), force_quiet: env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET").unwrap_or(true), use_native_sleep: env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP") .unwrap_or(true), }, home_assistant: HomeAssistantSettings { url: env::var("HA_URL").unwrap_or_default(), token: env::var("HA_TOKEN").unwrap_or_default(), outdoor_entity_id: env::var("HA_OUTDOOR_ENTITY_ID").unwrap_or_default(), sensor_stale_after_seconds: env_u64("HA_SENSOR_STALE_AFTER_SECONDS") .unwrap_or(300) .clamp(30, 86_400), allow_invalid_tls: env::var("HA_ALLOW_INVALID_TLS") .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) .unwrap_or(false), manual_auth_override: false, sensor_aliases: Default::default(), flow_inputs: Vec::new(), }, } } /// Environment values explicitly supplied by the service override persisted runtime values. pub fn apply_runtime_env_overrides(&self, settings: &mut RuntimeSettings) { if let Some(value) = env_bool("GREE_CONTROLLER_PING_METRICS_ENABLED") { settings.ping_metrics_enabled = value; } if let Some(value) = env_u64("GREE_CONTROLLER_PING_INTERVAL_SECONDS") { settings.ping_interval_seconds = value.clamp(10, 3600); } if let Some(value) = env_u32("GREE_CONTROLLER_PING_SAMPLE_COUNT") { settings.ping_sample_count = value.clamp(1, 10); } if env::var_os("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").is_some() { settings.history_retention_days = env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS") .unwrap_or(settings.history_retention_days) .clamp(1, 3650); } if let Some(value) = env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED") { settings.history_compaction_enabled = value; } if env::var_os("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").is_some() { settings.event_log_retention_days = env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS") .unwrap_or(settings.event_log_retention_days) .clamp(1, 3650); } if let Some(value) = env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP") { settings.suppress_device_beep = value; } if let Some(value) = env_bool("GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED") { settings.compressor_protection_enabled = value; } if let Some(value) = env_u64("GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS") { settings.compressor_protection_seconds = value.clamp(30, 1800); } if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_OVERLAY") { settings.debug.overlay_enabled = value; } if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES") { settings.debug.gree_frames = value; } if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_CLOUD_REQUESTS") { settings.debug.cloud_requests = value; } if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_CLOUD_MQTT") { settings.debug.cloud_mqtt = value; } if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED") { settings.night_mode.enabled = value; } if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_START") { if !value.trim().is_empty() { settings.night_mode.start_time = value; } } if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_END") { if !value.trim().is_empty() { settings.night_mode.end_time = value; } } if let Some(value) = env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED") { settings.night_mode.max_fan_speed = value.clamp(1, 5); } if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET") { settings.night_mode.force_quiet = value; } if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP") { settings.night_mode.use_native_sleep = value; } if let Some(value) = env_u64("HA_SENSOR_STALE_AFTER_SECONDS") { settings.home_assistant.sensor_stale_after_seconds = value.clamp(30, 86_400); } let influx_env_present = [ "GREE_CONTROLLER_INFLUX_ENABLED", "GREE_CONTROLLER_INFLUX_VERSION", "GREE_CONTROLLER_INFLUX_URL", "GREE_CONTROLLER_INFLUX_DATABASE", "GREE_CONTROLLER_INFLUX_USERNAME", "GREE_CONTROLLER_INFLUX_PASSWORD", "GREE_CONTROLLER_INFLUX_ORG", "GREE_CONTROLLER_INFLUX_BUCKET", "GREE_CONTROLLER_INFLUX_TOKEN", "GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS", "INFLUXDB_URL", "INFLUXDB_DATABASE", "INFLUXDB_USERNAME", "INFLUXDB_PASSWORD", "INFLUXDB_TOKEN", "INFLUXDB_ORG", "INFLUXDB_BUCKET", ] .iter() .any(|name| env::var_os(name).is_some()); if influx_env_present { let env_settings = influx_settings_from_env(); if env::var_os("GREE_CONTROLLER_INFLUX_ENABLED").is_some() { settings.influxdb.enabled = env_settings.enabled; } else if first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).is_some() { settings.influxdb.enabled = true; } if first_env(&["GREE_CONTROLLER_INFLUX_VERSION"]).is_some() { settings.influxdb.version = env_settings.version; } if first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).is_some() { settings.influxdb.url = env_settings.url; } if first_env(&["GREE_CONTROLLER_INFLUX_DATABASE", "INFLUXDB_DATABASE"]).is_some() { settings.influxdb.database = env_settings.database; } if first_env(&["GREE_CONTROLLER_INFLUX_USERNAME", "INFLUXDB_USERNAME"]).is_some() { settings.influxdb.username = env_settings.username; } if first_env(&["GREE_CONTROLLER_INFLUX_PASSWORD", "INFLUXDB_PASSWORD"]).is_some() { settings.influxdb.password = env_settings.password; } if first_env(&["GREE_CONTROLLER_INFLUX_ORG", "INFLUXDB_ORG"]).is_some() { settings.influxdb.org = env_settings.org; } if first_env(&["GREE_CONTROLLER_INFLUX_BUCKET", "INFLUXDB_BUCKET"]).is_some() { settings.influxdb.bucket = env_settings.bucket; } if first_env(&["GREE_CONTROLLER_INFLUX_TOKEN", "INFLUXDB_TOKEN"]).is_some() { settings.influxdb.token = env_settings.token; } if env::var_os("GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS").is_some() { settings.influxdb.history_threshold_days = env_settings.history_threshold_days; } } } } fn normalize_base_path(value: &str) -> Result { let value = value.trim(); if value.is_empty() || value == "/" { return Ok(String::new()); } if value.contains('?') || value.contains('#') || value.split('/').any(|part| matches!(part, "." | "..")) { anyhow::bail!("GREE_CONTROLLER_BASE_PATH must be a simple URL path without '.', '..', query or fragment"); } Ok(format!("/{}", value.trim_matches('/'))) } fn normalize_public_chart_base_url(value: &str) -> Result { let value = value.trim(); if value.is_empty() { return Ok(String::new()); } let parsed = url::Url::parse(value) .with_context(|| "GREE_CONTROLLER_PUBLIC_CHART_BASE_URL must be an absolute HTTP(S) URL")?; if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() || !parsed.username().is_empty() || parsed.password().is_some() || parsed.query().is_some() || parsed.fragment().is_some() { anyhow::bail!("GREE_CONTROLLER_PUBLIC_CHART_BASE_URL must be an absolute HTTP(S) URL without credentials, query or fragment"); } Ok(value.trim_end_matches('/').to_string()) } fn env_bool(name: &str) -> Option { env::var(name) .ok() .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) } fn env_u32(name: &str) -> Option { env::var(name).ok()?.parse().ok() } fn env_u64(name: &str) -> Option { env::var(name).ok()?.parse().ok() } fn env_u8(name: &str) -> Option { env::var(name).ok()?.parse().ok() } fn first_env(names: &[&str]) -> Option { names.iter().find_map(|name| { let value = env::var(name).ok()?; (!value.trim().is_empty()).then_some(value) }) } fn influx_settings_from_env() -> InfluxDbSettings { let mut settings = InfluxDbSettings::default(); settings.version = first_env(&["GREE_CONTROLLER_INFLUX_VERSION"]).unwrap_or_else(|| "2".into()); settings.url = first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).unwrap_or_default(); settings.enabled = env_bool("GREE_CONTROLLER_INFLUX_ENABLED").unwrap_or(!settings.url.is_empty()); settings.database = first_env(&["GREE_CONTROLLER_INFLUX_DATABASE", "INFLUXDB_DATABASE"]) .unwrap_or_else(|| "gree_controller".into()); settings.username = first_env(&["GREE_CONTROLLER_INFLUX_USERNAME", "INFLUXDB_USERNAME"]).unwrap_or_default(); settings.password = first_env(&["GREE_CONTROLLER_INFLUX_PASSWORD", "INFLUXDB_PASSWORD"]).unwrap_or_default(); settings.org = first_env(&["GREE_CONTROLLER_INFLUX_ORG", "INFLUXDB_ORG"]).unwrap_or_default(); settings.bucket = first_env(&["GREE_CONTROLLER_INFLUX_BUCKET", "INFLUXDB_BUCKET"]) .unwrap_or_else(|| "gree_controller".into()); settings.token = first_env(&["GREE_CONTROLLER_INFLUX_TOKEN", "INFLUXDB_TOKEN"]).unwrap_or_default(); settings.history_threshold_days = env_u32("GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS") .unwrap_or(30) .clamp(1, 3650); settings }