Files
EMS/internal/status/status.go
Lutz Finsterle 009f8b05f3 Stack WW boost buttons vertically in consumer card
The reset and boost toggle buttons were side by side, looking cramped.
Wrapped them in a flex column div so they stack neatly below each other.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:23:27 +02:00

990 lines
28 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"
"github.com/tb/ems/internal/monitor"
"github.com/tb/ems/internal/trip"
)
// 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
}
// CarOption is a selectable car for the trip mode form.
type CarOption struct {
Key string
Name string
BatteryKWh float64
}
// 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
monitorMode *monitor.Mode
wwBoostDisabled *monitor.Mode
tripMgr *trip.Manager
carOptions []CarOption
}
// NewStore creates a new status store.
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, mode *monitor.Mode, wwBoostDisabled *monitor.Mode, tm *trip.Manager, cars []CarOption) *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,
monitorMode: mode,
wwBoostDisabled: wwBoostDisabled,
tripMgr: tm,
carOptions: cars,
}
}
// 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
Priority string // e.g. "①"
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)
IsWW bool // true for ConsumerWW — shows reset button instead of override
WWBoostDisabled bool // copied from pageData for template access inside range
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 tripView struct {
Active bool
CarName string
WallboxLabel string // "Wallbox A" or "Wallbox B"
CurrentSOC float64
EnergyKWh float64 // energy needed
DeadlineStr string // e.g. "morgen 07:00"
StartsAtStr string // e.g. "heute 22:30" / "jetzt"
DurationStr string // estimated charge time, e.g. "15h 45min"
ChargingStarted bool
ChargingSince string // e.g. "seit 2h 10min"
IsTight bool // start time already passed, deadline at risk
}
type pageData struct {
LastUpdate time.Time
ErrMsg string
DryRun bool
MonitorOnly bool
WWBoostDisabled 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
Trip tripView
CarOptions []CarOption
}
// 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
}
monitorOnly := s.monitorMode != nil && s.monitorMode.IsActive()
wwBoostDisabled := s.wwBoostDisabled != nil && s.wwBoostDisabled.IsActive()
now := time.Now()
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,
MonitorOnly: monitorOnly,
WWBoostDisabled: wwBoostDisabled,
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
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"),
}
}
}
prioritySymbols := []string{"①", "②", "③", "④"}
for i, c := range consumerOrder {
rec := s.consumers[c]
meta := consumerMeta[c]
prio := ""
if i < len(prioritySymbols) {
prio = prioritySymbols[i]
}
cv := consumerView{
Icon: meta.Icon,
Label: meta.Label,
Priority: prio,
ConsumerKey: c.String(),
Active: rec.active,
Since: rec.since,
Reason: rec.reason,
CanOverride: c != engine.ConsumerWW,
IsWW: c == engine.ConsumerWW,
WWBoostDisabled: c == engine.ConsumerWW && wwBoostDisabled,
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()
// Trip mode view (read outside store lock — tripMgr has its own lock)
data.CarOptions = s.carOptions
if s.tripMgr != nil {
data.Trip = buildTripView(s.tripMgr, now)
}
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)
}
}
}
// buildTripView constructs the trip card data for the current cycle.
func buildTripView(tm *trip.Manager, now time.Time) tripView {
goal := tm.ActiveGoal()
if goal == nil {
return tripView{}
}
// Use rated wallbox power as fallback (learned rate improves over sessions)
// The actual rated power isn't available here, so use battery capacity / 10 as rough estimate.
// The real rate is computed in runCycle; here we just format what's stored.
rateKW := goal.BatteryKWh / 20.0 // conservative placeholder for display only
startTime := goal.StartTime(rateKW)
duration := goal.ChargeDuration(rateKW)
wbLabel := "Wallbox A"
if goal.Wallbox == "wallbox_b" {
wbLabel = "Wallbox B"
}
tv := tripView{
Active: true,
CarName: goal.CarName,
WallboxLabel: wbLabel,
CurrentSOC: goal.CurrentSOC,
EnergyKWh: goal.EnergyNeededKWh(),
DeadlineStr: formatDay(goal.Deadline, now),
DurationStr: formatDur(duration),
IsTight: goal.IsTight(now, rateKW),
}
if !goal.ChargingStartedAt.IsZero() {
tv.ChargingStarted = true
tv.ChargingSince = formatDur(now.Sub(goal.ChargingStartedAt))
} else if goal.ShouldStartNow(now, rateKW) {
tv.StartsAtStr = "jetzt"
} else {
tv.StartsAtStr = formatDay(startTime, now)
}
return tv
}
func formatDay(t, now time.Time) string {
today := now.Format("2006-01-02")
tomorrow := now.AddDate(0, 0, 1).Format("2006-01-02")
switch t.Format("2006-01-02") {
case today:
return "heute " + t.Format("15:04")
case tomorrow:
return "morgen " + t.Format("15:04")
default:
return t.Format("02.01. 15:04")
}
}
func formatDur(d time.Duration) string {
d = d.Round(time.Minute)
h := int(d.Hours())
m := int(d.Minutes()) % 60
if h == 0 {
return fmt.Sprintf("%d min", m)
}
if m == 0 {
return fmt.Sprintf("%d h", h)
}
return fmt.Sprintf("%d h %d min", h, m)
}
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; }
.banner.monitor {
background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa;
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
}
.resume-btn {
background: #fff; border: 1px solid #9a3412; color: #9a3412;
border-radius: 7px; padding: 0.25rem 0.7rem; font-size: 0.8rem;
font-weight: 700; cursor: pointer; white-space: nowrap; flex-shrink: 0;
}
.resume-btn:active { opacity: 0.7; }
.monitor-toggle {
text-align: center; margin-top: 1.5rem; padding-bottom: 0.5rem;
}
.monitor-toggle-btn {
background: none; border: 1px solid #d1d5db; color: #9ca3af;
border-radius: 8px; padding: 0.35rem 1rem; font-size: 0.75rem;
cursor: pointer;
}
.monitor-toggle-btn:hover { border-color: #9ca3af; color: #6b7280; }
/* Trip mode card */
.trip-card {
background: #f0fdf4; border: 1px solid #86efac;
border-radius: 14px; padding: 0.85rem 1rem; margin-bottom: 1.25rem;
}
.trip-card.tight { background: #fff7ed; border-color: #fed7aa; }
.trip-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 0.5rem;
}
.trip-title { font-size: 0.78rem; font-weight: 700; color: #15803d; text-transform: uppercase; letter-spacing: 0.05em; }
.trip-card.tight .trip-title { color: #9a3412; }
.trip-cancel { background: none; border: 1px solid #d1d5db; color: #9ca3af; border-radius: 7px; padding: 0.2rem 0.55rem; font-size: 0.75rem; cursor: pointer; }
.trip-cancel:hover { border-color: #9ca3af; color: #6b7280; }
.trip-car { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.2rem; }
.trip-detail { font-size: 0.78rem; color: #6b7280; }
.trip-timing { font-size: 0.85rem; font-weight: 600; color: #15803d; margin-top: 0.35rem; }
.trip-card.tight .trip-timing { color: #9a3412; }
.trip-deadline { font-size: 0.78rem; color: #6b7280; margin-top: 0.1rem; }
/* Trip form */
.trip-form-card {
background: #fff; border-radius: 14px; padding: 0.85rem 1rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.07); margin-top: 1.25rem;
}
.trip-form { display: flex; flex-direction: column; gap: 0.6rem; margin-top: 0.5rem; }
.trip-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.trip-select, .trip-number, .trip-datetime {
border: 1px solid #d1d5db; border-radius: 8px; padding: 0.35rem 0.5rem;
font-size: 0.82rem; background: #f9fafb; color: #374151;
}
.trip-select { flex: 1; min-width: 0; }
.trip-number { width: 4.5rem; }
.trip-datetime { flex: 1; min-width: 0; }
.trip-label { font-size: 0.75rem; color: #6b7280; white-space: nowrap; }
.trip-submit {
background: #16a34a; color: #fff; border: none; border-radius: 8px;
padding: 0.45rem 1.2rem; font-size: 0.85rem; font-weight: 700; cursor: pointer; align-self: flex-end;
}
.trip-submit:active { opacity: 0.8; }
.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;
}
/* Priority badge */
.prio-badge {
font-size: 0.75rem;
color: #9ca3af;
margin-right: 0.35rem;
font-variant-numeric: tabular-nums;
}
/* 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 .MonitorOnly}}
<div class="banner monitor">
<span>⏸ Monitor-Only — Keine Schaltvorgänge</span>
<form method="post" action="/monitor">
<button type="submit" class="resume-btn">▶ Automatik</button>
</form>
</div>
{{else 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 &mdash; Priorität ↓</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">
<span class="prio-badge">{{.Priority}}</span>{{.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}}
{{if .IsWW}}
<div style="display:flex;flex-direction:column;gap:0.3rem;flex-shrink:0">
{{if not .Unconfigured}}
<form method="post" action="/ww/reset">
<button type="submit" class="override-btn turn-off" style="width:100%" title="WW-Solltemperatur auf Basiswert zurücksetzen, Boost für heute sperren">🌡️ Zurücksetzen</button>
</form>
{{end}}
<form method="post" action="/ww/boost">
{{if .WWBoostDisabled}}
<button type="submit" class="override-btn turn-on" style="width:100%" title="WW Boost durch PV-Überschuss wieder erlauben">✅ Boost ein</button>
{{else}}
<button type="submit" class="override-btn turn-off" style="width:100%" title="WW Boost dauerhaft deaktivieren (z.B. im Sommer)">⛔ Boost aus</button>
{{end}}
</form>
</div>
{{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}}
{{if not .MonitorOnly}}
<div class="monitor-toggle">
<form method="post" action="/monitor">
<button type="submit" class="monitor-toggle-btn">⏸ Monitor-Only</button>
</form>
</div>
{{end}}
{{if .Trip.Active}}
<div class="trip-card{{if .Trip.IsTight}} tight{{end}}">
<div class="trip-header">
<span class="trip-title">🚗 Fahrt geplant</span>
<form method="post" action="/trip/cancel">
<button type="submit" class="trip-cancel">Abbrechen</button>
</form>
</div>
<div class="trip-car">{{.Trip.CarName}} · {{.Trip.WallboxLabel}}</div>
<div class="trip-detail">Ladebedarf: {{printf "%.1f" .Trip.EnergyKWh}} kWh &nbsp;({{printf "%.0f" .Trip.CurrentSOC}}% → 100%) · ca. {{.Trip.DurationStr}}</div>
{{if .Trip.ChargingStarted}}
<div class="trip-timing">⚡ Lädt seit {{.Trip.ChargingSince}}</div>
{{else if .Trip.IsTight}}
<div class="trip-timing">⚠ Zeitfenster knapp — sofort einstecken!</div>
{{else}}
<div class="trip-timing">Laden startet: {{.Trip.StartsAtStr}}</div>
{{end}}
<div class="trip-deadline">Bereit bis: {{.Trip.DeadlineStr}}</div>
</div>
{{else if .CarOptions}}
<div class="trip-form-card">
<div class="section-title">🚗 Fahrtziel planen</div>
<form method="post" action="/trip" class="trip-form">
<div class="trip-row">
<select name="wallbox" class="trip-select">
<option value="wallbox_a">Wallbox A</option>
<option value="wallbox_b">Wallbox B</option>
</select>
<select name="car" class="trip-select">
{{range .CarOptions}}
<option value="{{.Key}}">{{.Name}} ({{.BatteryKWh}} kWh)</option>
{{end}}
</select>
</div>
<div class="trip-row">
<span class="trip-label">Ladestand jetzt</span>
<input type="number" name="current_soc" min="1" max="99" value="50" class="trip-number"> %
</div>
<div class="trip-row">
<span class="trip-label">Bereit bis</span>
<input type="datetime-local" name="deadline" class="trip-datetime" required>
</div>
<button type="submit" class="trip-submit">Planen</button>
</form>
</div>
{{end}}
</body>
</html>`