diff --git a/Makefile b/Makefile index 7e5fac8..654af21 100644 --- a/Makefile +++ b/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: diff --git a/internal/status/status.go b/internal/status/status.go index 92ce7a7..f57f2e9 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -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; }