User enters car SOC + departure deadline; EMS computes when to start charging and activates the wallbox at the right time (grid import OK for trip mode). Charging rate is learned from completed PM sessions, improving over time. Car profiles (Mini Cooper SE 50 kWh, BMW ix2 63 kWh) are config-driven and selectable per trip. - internal/trip/trip.go: Goal/Session types, Manager with per-cycle Tick() that accumulates energy, detects session completion, learns avg charge rate from JSONL session log - internal/config: CarProfile + Cars map, TripGoalFile, SessionLogFile - internal/status: trip card (active goal) + trip form (set new goal), CarOption list, formatDay/formatDur helpers - main.go: tripMgr lifecycle, /trip + /trip/cancel HTTP handlers, runCycle integration (ShouldStartNow → ApplyOverride, auto-clear) - configs/ems-config.yaml: cars section, trip_goal_file, session_log_file Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
229 lines
8.0 KiB
Go
229 lines
8.0 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
|
|
}
|
|
|
|
// 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
|
|
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
|
|
}
|