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>
232 lines
8.5 KiB
Go
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
|
|
}
|