1. SOC high bypass: when SOC >= soc_high_bypass_pct (default 90%), activate car charging without requiring a good forecast. Solves today's missed charging window where 4kW was exported for 6h with a full battery because the intraday forecast dropped from 15.4 to 13.3 kWh. 2. Log ambient_c in every cycle: makes it diagnosable why isHeatingPeriod suppresses SG-Ready on warm days (heating_min_ambient_c: 15°C gate). 3. Consumer names in logs: replace slog integer Consumer values with .String() so logs show "wallbox_a" instead of "2". 4. Status page order: reorder consumers by EMS priority (WallboxA → WallboxB → WW → SG-Ready) instead of the old reversed order. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
260 lines
10 KiB
Go
260 lines
10 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"`
|
|
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
|
|
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"`
|
|
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
|
|
}
|