Add WW boost enable/disable toggle (persistent flag file)

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 16:09:33 +02:00
parent 1b57293da9
commit afbb7bf62c
4 changed files with 64 additions and 16 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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; }
<button type="submit" class="override-btn turn-off" title="WW-Solltemperatur auf Basiswert zurücksetzen, Boost für heute sperren">🌡️ Zurücksetzen</button>
</form>
{{end}}
{{if .IsWW}}
<form method="post" action="/ww/boost" style="flex-shrink:0">
{{if .WWBoostDisabled}}
<button type="submit" class="override-btn turn-on" title="WW Boost durch PV-Überschuss wieder erlauben">✅ Boost ein</button>
{{else}}
<button type="submit" class="override-btn turn-off" title="WW Boost dauerhaft deaktivieren (z.B. im Sommer)">⛔ Boost aus</button>
{{end}}
</form>
{{end}}
</div>
{{end}}

40
main.go
View File

@@ -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 {