Files
EMS/internal/config/config.go
Lutz Finsterle fc535d395c Add per-consumer accepted import tolerance for wallboxes
With a 4.6kW inverter and 4kW WallboxB + ~450W house load, any PV dip
causes brief grid import and would trigger shutdown/restart cycling.

New config: wallbox_a_accepted_import_w / wallbox_b_accepted_import_w
WallboxB set to 600W: engine skips shutdown if import <= 600W, keeping
the car charging through short cloud shadows. WallboxA disabled (2kW
leaves sufficient inverter headroom without needing tolerance).

Logic: shutdownLastConsumer checks per-consumer tolerance before
applying min-runtime and initiating turn-off.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:34:18 +02:00

232 lines
8.5 KiB
Go

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"`
Cars map[string]CarProfile `yaml:"cars"`
EMS EMSConfig `yaml:"ems"`
}
// 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
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)
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
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
}
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
}