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: build
install -Dm755 $(BINARY) /usr/local/bin/$(BINARY) 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 install -Dm644 systemd/ems.service /etc/systemd/system/ems.service
systemctl daemon-reload 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" @echo "Run: systemctl enable --now ems"
lint: lint:

View File

@@ -36,6 +36,7 @@ type consumerRecord struct {
reason string reason string
manualOverride bool manualOverride bool
overrideUntil time.Time 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. // 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) CanOverride bool // true for Shelly consumers (hardware read-back available)
ManualOverride bool ManualOverride bool
OverrideUntil time.Time 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 { type forecastView struct {
@@ -128,17 +137,20 @@ type forecastView struct {
} }
type pageData struct { type pageData struct {
LastUpdate time.Time LastUpdate time.Time
ErrMsg string ErrMsg string
DryRun bool DryRun bool
BatterySOC float64 BatterySOC float64
GridPowerW float64 GridPowerW float64
PVProductionW float64 PVProductionW float64
AmbientTempC float64 AmbientTempC float64
IsExporting bool CompressorPowerW float64
AbsGridW float64 IsExporting bool
Forecast forecastView AbsGridW float64
Consumers []consumerView HasPhaseData bool
Phases []phaseView
Forecast forecastView
Consumers []consumerView
} }
// SyncConsumerStates updates active flags for all consumers directly from the // 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. // Handler returns an HTTP handler that renders the status page.
func (s *Store) Handler() http.HandlerFunc { func (s *Store) Handler() http.HandlerFunc {
tmpl := template.Must(template.New("status").Funcs(template.FuncMap{ tmpl := template.Must(template.New("status").Funcs(template.FuncMap{
@@ -195,16 +219,32 @@ func (s *Store) Handler() http.HandlerFunc {
} }
s.mu.RLock() s.mu.RLock()
l1, l2, l3 := s.state.PhaseL1PowerW, s.state.PhaseL2PowerW, s.state.PhaseL3PowerW
hasPhase := l1 != 0 || l2 != 0 || l3 != 0
data := pageData{ data := pageData{
LastUpdate: s.lastUpdate, LastUpdate: s.lastUpdate,
ErrMsg: s.errMsg, ErrMsg: s.errMsg,
DryRun: s.dryRun, DryRun: s.dryRun,
BatterySOC: s.state.BatterySOC, BatterySOC: s.state.BatterySOC,
GridPowerW: s.state.GridPowerW, GridPowerW: s.state.GridPowerW,
PVProductionW: s.state.PVProductionW, PVProductionW: s.state.PVProductionW,
AmbientTempC: s.state.AmbientTempC, AmbientTempC: s.state.AmbientTempC,
IsExporting: s.state.GridPowerW < 0, CompressorPowerW: s.state.CompressorPowerW,
AbsGridW: abs(s.state.GridPowerW), 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 { if s.fcResult != nil {
data.Forecast = forecastView{ data.Forecast = forecastView{
@@ -227,6 +267,7 @@ func (s *Store) Handler() http.HandlerFunc {
CanOverride: c != engine.ConsumerWW, CanOverride: c != engine.ConsumerWW,
ManualOverride: rec.manualOverride, ManualOverride: rec.manualOverride,
OverrideUntil: rec.overrideUntil, OverrideUntil: rec.overrideUntil,
LivePowerW: rec.livePowerW,
} }
if c == engine.ConsumerWW && !s.wwConfigured { if c == engine.ConsumerWW && !s.wwConfigured {
cv.Unconfigured = true cv.Unconfigured = true
@@ -352,6 +393,41 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
/* Grid card direction arrow */ /* Grid card direction arrow */
.arrow { font-size: 1.1rem; margin-right: 0.1rem; } .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 */ /* Consumer list */
.section-title { .section-title {
font-size: 0.7rem; 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 class="card-value">{{printf "%.1f" .AmbientTempC}}<span style="font-size:1rem;font-weight:400"> °C</span></div>
</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) --> <!-- Forecast (full width) -->
{{if .Forecast.Available}} {{if .Forecast.Available}}
<div class="card full"> <div class="card full">
@@ -512,6 +605,9 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
{{end}} {{end}}
</div> </div>
</div> </div>
{{if and .Active .LivePowerW}}
<span class="power-badge">{{formatW .LivePowerW}}</span>
{{end}}
{{if .CanOverride}} {{if .CanOverride}}
<form method="post" action="/override"> <form method="post" action="/override">
<input type="hidden" name="consumer" value="{{.ConsumerKey}}"> <input type="hidden" name="consumer" value="{{.ConsumerKey}}">
@@ -527,5 +623,12 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
</div> </div>
{{end}} {{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> </body>
</html>` </html>`

View File

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