package config import ( "fmt" "net" "os" "time" "gopkg.in/yaml.v3" ) // Config is the top-level EMS configuration. type Config struct { Prometheus PrometheusConfig `yaml:"prometheus"` Shelly ShellyConfig `yaml:"shelly"` Viessmann ViessmannConfig `yaml:"viessmann"` SOC SOCThresholds `yaml:"soc_thresholds"` Hysteresis HysteresisConfig `yaml:"hysteresis"` Thresholds PowerThresholds `yaml:"thresholds"` Consumers ConsumersConfig `yaml:"consumers"` Strategic StrategicConfig `yaml:"strategic"` Season SeasonConfig `yaml:"season"` Forecast ForecastConfig `yaml:"forecast"` Battery BatteryConfig `yaml:"battery"` CarCharging CarChargingConfig `yaml:"car_charging"` Cars map[string]CarProfile `yaml:"cars"` EMS EMSConfig `yaml:"ems"` } // BatteryConfig holds physical battery properties. type BatteryConfig struct { CapacityKWh float64 `yaml:"capacity_kwh"` // usable battery capacity in kWh } // CarChargingConfig holds parameters for the proactive car charging strategy. type CarChargingConfig struct { MinSOC int `yaml:"min_soc"` // minimum SOC% to start car charging (e.g. 25) SOCFloor int `yaml:"soc_floor"` // never drain battery below this % (e.g. 5) SOCHighBypassPct int `yaml:"soc_high_bypass_pct"` // activate without forecast check when SOC ≥ this % (0 → default 90%) EODSOCTarget int `yaml:"eod_soc_target"` // target SOC% to reach by sunset (e.g. 90) EODTime string `yaml:"eod_time"` // soft-stop check starts at this time (e.g. "16:00") NoCarRetryMin int `yaml:"no_car_retry_min"` // minutes before retrying after no-car detection PVThresholdAW float64 `yaml:"pv_threshold_a_w"` // min PV production to start WallboxA (W) PVThresholdBW float64 `yaml:"pv_threshold_b_w"` // min PV production to start WallboxB (W) GridDeltaThreshW float64 `yaml:"grid_delta_thresh_w"` // min grid power shift after WallboxB activation = car detected (W) } func (c *CarChargingConfig) EODTimeParsed(ref time.Time) time.Time { var h, m int fmt.Sscanf(c.EODTime, "%d:%d", &h, &m) return time.Date(ref.Year(), ref.Month(), ref.Day(), h, m, 0, 0, ref.Location()) } // CarProfile holds the display name and battery capacity of a known vehicle. type CarProfile struct { Name string `yaml:"name"` BatteryKWh float64 `yaml:"battery_kwh"` } // PrometheusConfig holds Prometheus connection settings. type PrometheusConfig struct { URL string `yaml:"url"` Metrics map[string]string `yaml:"metrics"` } // ShellyConfig holds all Shelly actuator addresses. type ShellyConfig struct { SGReady ShellyDevice `yaml:"sg_ready"` WallboxA ShellyDevice `yaml:"wallbox_a"` WallboxB ShellyDevice `yaml:"wallbox_b"` } // ShellyDevice represents a single Shelly device. type ShellyDevice struct { IP string `yaml:"ip"` Gen int `yaml:"gen"` PowerW int `yaml:"power_w"` Password string `yaml:"password"` // optional; Gen2 uses HTTP Digest auth } // ViessmannConfig holds Viessmann API credentials (write access for WW temp). type ViessmannConfig struct { TokenFile string `yaml:"token_file"` ClientID string `yaml:"client_id"` InstallationID string `yaml:"installation_id"` GatewaySerial string `yaml:"gateway_serial"` DeviceID string `yaml:"device_id"` } // SOCThresholds defines the battery SOC levels that gate consumers. type SOCThresholds struct { BlockAll float64 `yaml:"block_all"` SGReadyOnly float64 `yaml:"sg_ready_only"` PlusWallboxA float64 `yaml:"plus_wallbox_a"` AllConsumers float64 `yaml:"all_consumers"` } // HysteresisConfig defines timing parameters for switching decisions. type HysteresisConfig struct { ExportOnDuration string `yaml:"export_on_duration"` ImportOffDuration string `yaml:"import_off_duration"` MinRuntimeWallbox string `yaml:"min_runtime_wallbox"` MinRuntimeSGReady string `yaml:"min_runtime_sg_ready"` } func (h *HysteresisConfig) ExportOnDurationParsed() time.Duration { d, _ := time.ParseDuration(h.ExportOnDuration) return d } func (h *HysteresisConfig) ImportOffDurationParsed() time.Duration { d, _ := time.ParseDuration(h.ImportOffDuration) return d } func (h *HysteresisConfig) MinRuntimeWallboxParsed() time.Duration { d, _ := time.ParseDuration(h.MinRuntimeWallbox) return d } func (h *HysteresisConfig) MinRuntimeSGReadyParsed() time.Duration { d, _ := time.ParseDuration(h.MinRuntimeSGReady) return d } // PowerThresholds defines the grid power levels that trigger switching. // Export thresholds are negative (grid exports = negative grid power). type PowerThresholds struct { SGReadyExportW float64 `yaml:"sg_ready_export_w"` WWExportW float64 `yaml:"ww_export_w"` WallboxAExportW float64 `yaml:"wallbox_a_export_w"` WallboxAPhaseExportW float64 `yaml:"wallbox_a_phase_export_w"` // per-phase export for single-phase WallboxA WallboxBExportW float64 `yaml:"wallbox_b_export_w"` ImportOffW float64 `yaml:"import_off_w"` } // ConsumersConfig holds per-consumer behavior thresholds. type ConsumersConfig struct { CompressorIdleW int `yaml:"compressor_idle_w"` // below this = heat pump compressor idle (W) WallboxMinChargeW int `yaml:"wallbox_min_charge_w"` // below this = car not charging (W) IdleCycles int `yaml:"idle_cycles"` // consecutive idle cycles before early release SGReadyStartupMin int `yaml:"sg_ready_startup_min"` // grace period after SG-Ready activation before idle detection starts (min, 0 = disabled) WallboxAAcceptedImportW float64 `yaml:"wallbox_a_accepted_import_w"` // tolerate this much grid import while WallboxA is running (0 = disabled) WallboxBAcceptedImportW float64 `yaml:"wallbox_b_accepted_import_w"` // tolerate this much grid import while WallboxB is running (0 = disabled) } // StrategicConfig holds PV forecast based strategic settings. type StrategicConfig struct { ForecastHighKWh float64 `yaml:"forecast_high_kwh"` ForecastMidKWh float64 `yaml:"forecast_mid_kwh"` WWBoostHighC float64 `yaml:"ww_boost_high_c"` WWBoostMidC float64 `yaml:"ww_boost_mid_c"` WWBaseC float64 `yaml:"ww_base_c"` // normal WW setpoint (°C) WWMaxSetpointC float64 `yaml:"ww_max_setpoint_c"` // absolute maximum WW setpoint (°C), e.g. 60 WWHysteresisC float64 `yaml:"ww_hysteresis_c"` // Viessmann switchOn = setpoint - hysteresis (°C), e.g. 5 WWWindowStart string `yaml:"ww_window_start"` // e.g. "12:30" WWWindowEnd string `yaml:"ww_window_end"` // e.g. "18:00" ScheduleOn string `yaml:"schedule_on"` ScheduleOff string `yaml:"schedule_off"` } // SeasonConfig defines the heating season by month range and ambient temperature. type SeasonConfig struct { HeatingStartMonth int `yaml:"heating_start_month"` HeatingEndMonth int `yaml:"heating_end_month"` HeatingMinAmbientC float64 `yaml:"heating_min_ambient_c"` // above this: non-heating regardless of month (0 = disabled) } // ForecastConfig holds forecast.solar API parameters. type ForecastConfig struct { Enabled bool `yaml:"enabled"` Lat float64 `yaml:"lat"` Lon float64 `yaml:"lon"` Declination int `yaml:"declination"` // panel tilt in degrees Azimuth int `yaml:"azimuth"` // degrees from south (south=0, west=90) KWp float64 `yaml:"kwp"` // installed peak power FetchInterval string `yaml:"fetch_interval"` // how often to re-fetch during the day FetchWindowStart string `yaml:"fetch_window_start"` // e.g. "07:00" FetchWindowEnd string `yaml:"fetch_window_end"` // e.g. "19:00" BaseLoadW float64 `yaml:"base_load_w"` // typical house consumption for surplus estimate (W) MinSurplusW float64 `yaml:"min_surplus_w"` // min export needed to trigger wallbox charging (W) } func (f *ForecastConfig) FetchIntervalParsed() time.Duration { d, err := time.ParseDuration(f.FetchInterval) if err != nil || d <= 0 { return 24 * time.Hour // safe default: once per day } return d } // EMSConfig holds operational settings for the EMS daemon. type EMSConfig struct { PollInterval string `yaml:"poll_interval"` ListenAddr string `yaml:"listen_addr"` // web UI address (authenticated) MetricsAddr string `yaml:"metrics_addr"` // Prometheus /metrics address (no auth, internal only) LogLevel string `yaml:"log_level"` StateFile string `yaml:"state_file"` RecoveryTimeout string `yaml:"recovery_timeout"` OverrideTimeout string `yaml:"override_timeout"` OverrideMaxImportW float64 `yaml:"override_max_import_w"` // cancel override if grid import exceeds this (0 = disabled) MonitorOnlyFile string `yaml:"monitor_only_file"` // flag file path: presence = monitor-only mode active WWBoostDisableFile string `yaml:"ww_boost_disable_file"` // flag file path: presence = WW boost disabled TripGoalFile string `yaml:"trip_goal_file"` // persisted active trip goal SessionLogFile string `yaml:"session_log_file"` // JSONL log of completed charge sessions HTTPUsername string `yaml:"http_username"` // login username (empty = no auth) HTTPPassword string `yaml:"http_password"` // login password } func (e *EMSConfig) PollIntervalParsed() time.Duration { d, _ := time.ParseDuration(e.PollInterval) return d } func (e *EMSConfig) RecoveryTimeoutParsed() time.Duration { d, err := time.ParseDuration(e.RecoveryTimeout) if err != nil { return time.Hour // safe default } return d } func (e *EMSConfig) OverrideTimeoutParsed() time.Duration { d, err := time.ParseDuration(e.OverrideTimeout) if err != nil { return time.Hour // safe default } return d } // Load reads and parses the YAML config file at the given path. func Load(path string) (*Config, error) { // Warn if config file is readable by group or others (contains credentials). if info, err := os.Stat(path); err == nil { if info.Mode().Perm()&0o077 != 0 { fmt.Fprintf(os.Stderr, "WARNING: config file %s has permissions %o — should be 0600 (contains credentials)\n", path, info.Mode().Perm()) } } data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading config file: %w", err) } var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parsing config file: %w", err) } if err := cfg.validate(); err != nil { return nil, fmt.Errorf("invalid config: %w", err) } return &cfg, nil } func (c *Config) validate() error { if c.Prometheus.URL == "" { return fmt.Errorf("prometheus.url is required") } if c.EMS.PollInterval == "" { return fmt.Errorf("ems.poll_interval is required") } if _, err := time.ParseDuration(c.EMS.PollInterval); err != nil { return fmt.Errorf("ems.poll_interval %q: %w", c.EMS.PollInterval, err) } if c.EMS.ListenAddr == "" { return fmt.Errorf("ems.listen_addr is required") } // Validate Shelly IPs to prevent SSRF via config manipulation. for name, dev := range map[string]ShellyDevice{ "sg_ready": c.Shelly.SGReady, "wallbox_a": c.Shelly.WallboxA, "wallbox_b": c.Shelly.WallboxB, } { if dev.IP != "" && net.ParseIP(dev.IP) == nil { return fmt.Errorf("shelly.%s.ip %q is not a valid IP address", name, dev.IP) } } if c.EMS.HTTPUsername != "" && c.EMS.HTTPPassword == "" { return fmt.Errorf("ems.http_username is set but ems.http_password is empty") } if c.EMS.HTTPPassword != "" && c.EMS.HTTPUsername == "" { return fmt.Errorf("ems.http_password is set but ems.http_username is empty") } return nil }