Files
EMS/internal/status/status.go
Lutz Finsterle 7a971c4c41 Add configurable override duration and hard-stop thresholds
Override duration (web UI):
- Dropdown on Ein button: 30min / 1h / 2h / 4h
- Engine notified immediately via ApplyOverride() — no waiting for
  next SyncHardwareState cycle
- Aus button always uses 1h lockout (keeps consumer off for 1h)

Hard-stop thresholds that cancel active overrides:
- SOC emergency brake: now also clears ManualOverride flag so EMS
  resumes full control after the safety shutdown
- override_max_import_w (default 800W): if grid import exceeds this
  while an override is active, override is cancelled immediately
  (no hysteresis delay — protection is instant)

Config: ems.override_max_import_w (0 = disabled)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:43:49 +02:00

717 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
livePowerW float64 // from Shelly PM; 0 for non-PM devices
}
// 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
LivePowerW float64 // from Shelly PM; 0 for non-PM devices
}
type phaseView struct {
Label string
PowerW float64
IsExport bool
AbsPowerW float64
}
type forecastView struct {
Available bool
KWh float64
Icon string
Quality string
}
type chargingAdviceView struct {
Show bool
PlugInBy string // e.g. "11:30"
WindowStart string // e.g. "12:00"
WindowEnd string // e.g. "16:00"
}
type pageData struct {
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
ChargingAdvice chargingAdviceView
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
}
}
}
// 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{
"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()
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,
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{
Available: true,
KWh: s.fcResult.TotalKWh,
Icon: s.fcResult.QualityIcon(s.strategic),
Quality: s.fcResult.Quality(s.strategic),
}
// Charging advice: show when surplus window is ahead and no wallbox is active
ws := s.fcResult.SurplusWindowStart
we := s.fcResult.SurplusWindowEnd
now := time.Now()
wallboxActive := s.consumers[engine.ConsumerWallboxA].active ||
s.consumers[engine.ConsumerWallboxB].active
if !ws.IsZero() && now.Before(ws) && !wallboxActive {
plugBy := ws.Add(-30 * time.Minute)
if plugBy.Before(now) {
plugBy = ws // less than 30 min to window — just show window start
}
data.ChargingAdvice = chargingAdviceView{
Show: true,
PlugInBy: plugBy.Format("15:04"),
WindowStart: ws.Format("15:04"),
WindowEnd: we.Format("15:04"),
}
}
}
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,
LivePowerW: rec.livePowerW,
}
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; }
/* 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;
}
/* Charging advice card */
.advice-card {
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 14px;
padding: 0.85rem 1rem;
margin-bottom: 1.25rem;
}
.advice-title {
font-size: 0.78rem;
font-weight: 700;
color: #1d4ed8;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.4rem;
}
.advice-main {
font-size: 1rem;
font-weight: 700;
color: #1e3a8a;
}
.advice-sub {
font-size: 0.78rem;
color: #3b82f6;
margin-top: 0.2rem;
}
/* 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;
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; }
.dur-select {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 0.3rem 0.4rem;
font-size: 0.75rem;
background: #f9fafb;
color: #374151;
cursor: pointer;
flex-shrink: 0;
}
.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>
<!-- 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">
<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>
{{if .ChargingAdvice.Show}}
<div class="advice-card">
<div class="advice-title">🔌 Ladeempfehlung</div>
<div class="advice-main">Auto einstecken bis {{.ChargingAdvice.PlugInBy}} Uhr</div>
<div class="advice-sub">
Erwartetes Überschuss-Fenster: {{.ChargingAdvice.WindowStart}} {{.ChargingAdvice.WindowEnd}} Uhr
</div>
</div>
{{end}}
<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 and .Active .LivePowerW}}
<span class="power-badge">{{formatW .LivePowerW}}</span>
{{end}}
{{if .CanOverride}}
<form method="post" action="/override" style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0">
<input type="hidden" name="consumer" value="{{.ConsumerKey}}">
{{if .Active}}
<input type="hidden" name="state" value="off">
<input type="hidden" name="duration" value="1h">
<button type="submit" class="override-btn turn-off">Aus</button>
{{else}}
<input type="hidden" name="state" value="on">
<select name="duration" class="dur-select">
<option value="30m">30 min</option>
<option value="1h" selected>1 Std</option>
<option value="2h">2 Std</option>
<option value="4h">4 Std</option>
</select>
<button type="submit" class="override-btn turn-on">Ein</button>
{{end}}
</form>
{{end}}
</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>`