Files
gree-controller/src/config.rs
T
2026-09-17 08:52:02 +02:00

363 lines
16 KiB
Rust

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<Self> {
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<String> {
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<String> {
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<bool> {
env::var(name)
.ok()
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
}
fn env_u32(name: &str) -> Option<u32> {
env::var(name).ok()?.parse().ok()
}
fn env_u64(name: &str) -> Option<u64> {
env::var(name).ok()?.parse().ok()
}
fn env_u8(name: &str) -> Option<u8> {
env::var(name).ok()?.parse().ok()
}
fn first_env(names: &[&str]) -> Option<String> {
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
}