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:
43
main.go
43
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.
|
||||
|
||||
Reference in New Issue
Block a user