Add monitor-only mode: suppress all actions without stopping EMS

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 11:27:53 +02:00
parent 0b51ce5240
commit 00f8f3cdde
5 changed files with 146 additions and 9 deletions

43
main.go
View File

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