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

View File

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

61
internal/monitor/mode.go Normal file
View 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
}

View File

@@ -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}}
</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>
{{end}}
{{if .ErrMsg}}
@@ -729,5 +762,13 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
</div>
{{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>
</html>`