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