From afbb7bf62c131abef786de29c35f259556b593c3 Mon Sep 17 00:00:00 2001 From: Lutz Finsterle Date: Mon, 6 Apr 2026 16:09:33 +0200 Subject: [PATCH] Add WW boost enable/disable toggle (persistent flag file) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In summer the WW boost interferes with car charging priority. A new ⛔/✅ toggle on the WW consumer card persists to /etc/ems/ww-boost-off (presence = disabled, absence = enabled). - config: ww_boost_disable_file flag file path - main: wwBoostDisabled mode, wwBoostToggleHandler, runCycle skips computeWWBoost when disabled - status: WWBoostDisabled in pageData, toggle button in WW card showing ⛔ Boost aus / ✅ Boost ein depending on state Co-Authored-By: Claude Sonnet 4.6 --- configs/ems-config.yaml | 3 ++- internal/config/config.go | 1 + internal/status/status.go | 36 ++++++++++++++++++++++++----------- main.go | 40 +++++++++++++++++++++++++++++++++++---- 4 files changed, 64 insertions(+), 16 deletions(-) diff --git a/configs/ems-config.yaml b/configs/ems-config.yaml index fb67155..524e461 100644 --- a/configs/ems-config.yaml +++ b/configs/ems-config.yaml @@ -111,7 +111,8 @@ ems: recovery_timeout: "1h" # ignore Shelly state if EMS was down longer override_timeout: "1h" # default lockout when EMS detects external Shelly change override_max_import_w: 800 # cancel override immediately if importing more than this (0 = disabled) - monitor_only_file: "/etc/ems/monitor-only" # presence of this file = monitor-only mode active + monitor_only_file: "/etc/ems/monitor-only" # presence of this file = monitor-only mode active + ww_boost_disable_file: "/etc/ems/ww-boost-off" # presence of this file = WW boost disabled trip_goal_file: "/var/lib/ems/trip-goal.json" # persisted active trip goal session_log_file: "/var/lib/ems/sessions.jsonl" # JSONL log of completed charge sessions diff --git a/internal/config/config.go b/internal/config/config.go index ad04bcc..ff8f0f6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -167,6 +167,7 @@ type EMSConfig struct { 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 } diff --git a/internal/status/status.go b/internal/status/status.go index 7cea616..9943e54 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -59,25 +59,27 @@ type Store struct { strategic config.StrategicConfig consumers map[engine.Consumer]*consumerRecord fcResult *forecast.Result - monitorMode *monitor.Mode - tripMgr *trip.Manager - carOptions []CarOption + monitorMode *monitor.Mode + wwBoostDisabled *monitor.Mode + tripMgr *trip.Manager + carOptions []CarOption } // NewStore creates a new status store. -func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, mode *monitor.Mode, tm *trip.Manager, cars []CarOption) *Store { +func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, mode *monitor.Mode, wwBoostDisabled *monitor.Mode, tm *trip.Manager, cars []CarOption) *Store { consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder)) for _, c := range consumerOrder { consumers[c] = &consumerRecord{} } return &Store{ - dryRun: dryRun, - wwConfigured: wwConfigured, - strategic: strategic, - consumers: consumers, - monitorMode: mode, - tripMgr: tm, - carOptions: cars, + dryRun: dryRun, + wwConfigured: wwConfigured, + strategic: strategic, + consumers: consumers, + monitorMode: mode, + wwBoostDisabled: wwBoostDisabled, + tripMgr: tm, + carOptions: cars, } } @@ -179,6 +181,7 @@ type pageData struct { ErrMsg string DryRun bool MonitorOnly bool + WWBoostDisabled bool BatterySOC float64 GridPowerW float64 PVProductionW float64 @@ -261,6 +264,7 @@ func (s *Store) Handler() http.HandlerFunc { } monitorOnly := s.monitorMode != nil && s.monitorMode.IsActive() + wwBoostDisabled := s.wwBoostDisabled != nil && s.wwBoostDisabled.IsActive() now := time.Now() s.mu.RLock() @@ -271,6 +275,7 @@ func (s *Store) Handler() http.HandlerFunc { ErrMsg: s.errMsg, DryRun: s.dryRun, MonitorOnly: monitorOnly, + WWBoostDisabled: wwBoostDisabled, BatterySOC: s.state.BatterySOC, GridPowerW: s.state.GridPowerW, PVProductionW: s.state.PVProductionW, @@ -902,6 +907,15 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; } {{end}} + {{if .IsWW}} +
+ {{if .WWBoostDisabled}} + + {{else}} + + {{end}} +
+ {{end}} {{end}} diff --git a/main.go b/main.go index 3476748..718824f 100644 --- a/main.go +++ b/main.go @@ -115,6 +115,12 @@ func main() { ) } + // WW boost disable flag (persistent; presence of file = boost disabled) + wwBoostDisabled := monitor.New(cfg.EMS.WWBoostDisableFile) + if wwBoostDisabled.IsActive() { + logger.Info("WW boost disabled via flag file", "flag_file", cfg.EMS.WWBoostDisableFile) + } + // Trip mode manager (goal persistence + session learning) tripMgr := trip.New(cfg.EMS.TripGoalFile, cfg.EMS.SessionLogFile) if g := tripMgr.ActiveGoal(); g != nil { @@ -130,7 +136,7 @@ func main() { // Status store (shared between HTTP handler and control loop) wwConfigured := cfg.Viessmann.InstallationID != "" - statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, monitorMode, tripMgr, carOptions) + statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, monitorMode, wwBoostDisabled, tripMgr, carOptions) // Metrics HTTP server mux := http.NewServeMux() @@ -144,6 +150,7 @@ func main() { mux.HandleFunc("/trip", tripSetHandler(tripMgr, cfg, logger)) mux.HandleFunc("/trip/cancel", tripCancelHandler(tripMgr, logger)) mux.HandleFunc("/ww/reset", wwResetHandler(act, eng, cfg, logger)) + mux.HandleFunc("/ww/boost", wwBoostToggleHandler(wwBoostDisabled, logger)) mux.HandleFunc("/", statusStore.Handler()) srv := &http.Server{ @@ -172,12 +179,12 @@ func main() { logger.Info("EMS control loop started") // Run once immediately - runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode, tripMgr) + runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode, wwBoostDisabled, tripMgr) for { select { case <-ticker.C: - runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode, tripMgr) + runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode, wwBoostDisabled, tripMgr) case sig := <-sigCh: logger.Info("received signal, shutting down", "signal", sig) @@ -204,6 +211,7 @@ func runCycle( logger *slog.Logger, dryRun bool, monitorMode *monitor.Mode, + wwBoostDisabled *monitor.Mode, tripMgr *trip.Manager, ) { now := time.Now() @@ -251,7 +259,10 @@ func runCycle( } // Step 3: Run decision engine - wwBoostC := computeWWBoost(fcResult, cfg) + wwBoostC := 0.0 + if !wwBoostDisabled.IsActive() { + wwBoostC = computeWWBoost(fcResult, cfg) + } decisionStart := time.Now() actions := eng.Decide(state, now, wwBoostC) m.DecisionDuration.Observe(time.Since(decisionStart).Seconds()) @@ -454,6 +465,27 @@ func tripCancelHandler(tm *trip.Manager, logger *slog.Logger) http.HandlerFunc { } } +// wwBoostToggleHandler toggles the WW boost disable flag on POST. +func wwBoostToggleHandler(disabled *monitor.Mode, logger *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + newState := !disabled.IsActive() + if err := disabled.Set(newState); err != nil { + logger.Error("WW boost toggle failed", "error", err) + } else { + if newState { + logger.Info("WW boost disabled via web UI") + } else { + logger.Info("WW boost enabled via web UI") + } + } + http.Redirect(w, r, "/", http.StatusSeeOther) + } +} + // wwResetHandler resets the WW temperature to the configured base setpoint and // blocks further boosts for the rest of the day via an engine override. func wwResetHandler(act *actuator.Actuator, eng *engine.Engine, cfg *config.Config, logger *slog.Logger) http.HandlerFunc {