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:
@@ -111,3 +111,4 @@ ems:
|
|||||||
recovery_timeout: "1h" # ignore Shelly state if EMS was down longer
|
recovery_timeout: "1h" # ignore Shelly state if EMS was down longer
|
||||||
override_timeout: "1h" # default lockout when EMS detects external Shelly change
|
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)
|
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
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ type EMSConfig struct {
|
|||||||
RecoveryTimeout string `yaml:"recovery_timeout"`
|
RecoveryTimeout string `yaml:"recovery_timeout"`
|
||||||
OverrideTimeout string `yaml:"override_timeout"`
|
OverrideTimeout string `yaml:"override_timeout"`
|
||||||
OverrideMaxImportW float64 `yaml:"override_max_import_w"` // cancel override if grid import exceeds this (0 = disabled)
|
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 {
|
func (e *EMSConfig) PollIntervalParsed() time.Duration {
|
||||||
|
|||||||
61
internal/monitor/mode.go
Normal file
61
internal/monitor/mode.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/tb/ems/internal/config"
|
"github.com/tb/ems/internal/config"
|
||||||
"github.com/tb/ems/internal/engine"
|
"github.com/tb/ems/internal/engine"
|
||||||
"github.com/tb/ems/internal/forecast"
|
"github.com/tb/ems/internal/forecast"
|
||||||
|
"github.com/tb/ems/internal/monitor"
|
||||||
)
|
)
|
||||||
|
|
||||||
// consumerMeta holds static display info for each consumer.
|
// consumerMeta holds static display info for each consumer.
|
||||||
@@ -50,10 +51,11 @@ type Store struct {
|
|||||||
strategic config.StrategicConfig
|
strategic config.StrategicConfig
|
||||||
consumers map[engine.Consumer]*consumerRecord
|
consumers map[engine.Consumer]*consumerRecord
|
||||||
fcResult *forecast.Result
|
fcResult *forecast.Result
|
||||||
|
monitorMode *monitor.Mode
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStore creates a new status store.
|
// 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))
|
consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder))
|
||||||
for _, c := range consumerOrder {
|
for _, c := range consumerOrder {
|
||||||
consumers[c] = &consumerRecord{}
|
consumers[c] = &consumerRecord{}
|
||||||
@@ -63,6 +65,7 @@ func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig)
|
|||||||
wwConfigured: wwConfigured,
|
wwConfigured: wwConfigured,
|
||||||
strategic: strategic,
|
strategic: strategic,
|
||||||
consumers: consumers,
|
consumers: consumers,
|
||||||
|
monitorMode: mode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +151,7 @@ type pageData struct {
|
|||||||
LastUpdate time.Time
|
LastUpdate time.Time
|
||||||
ErrMsg string
|
ErrMsg string
|
||||||
DryRun bool
|
DryRun bool
|
||||||
|
MonitorOnly bool
|
||||||
BatterySOC float64
|
BatterySOC float64
|
||||||
GridPowerW float64
|
GridPowerW float64
|
||||||
PVProductionW float64
|
PVProductionW float64
|
||||||
@@ -227,6 +231,8 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
monitorOnly := s.monitorMode != nil && s.monitorMode.IsActive()
|
||||||
|
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
l1, l2, l3 := s.state.PhaseL1PowerW, s.state.PhaseL2PowerW, s.state.PhaseL3PowerW
|
l1, l2, l3 := s.state.PhaseL1PowerW, s.state.PhaseL2PowerW, s.state.PhaseL3PowerW
|
||||||
hasPhase := l1 != 0 || l2 != 0 || l3 != 0
|
hasPhase := l1 != 0 || l2 != 0 || l3 != 0
|
||||||
@@ -234,6 +240,7 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
LastUpdate: s.lastUpdate,
|
LastUpdate: s.lastUpdate,
|
||||||
ErrMsg: s.errMsg,
|
ErrMsg: s.errMsg,
|
||||||
DryRun: s.dryRun,
|
DryRun: s.dryRun,
|
||||||
|
MonitorOnly: monitorOnly,
|
||||||
BatterySOC: s.state.BatterySOC,
|
BatterySOC: s.state.BatterySOC,
|
||||||
GridPowerW: s.state.GridPowerW,
|
GridPowerW: s.state.GridPowerW,
|
||||||
PVProductionW: s.state.PVProductionW,
|
PVProductionW: s.state.PVProductionW,
|
||||||
@@ -366,8 +373,27 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
.banner.warn { background: #fef9c3; color: #854d0e; border: 1px solid #fde68a; }
|
.banner.warn { background: #fef9c3; color: #854d0e; border: 1px solid #fde68a; }
|
||||||
.banner.error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
|
.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 {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -584,7 +610,14 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
{{end}}
|
{{end}}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{{if .DryRun}}
|
{{if .MonitorOnly}}
|
||||||
|
<div class="banner monitor">
|
||||||
|
<span>⏸ Monitor-Only — Keine Schaltvorgänge</span>
|
||||||
|
<form method="post" action="/monitor">
|
||||||
|
<button type="submit" class="resume-btn">▶ Automatik</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{else if .DryRun}}
|
||||||
<div class="banner warn">⚠️ Testmodus — keine echten Schaltvorgänge</div>
|
<div class="banner warn">⚠️ Testmodus — keine echten Schaltvorgänge</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .ErrMsg}}
|
{{if .ErrMsg}}
|
||||||
@@ -729,5 +762,13 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if not .MonitorOnly}}
|
||||||
|
<div class="monitor-toggle">
|
||||||
|
<form method="post" action="/monitor">
|
||||||
|
<button type="submit" class="monitor-toggle-btn">⏸ Monitor-Only</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|||||||
43
main.go
43
main.go
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/tb/ems/internal/engine"
|
"github.com/tb/ems/internal/engine"
|
||||||
"github.com/tb/ems/internal/forecast"
|
"github.com/tb/ems/internal/forecast"
|
||||||
"github.com/tb/ems/internal/metrics"
|
"github.com/tb/ems/internal/metrics"
|
||||||
|
"github.com/tb/ems/internal/monitor"
|
||||||
"github.com/tb/ems/internal/status"
|
"github.com/tb/ems/internal/status"
|
||||||
"github.com/tb/ems/internal/viessmann"
|
"github.com/tb/ems/internal/viessmann"
|
||||||
)
|
)
|
||||||
@@ -103,9 +104,17 @@ func main() {
|
|||||||
reg := prometheus.NewRegistry()
|
reg := prometheus.NewRegistry()
|
||||||
m := metrics.NewMetrics(reg)
|
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)
|
// Status store (shared between HTTP handler and control loop)
|
||||||
wwConfigured := cfg.Viessmann.InstallationID != ""
|
wwConfigured := cfg.Viessmann.InstallationID != ""
|
||||||
statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic)
|
statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, monitorMode)
|
||||||
|
|
||||||
// Metrics HTTP server
|
// Metrics HTTP server
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -115,6 +124,7 @@ func main() {
|
|||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/override", overrideHandler(act, eng, logger))
|
mux.HandleFunc("/override", overrideHandler(act, eng, logger))
|
||||||
|
mux.HandleFunc("/monitor", monitorHandler(monitorMode, logger))
|
||||||
mux.HandleFunc("/", statusStore.Handler())
|
mux.HandleFunc("/", statusStore.Handler())
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@@ -143,12 +153,12 @@ func main() {
|
|||||||
logger.Info("EMS control loop started")
|
logger.Info("EMS control loop started")
|
||||||
|
|
||||||
// Run once immediately
|
// 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 {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
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:
|
case sig := <-sigCh:
|
||||||
logger.Info("received signal, shutting down", "signal", sig)
|
logger.Info("received signal, shutting down", "signal", sig)
|
||||||
@@ -174,6 +184,7 @@ func runCycle(
|
|||||||
stateFile string,
|
stateFile string,
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
dryRun bool,
|
dryRun bool,
|
||||||
|
monitorMode *monitor.Mode,
|
||||||
) {
|
) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
@@ -240,9 +251,13 @@ func runCycle(
|
|||||||
// Step 4: Execute actions
|
// Step 4: Execute actions
|
||||||
m.RecordActions(actions)
|
m.RecordActions(actions)
|
||||||
|
|
||||||
if dryRun {
|
if dryRun || monitorMode.IsActive() {
|
||||||
|
mode := "DRY RUN"
|
||||||
|
if monitorMode.IsActive() {
|
||||||
|
mode = "MONITOR-ONLY"
|
||||||
|
}
|
||||||
for _, a := range actions {
|
for _, a := range actions {
|
||||||
logger.Info("[DRY RUN] would execute",
|
logger.Info(fmt.Sprintf("[%s] would execute", mode),
|
||||||
"consumer", a.Consumer,
|
"consumer", a.Consumer,
|
||||||
"turn_on", a.TurnOn,
|
"turn_on", a.TurnOn,
|
||||||
"reason", a.Reason,
|
"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
|
// shouldRecover checks the heartbeat file to decide whether to read back
|
||||||
// Shelly states on startup. Returns false if the file is missing (first run)
|
// Shelly states on startup. Returns false if the file is missing (first run)
|
||||||
// or older than the recovery timeout.
|
// or older than the recovery timeout.
|
||||||
|
|||||||
Reference in New Issue
Block a user