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:
7
Makefile
7
Makefile
@@ -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:
|
||||
|
||||
@@ -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>`
|
||||
|
||||
1
main.go
1
main.go
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user