package config import ( "fmt" "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"` EMS EMSConfig `yaml:"ems"` } // 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 } // 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) 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"` 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 } 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) { 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") } return nil }