Add status page enhancements and engine improvements

Status page:
- Per-phase grid power (L1/L2/L3) with export/import direction
- Shelly PM live power badge on active wallbox rows
- Compressor power shown below consumer list
- SyncDeviceStates() method on Store for PM power readback

Engine:
- Fix wallbox mutex: re-read Active state after P3 so same-cycle
  WallboxA activation correctly blocks WallboxB in P4
- Fix RecoverState: set ActivatedAt to zero so recovered consumers
  are immediately eligible for shutdown decisions
- Add 3 new unit tests: mutual exclusion, car-not-charging,
  compressor-idle early release

Makefile:
- make install no longer overwrites /etc/ems/ems-config.yaml if it
  already exists (prevents password loss on updates)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 10:06:01 +02:00
parent 99613c52ae
commit 79828a46c5
3 changed files with 130 additions and 21 deletions

View File

@@ -17,9 +17,14 @@ run: build
install: build
install -Dm755 $(BINARY) /usr/local/bin/$(BINARY)
install -Dm644 $(CONFIG) /etc/ems/ems-config.yaml
install -Dm644 systemd/ems.service /etc/systemd/system/ems.service
systemctl daemon-reload
@if [ ! -f /etc/ems/ems-config.yaml ]; then \
install -Dm644 $(CONFIG) /etc/ems/ems-config.yaml; \
echo "Config installed to /etc/ems/ems-config.yaml (first time)"; \
else \
echo "Config already exists at /etc/ems/ems-config.yaml — not overwritten"; \
fi
@echo "Run: systemctl enable --now ems"
lint:

View File

@@ -36,6 +36,7 @@ type consumerRecord struct {
reason string
manualOverride bool
overrideUntil time.Time
livePowerW float64 // from Shelly PM; 0 for non-PM devices
}
// Store holds the latest EMS snapshot and is safe for concurrent use.
@@ -118,6 +119,14 @@ type consumerView struct {
CanOverride bool // true for Shelly consumers (hardware read-back available)
ManualOverride bool
OverrideUntil time.Time
LivePowerW float64 // from Shelly PM; 0 for non-PM devices
}
type phaseView struct {
Label string
PowerW float64
IsExport bool
AbsPowerW float64
}
type forecastView struct {
@@ -128,17 +137,20 @@ type forecastView struct {
}
type pageData struct {
LastUpdate time.Time
ErrMsg string
DryRun bool
BatterySOC float64
GridPowerW float64
PVProductionW float64
AmbientTempC float64
IsExporting bool
AbsGridW float64
Forecast forecastView
Consumers []consumerView
LastUpdate time.Time
ErrMsg string
DryRun bool
BatterySOC float64
GridPowerW float64
PVProductionW float64
AmbientTempC float64
CompressorPowerW float64
IsExporting bool
AbsGridW float64
HasPhaseData bool
Phases []phaseView
Forecast forecastView
Consumers []consumerView
}
// SyncConsumerStates updates active flags for all consumers directly from the
@@ -154,6 +166,18 @@ func (s *Store) SyncConsumerStates(states map[engine.Consumer]bool) {
}
}
// SyncDeviceStates stores live power readings from Shelly PM devices.
// Called each cycle after ReadAllStates.
func (s *Store) SyncDeviceStates(states map[engine.Consumer]engine.DeviceStatus) {
s.mu.Lock()
defer s.mu.Unlock()
for c, status := range states {
if rec, ok := s.consumers[c]; ok {
rec.livePowerW = status.PowerW
}
}
}
// Handler returns an HTTP handler that renders the status page.
func (s *Store) Handler() http.HandlerFunc {
tmpl := template.Must(template.New("status").Funcs(template.FuncMap{
@@ -195,16 +219,32 @@ func (s *Store) Handler() http.HandlerFunc {
}
s.mu.RLock()
l1, l2, l3 := s.state.PhaseL1PowerW, s.state.PhaseL2PowerW, s.state.PhaseL3PowerW
hasPhase := l1 != 0 || l2 != 0 || l3 != 0
data := pageData{
LastUpdate: s.lastUpdate,
ErrMsg: s.errMsg,
DryRun: s.dryRun,
BatterySOC: s.state.BatterySOC,
GridPowerW: s.state.GridPowerW,
PVProductionW: s.state.PVProductionW,
AmbientTempC: s.state.AmbientTempC,
IsExporting: s.state.GridPowerW < 0,
AbsGridW: abs(s.state.GridPowerW),
LastUpdate: s.lastUpdate,
ErrMsg: s.errMsg,
DryRun: s.dryRun,
BatterySOC: s.state.BatterySOC,
GridPowerW: s.state.GridPowerW,
PVProductionW: s.state.PVProductionW,
AmbientTempC: s.state.AmbientTempC,
CompressorPowerW: s.state.CompressorPowerW,
IsExporting: s.state.GridPowerW < 0,
AbsGridW: abs(s.state.GridPowerW),
HasPhaseData: hasPhase,
}
if hasPhase {
for _, ph := range []struct{ label string; w float64 }{
{"L1", l1}, {"L2", l2}, {"L3", l3},
} {
data.Phases = append(data.Phases, phaseView{
Label: ph.label,
PowerW: ph.w,
IsExport: ph.w < 0,
AbsPowerW: abs(ph.w),
})
}
}
if s.fcResult != nil {
data.Forecast = forecastView{
@@ -227,6 +267,7 @@ func (s *Store) Handler() http.HandlerFunc {
CanOverride: c != engine.ConsumerWW,
ManualOverride: rec.manualOverride,
OverrideUntil: rec.overrideUntil,
LivePowerW: rec.livePowerW,
}
if c == engine.ConsumerWW && !s.wwConfigured {
cv.Unconfigured = true
@@ -352,6 +393,41 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
/* Grid card direction arrow */
.arrow { font-size: 1.1rem; margin-right: 0.1rem; }
/* Phase grid */
.phase-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin-top: 0.75rem;
}
.phase-item {
text-align: center;
}
.phase-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9ca3af;
}
.phase-value {
font-size: 0.9rem;
font-weight: 700;
margin-top: 0.1rem;
}
/* Live power badge on consumers */
.power-badge {
font-size: 0.72rem;
font-weight: 600;
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
border-radius: 6px;
padding: 0.15rem 0.45rem;
flex-shrink: 0;
margin-right: 0.25rem;
}
/* Consumer list */
.section-title {
font-size: 0.7rem;
@@ -476,6 +552,23 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
<div class="card-value">{{printf "%.1f" .AmbientTempC}}<span style="font-size:1rem;font-weight:400"> °C</span></div>
</div>
<!-- Phase power (full width, only if data available) -->
{{if .HasPhaseData}}
<div class="card full">
<div class="card-label">Phasen (Netz)</div>
<div class="phase-grid">
{{range .Phases}}
<div class="phase-item">
<div class="phase-label">{{.Label}}</div>
<div class="phase-value" style="color:{{if .IsExport}}#16a34a{{else}}#dc2626{{end}}">
{{if .IsExport}}{{else}}{{end}}{{formatW .AbsPowerW}}
</div>
</div>
{{end}}
</div>
</div>
{{end}}
<!-- Forecast (full width) -->
{{if .Forecast.Available}}
<div class="card full">
@@ -512,6 +605,9 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
{{end}}
</div>
</div>
{{if and .Active .LivePowerW}}
<span class="power-badge">{{formatW .LivePowerW}}</span>
{{end}}
{{if .CanOverride}}
<form method="post" action="/override">
<input type="hidden" name="consumer" value="{{.ConsumerKey}}">
@@ -527,5 +623,12 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
</div>
{{end}}
<!-- Compressor status (shown when SG-Ready context is relevant) -->
{{if .CompressorPowerW}}
<div style="margin-top:0.5rem;font-size:0.75rem;color:#9ca3af;text-align:right">
Kompressor: {{formatW .CompressorPowerW}}
</div>
{{end}}
</body>
</html>`

View File

@@ -184,6 +184,7 @@ func runCycle(
logger.Warn("all Shelly devices unreachable, skipping override detection", "error", err)
} else {
eng.SyncHardwareState(shellyStates, now, cfg.EMS.OverrideTimeoutParsed())
store.SyncDeviceStates(shellyStates)
}
// Update metrics with current state