From 00f8f3cdde59c9bd1a2042c5c72684f4481c98af Mon Sep 17 00:00:00 2001 From: Lutz Finsterle Date: Mon, 6 Apr 2026 11:27:53 +0200 Subject: [PATCH] Add monitor-only mode: suppress all actions without stopping EMS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New internal/monitor package provides a thread-safe, file-backed toggle. Flag file presence (/etc/ems/monitor-only) = monitor-only active, survives reboots. Deletion = resume normal operation. Behaviour when active: - EMS continues polling Prometheus, running the decision engine, and updating the status page every 2 minutes — full visibility maintained - All actuator calls suppressed (Shelly switches + Viessmann WW writes) - Suppressed actions logged as [MONITOR-ONLY] for audit trail - Startup warns if flag file is already present Web UI: - Amber banner "⏸ Monitor-Only — Keine Schaltvorgänge" with inline "▶ Automatik" resume button when active - Small unobtrusive "⏸ Monitor-Only" button at page bottom when inactive - POST /monitor endpoint toggles state and redirects back to status page Co-Authored-By: Claude Sonnet 4.6 --- configs/ems-config.yaml | 1 + internal/config/config.go | 1 + internal/monitor/mode.go | 61 +++++++++++++++++++++++++++++++++++++++ internal/status/status.go | 49 ++++++++++++++++++++++++++++--- main.go | 43 +++++++++++++++++++++++---- 5 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 internal/monitor/mode.go diff --git a/configs/ems-config.yaml b/configs/ems-config.yaml index aafddab..4a91cef 100644 --- a/configs/ems-config.yaml +++ b/configs/ems-config.yaml @@ -111,3 +111,4 @@ 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 diff --git a/internal/config/config.go b/internal/config/config.go index bd41840..d8bd49b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -159,6 +159,7 @@ type EMSConfig struct { 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 } func (e *EMSConfig) PollIntervalParsed() time.Duration { diff --git a/internal/monitor/mode.go b/internal/monitor/mode.go new file mode 100644 index 0000000..87bf470 --- /dev/null +++ b/internal/monitor/mode.go @@ -0,0 +1,61 @@ +// Package monitor provides a persistent monitor-only mode toggle. +// When active, the EMS continues polling and showing the status page +// but suppresses all actuator calls (Shelly switches, Viessmann API). +// State is stored as a flag file: presence = active, absence = normal. +package monitor + +import ( + "os" + "sync" +) + +// Mode is a thread-safe, file-backed monitor-only flag. +type Mode struct { + mu sync.RWMutex + active bool + filePath string +} + +// New creates a Mode and loads the current state from the flag file. +// If filePath is empty, the mode is in-memory only (not persistent). +func New(filePath string) *Mode { + m := &Mode{filePath: filePath} + if filePath != "" { + _, err := os.Stat(filePath) + m.active = err == nil // file exists → monitor-only + } + return m +} + +// IsActive reports whether monitor-only mode is currently enabled. +func (m *Mode) IsActive() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.active +} + +// Set enables or disables monitor-only mode and persists the change to disk. +func (m *Mode) Set(active bool) error { + m.mu.Lock() + defer m.mu.Unlock() + + m.active = active + + if m.filePath == "" { + return nil // in-memory only + } + + if active { + f, err := os.Create(m.filePath) + if err != nil { + return err + } + return f.Close() + } + + err := os.Remove(m.filePath) + if os.IsNotExist(err) { + return nil + } + return err +} diff --git a/internal/status/status.go b/internal/status/status.go index 5a03f24..c4f13e7 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -11,6 +11,7 @@ import ( "github.com/tb/ems/internal/config" "github.com/tb/ems/internal/engine" "github.com/tb/ems/internal/forecast" + "github.com/tb/ems/internal/monitor" ) // consumerMeta holds static display info for each consumer. @@ -50,10 +51,11 @@ type Store struct { strategic config.StrategicConfig consumers map[engine.Consumer]*consumerRecord fcResult *forecast.Result + monitorMode *monitor.Mode } // NewStore creates a new status store. -func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig) *Store { +func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, mode *monitor.Mode) *Store { consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder)) for _, c := range consumerOrder { consumers[c] = &consumerRecord{} @@ -63,6 +65,7 @@ func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig) wwConfigured: wwConfigured, strategic: strategic, consumers: consumers, + monitorMode: mode, } } @@ -148,6 +151,7 @@ type pageData struct { LastUpdate time.Time ErrMsg string DryRun bool + MonitorOnly bool BatterySOC float64 GridPowerW float64 PVProductionW float64 @@ -227,6 +231,8 @@ func (s *Store) Handler() http.HandlerFunc { return } + monitorOnly := s.monitorMode != nil && s.monitorMode.IsActive() + s.mu.RLock() l1, l2, l3 := s.state.PhaseL1PowerW, s.state.PhaseL2PowerW, s.state.PhaseL3PowerW hasPhase := l1 != 0 || l2 != 0 || l3 != 0 @@ -234,6 +240,7 @@ func (s *Store) Handler() http.HandlerFunc { LastUpdate: s.lastUpdate, ErrMsg: s.errMsg, DryRun: s.dryRun, + MonitorOnly: monitorOnly, BatterySOC: s.state.BatterySOC, GridPowerW: s.state.GridPowerW, PVProductionW: s.state.PVProductionW, @@ -366,8 +373,27 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; } font-size: 0.85rem; margin-bottom: 1rem; } -.banner.warn { background: #fef9c3; color: #854d0e; border: 1px solid #fde68a; } -.banner.error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; } +.banner.warn { background: #fef9c3; color: #854d0e; border: 1px solid #fde68a; } +.banner.error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; } +.banner.monitor { + background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; + display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; +} +.resume-btn { + background: #fff; border: 1px solid #9a3412; color: #9a3412; + border-radius: 7px; padding: 0.25rem 0.7rem; font-size: 0.8rem; + font-weight: 700; cursor: pointer; white-space: nowrap; flex-shrink: 0; +} +.resume-btn:active { opacity: 0.7; } +.monitor-toggle { + text-align: center; margin-top: 1.5rem; padding-bottom: 0.5rem; +} +.monitor-toggle-btn { + background: none; border: 1px solid #d1d5db; color: #9ca3af; + border-radius: 8px; padding: 0.35rem 1rem; font-size: 0.75rem; + cursor: pointer; +} +.monitor-toggle-btn:hover { border-color: #9ca3af; color: #6b7280; } .grid { display: grid; @@ -584,7 +610,14 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; } {{end}} -{{if .DryRun}} +{{if .MonitorOnly}} + +{{else if .DryRun}} {{end}} {{if .ErrMsg}} @@ -729,5 +762,13 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; } {{end}} +{{if not .MonitorOnly}} +
+
+ +
+
+{{end}} + ` diff --git a/main.go b/main.go index df498f4..06a6fe3 100644 --- a/main.go +++ b/main.go @@ -20,6 +20,7 @@ import ( "github.com/tb/ems/internal/engine" "github.com/tb/ems/internal/forecast" "github.com/tb/ems/internal/metrics" + "github.com/tb/ems/internal/monitor" "github.com/tb/ems/internal/status" "github.com/tb/ems/internal/viessmann" ) @@ -103,9 +104,17 @@ func main() { reg := prometheus.NewRegistry() m := metrics.NewMetrics(reg) + // Monitor-only mode (persistent flag file) + monitorMode := monitor.New(cfg.EMS.MonitorOnlyFile) + if monitorMode.IsActive() { + logger.Warn("starting in monitor-only mode — actuator calls suppressed", + "flag_file", cfg.EMS.MonitorOnlyFile, + ) + } + // Status store (shared between HTTP handler and control loop) wwConfigured := cfg.Viessmann.InstallationID != "" - statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic) + statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, monitorMode) // Metrics HTTP server mux := http.NewServeMux() @@ -115,6 +124,7 @@ func main() { w.Write([]byte("ok")) }) mux.HandleFunc("/override", overrideHandler(act, eng, logger)) + mux.HandleFunc("/monitor", monitorHandler(monitorMode, logger)) mux.HandleFunc("/", statusStore.Handler()) srv := &http.Server{ @@ -143,12 +153,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) + runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode) for { select { case <-ticker.C: - runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun) + runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode) case sig := <-sigCh: logger.Info("received signal, shutting down", "signal", sig) @@ -174,6 +184,7 @@ func runCycle( stateFile string, logger *slog.Logger, dryRun bool, + monitorMode *monitor.Mode, ) { now := time.Now() @@ -240,9 +251,13 @@ func runCycle( // Step 4: Execute actions m.RecordActions(actions) - if dryRun { + if dryRun || monitorMode.IsActive() { + mode := "DRY RUN" + if monitorMode.IsActive() { + mode = "MONITOR-ONLY" + } for _, a := range actions { - logger.Info("[DRY RUN] would execute", + logger.Info(fmt.Sprintf("[%s] would execute", mode), "consumer", a.Consumer, "turn_on", a.TurnOn, "reason", a.Reason, @@ -256,6 +271,24 @@ func runCycle( } } +// monitorHandler toggles monitor-only mode on POST and redirects to the status page. +func monitorHandler(mode *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 := !mode.IsActive() + if err := mode.Set(newState); err != nil { + logger.Error("failed to set monitor-only mode", "active", newState, "error", err) + http.Error(w, "could not update monitor mode — check logs", http.StatusInternalServerError) + return + } + logger.Info("monitor-only mode changed", "active", newState) + http.Redirect(w, r, "/", http.StatusSeeOther) + } +} + // shouldRecover checks the heartbeat file to decide whether to read back // Shelly states on startup. Returns false if the file is missing (first run) // or older than the recovery timeout.