Initial commit: EMS — Energie Management System
Complete self-consumption optimisation system for 7 kWp PV installation: - Prometheus collector (grid power, SOC, PV, per-phase, compressor) - Pure decision engine with SOC gates, hysteresis, priority ordering - Shelly Gen1/Gen2 actuator (SHA-256 Digest auth, PM power readback) - Viessmann OAuth2 client for DHW temperature control - PV forecast integration (forecast.solar) - Wallbox mutual exclusion (VX3 4.6 kW AC output constraint) - Car-not-charging detection via Shelly PM - Compressor idle → early SG-Ready release - Per-phase grid power for single-phase wallbox decisions - Manual override detection and web UI with override buttons - Full unit test coverage for decision engine - systemd service, Makefile, complete documentation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
531
internal/status/status.go
Normal file
531
internal/status/status.go
Normal file
@@ -0,0 +1,531 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tb/ems/internal/collector"
|
||||
"github.com/tb/ems/internal/config"
|
||||
"github.com/tb/ems/internal/engine"
|
||||
"github.com/tb/ems/internal/forecast"
|
||||
)
|
||||
|
||||
// consumerMeta holds static display info for each consumer.
|
||||
var consumerMeta = map[engine.Consumer]struct{ Label, Icon string }{
|
||||
engine.ConsumerSGReady: {"Wärmepumpe Boost (SG-Ready)", "🔥"},
|
||||
engine.ConsumerWW: {"Warmwasser Boost", "🌡️"},
|
||||
engine.ConsumerWallboxA: {"Wallbox A (2 kW)", "🔌"},
|
||||
engine.ConsumerWallboxB: {"Wallbox B (4 kW)", "🔌"},
|
||||
}
|
||||
|
||||
// consumerOrder defines the display order of consumers.
|
||||
var consumerOrder = []engine.Consumer{
|
||||
engine.ConsumerSGReady,
|
||||
engine.ConsumerWW,
|
||||
engine.ConsumerWallboxA,
|
||||
engine.ConsumerWallboxB,
|
||||
}
|
||||
|
||||
// consumerRecord tracks the state of a single consumer across cycles.
|
||||
type consumerRecord struct {
|
||||
active bool
|
||||
since time.Time
|
||||
reason string
|
||||
manualOverride bool
|
||||
overrideUntil time.Time
|
||||
}
|
||||
|
||||
// Store holds the latest EMS snapshot and is safe for concurrent use.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
lastUpdate time.Time
|
||||
errMsg string
|
||||
state collector.SystemState
|
||||
dryRun bool
|
||||
wwConfigured bool
|
||||
strategic config.StrategicConfig
|
||||
consumers map[engine.Consumer]*consumerRecord
|
||||
fcResult *forecast.Result
|
||||
}
|
||||
|
||||
// NewStore creates a new status store.
|
||||
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig) *Store {
|
||||
consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder))
|
||||
for _, c := range consumerOrder {
|
||||
consumers[c] = &consumerRecord{}
|
||||
}
|
||||
return &Store{
|
||||
dryRun: dryRun,
|
||||
wwConfigured: wwConfigured,
|
||||
strategic: strategic,
|
||||
consumers: consumers,
|
||||
}
|
||||
}
|
||||
|
||||
// Update records the latest state, actions, overrides, and forecast for this cycle.
|
||||
func (s *Store) Update(state collector.SystemState, actions []engine.Action, overrides map[engine.Consumer]engine.OverrideInfo, fcResult *forecast.Result, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.lastUpdate = time.Now()
|
||||
s.state = state
|
||||
|
||||
if err != nil {
|
||||
s.errMsg = err.Error()
|
||||
} else {
|
||||
s.errMsg = ""
|
||||
}
|
||||
|
||||
if fcResult != nil {
|
||||
s.fcResult = fcResult
|
||||
}
|
||||
|
||||
for _, a := range actions {
|
||||
rec, ok := s.consumers[a.Consumer]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rec.active = a.TurnOn
|
||||
rec.since = time.Now()
|
||||
rec.reason = a.Reason
|
||||
}
|
||||
|
||||
// Sync override state for all consumers
|
||||
for c, rec := range s.consumers {
|
||||
if info, overridden := overrides[c]; overridden {
|
||||
rec.manualOverride = info.Active
|
||||
rec.overrideUntil = info.Until
|
||||
} else {
|
||||
rec.manualOverride = false
|
||||
rec.overrideUntil = time.Time{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Template data types ---
|
||||
|
||||
type consumerView struct {
|
||||
Icon string
|
||||
Label string
|
||||
ConsumerKey string // e.g. "wallbox_a" — used for the override form
|
||||
Active bool
|
||||
Since time.Time
|
||||
Reason string
|
||||
Unconfigured bool
|
||||
CanOverride bool // true for Shelly consumers (hardware read-back available)
|
||||
ManualOverride bool
|
||||
OverrideUntil time.Time
|
||||
}
|
||||
|
||||
type forecastView struct {
|
||||
Available bool
|
||||
KWh float64
|
||||
Icon string
|
||||
Quality string
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// SyncConsumerStates updates active flags for all consumers directly from the
|
||||
// engine's current state. Called every cycle so the status page reflects reality
|
||||
// even when no Action was produced (e.g. after a manual override is detected).
|
||||
func (s *Store) SyncConsumerStates(states map[engine.Consumer]bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for c, active := range states {
|
||||
if rec, ok := s.consumers[c]; ok {
|
||||
rec.active = active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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{
|
||||
"formatW": formatW,
|
||||
"formatSince": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
d := time.Since(t).Round(time.Minute)
|
||||
if d < time.Minute {
|
||||
return "gerade eben"
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("seit %d min", int(d.Minutes()))
|
||||
}
|
||||
h := int(d.Hours())
|
||||
m := int(d.Minutes()) % 60
|
||||
if m == 0 {
|
||||
return fmt.Sprintf("seit %d h", h)
|
||||
}
|
||||
return fmt.Sprintf("seit %d h %d min", h, m)
|
||||
},
|
||||
"socColor": func(soc float64) string {
|
||||
switch {
|
||||
case soc >= 80:
|
||||
return "#16a34a"
|
||||
case soc >= 50:
|
||||
return "#d97706"
|
||||
default:
|
||||
return "#dc2626"
|
||||
}
|
||||
},
|
||||
}).Parse(htmlTemplate))
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
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),
|
||||
}
|
||||
if s.fcResult != nil {
|
||||
data.Forecast = forecastView{
|
||||
Available: true,
|
||||
KWh: s.fcResult.TotalKWh,
|
||||
Icon: s.fcResult.QualityIcon(s.strategic),
|
||||
Quality: s.fcResult.Quality(s.strategic),
|
||||
}
|
||||
}
|
||||
for _, c := range consumerOrder {
|
||||
rec := s.consumers[c]
|
||||
meta := consumerMeta[c]
|
||||
cv := consumerView{
|
||||
Icon: meta.Icon,
|
||||
Label: meta.Label,
|
||||
ConsumerKey: c.String(),
|
||||
Active: rec.active,
|
||||
Since: rec.since,
|
||||
Reason: rec.reason,
|
||||
CanOverride: c != engine.ConsumerWW,
|
||||
ManualOverride: rec.manualOverride,
|
||||
OverrideUntil: rec.overrideUntil,
|
||||
}
|
||||
if c == engine.ConsumerWW && !s.wwConfigured {
|
||||
cv.Unconfigured = true
|
||||
}
|
||||
data.Consumers = append(data.Consumers, cv)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func abs(v float64) float64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func formatW(w float64) string {
|
||||
if w >= 1000 || w <= -1000 {
|
||||
return fmt.Sprintf("%.1f kW", w/1000)
|
||||
}
|
||||
return fmt.Sprintf("%.0f W", w)
|
||||
}
|
||||
|
||||
const htmlTemplate = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="30">
|
||||
<title>Solar Status</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f0fdf4;
|
||||
color: #1a1a1a;
|
||||
min-height: 100vh;
|
||||
padding: 1.25rem 1rem 2rem;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
.updated { font-size: 0.75rem; color: #6b7280; }
|
||||
|
||||
.banner {
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.banner.warn { background: #fef9c3; color: #854d0e; border: 1px solid #fde68a; }
|
||||
.banner.error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.07);
|
||||
}
|
||||
.card-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.card-value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.card-sub {
|
||||
font-size: 0.78rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
/* SOC bar */
|
||||
.soc-bar {
|
||||
background: #e5e7eb;
|
||||
border-radius: 999px;
|
||||
height: 8px;
|
||||
margin-top: 0.6rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.soc-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* Forecast card — full width */
|
||||
.card.full { grid-column: 1 / -1; }
|
||||
.forecast-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.forecast-icon { font-size: 1.4rem; line-height: 1; }
|
||||
.forecast-quality { font-size: 0.85rem; color: #6b7280; margin-top: 0.2rem; }
|
||||
|
||||
/* Grid card direction arrow */
|
||||
.arrow { font-size: 1.1rem; margin-right: 0.1rem; }
|
||||
|
||||
/* Consumer list */
|
||||
.section-title {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.consumer {
|
||||
background: #ffffff;
|
||||
border-radius: 14px;
|
||||
padding: 0.85rem 1rem;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.07);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.consumer:last-child { margin-bottom: 0; }
|
||||
.consumer.unconfigured { opacity: 0.45; }
|
||||
|
||||
.indicator {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.indicator.on {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 0 3px #bbf7d0;
|
||||
}
|
||||
.indicator.off { background: #d1d5db; }
|
||||
.indicator.override {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 0 3px #fde68a;
|
||||
}
|
||||
|
||||
.consumer-icon { font-size: 1.2rem; flex-shrink: 0; }
|
||||
.consumer-body { flex: 1; min-width: 0; }
|
||||
.consumer-name { font-weight: 600; font-size: 0.9rem; }
|
||||
.consumer-detail {
|
||||
font-size: 0.78rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.15rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.consumer-detail.active { color: #16a34a; font-weight: 500; }
|
||||
.consumer-detail.override { color: #d97706; font-weight: 500; }
|
||||
|
||||
.override-btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.override-btn:active { opacity: 0.7; }
|
||||
.override-btn.turn-on { background: #dcfce7; color: #15803d; }
|
||||
.override-btn.turn-off { background: #fee2e2; color: #b91c1c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>☀️ Solar Status</h1>
|
||||
{{if not .LastUpdate.IsZero}}
|
||||
<span class="updated">{{.LastUpdate.Format "15:04:05"}}</span>
|
||||
{{end}}
|
||||
</header>
|
||||
|
||||
{{if .DryRun}}
|
||||
<div class="banner warn">⚠️ Testmodus — keine echten Schaltvorgänge</div>
|
||||
{{end}}
|
||||
{{if .ErrMsg}}
|
||||
<div class="banner error">⚠️ {{.ErrMsg}}</div>
|
||||
{{end}}
|
||||
|
||||
<div class="grid">
|
||||
|
||||
<!-- Battery -->
|
||||
<div class="card">
|
||||
<div class="card-label">Batterie</div>
|
||||
<div class="card-value" style="color: {{socColor .BatterySOC}}">
|
||||
{{printf "%.0f" .BatterySOC}}<span style="font-size:1rem;font-weight:400"> %</span>
|
||||
</div>
|
||||
<div class="soc-bar">
|
||||
<div class="soc-fill" style="width:{{printf "%.0f" .BatterySOC}}%; background:{{socColor .BatterySOC}}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="card">
|
||||
<div class="card-label">Netz</div>
|
||||
{{if .IsExporting}}
|
||||
<div class="card-value" style="color:#16a34a">
|
||||
<span class="arrow">↑</span>{{formatW .AbsGridW}}
|
||||
</div>
|
||||
<div class="card-sub">Einspeisung</div>
|
||||
{{else}}
|
||||
<div class="card-value" style="color:#dc2626">
|
||||
<span class="arrow">↓</span>{{formatW .AbsGridW}}
|
||||
</div>
|
||||
<div class="card-sub">Bezug</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- PV -->
|
||||
<div class="card">
|
||||
<div class="card-label">PV-Leistung</div>
|
||||
<div class="card-value" style="color:#d97706">{{formatW .PVProductionW}}</div>
|
||||
</div>
|
||||
|
||||
<!-- Temperature -->
|
||||
<div class="card">
|
||||
<div class="card-label">Außentemperatur</div>
|
||||
<div class="card-value">{{printf "%.1f" .AmbientTempC}}<span style="font-size:1rem;font-weight:400"> °C</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Forecast (full width) -->
|
||||
{{if .Forecast.Available}}
|
||||
<div class="card full">
|
||||
<div class="card-label">Prognose heute</div>
|
||||
<div class="forecast-row">
|
||||
<span class="forecast-icon">{{.Forecast.Icon}}</span>
|
||||
<span class="card-value" style="color:#d97706">{{printf "%.1f" .Forecast.KWh}} kWh</span>
|
||||
</div>
|
||||
<div class="forecast-quality">{{.Forecast.Quality}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="section-title">Verbraucher</div>
|
||||
|
||||
{{range .Consumers}}
|
||||
<div class="consumer{{if .Unconfigured}} unconfigured{{end}}">
|
||||
<div class="indicator {{if .ManualOverride}}override{{else if .Active}}on{{else}}off{{end}}"></div>
|
||||
<div class="consumer-icon">{{.Icon}}</div>
|
||||
<div class="consumer-body">
|
||||
<div class="consumer-name">{{.Label}}</div>
|
||||
<div class="consumer-detail{{if and .Active (not .ManualOverride)}} active{{end}}{{if .ManualOverride}} override{{end}}">
|
||||
{{if .Unconfigured}}
|
||||
nicht konfiguriert
|
||||
{{else if .ManualOverride}}
|
||||
Manuell bis {{.OverrideUntil.Format "15:04"}}
|
||||
{{else if .Active}}
|
||||
{{formatSince .Since}}
|
||||
{{else if .Reason}}
|
||||
{{.Reason}}
|
||||
{{else}}
|
||||
Ausgeschaltet
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{if .CanOverride}}
|
||||
<form method="post" action="/override">
|
||||
<input type="hidden" name="consumer" value="{{.ConsumerKey}}">
|
||||
{{if .Active}}
|
||||
<input type="hidden" name="state" value="off">
|
||||
<button type="submit" class="override-btn turn-off">Aus</button>
|
||||
{{else}}
|
||||
<input type="hidden" name="state" value="on">
|
||||
<button type="submit" class="override-btn turn-on">Ein</button>
|
||||
{{end}}
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
Reference in New Issue
Block a user