1. SOC high bypass: when SOC >= soc_high_bypass_pct (default 90%), activate car charging without requiring a good forecast. Solves today's missed charging window where 4kW was exported for 6h with a full battery because the intraday forecast dropped from 15.4 to 13.3 kWh. 2. Log ambient_c in every cycle: makes it diagnosable why isHeatingPeriod suppresses SG-Ready on warm days (heating_min_ambient_c: 15°C gate). 3. Consumer names in logs: replace slog integer Consumer values with .String() so logs show "wallbox_a" instead of "2". 4. Status page order: reorder consumers by EMS priority (WallboxA → WallboxB → WW → SG-Ready) instead of the old reversed order. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
991 lines
28 KiB
Go
991 lines
28 KiB
Go
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 — highest EMS priority first.
|
|
var consumerOrder = []engine.Consumer{
|
|
engine.ConsumerWallboxA,
|
|
engine.ConsumerWallboxB,
|
|
engine.ConsumerWW,
|
|
engine.ConsumerSGReady,
|
|
}
|
|
|
|
// 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
|
|
carCharging config.CarChargingConfig
|
|
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, carCharging config.CarChargingConfig, 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,
|
|
carCharging: carCharging,
|
|
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
|
|
SubDetail string // extra context line: tank temp for WW, compressor for SG-Ready
|
|
ReadinessHint string // shown when inactive: explains what's blocking activation
|
|
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 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
|
|
WWTopTempC float64
|
|
IsExporting bool
|
|
AbsGridW float64
|
|
HasPhaseData bool
|
|
Phases []phaseView
|
|
Forecast forecastView
|
|
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
|
|
forecastKWh := 0.0
|
|
if s.fcResult != nil {
|
|
forecastKWh = s.fcResult.TotalKWh
|
|
}
|
|
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,
|
|
WWTopTempC: s.state.WWTopTempC,
|
|
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),
|
|
}
|
|
}
|
|
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,
|
|
}
|
|
// SubDetail: extra context line per consumer type
|
|
switch c {
|
|
case engine.ConsumerSGReady:
|
|
if s.state.CompressorPowerW > 0 {
|
|
cv.SubDetail = "Kompressor: " + formatW(s.state.CompressorPowerW)
|
|
} else {
|
|
cv.SubDetail = "Kompressor: Standby"
|
|
}
|
|
case engine.ConsumerWW:
|
|
if s.state.WWTopTempC > 0 {
|
|
cv.SubDetail = fmt.Sprintf("Speicher: %.1f°C", s.state.WWTopTempC)
|
|
}
|
|
}
|
|
// ReadinessHint: shown when consumer is inactive and not overridden
|
|
if !rec.active && !rec.manualOverride {
|
|
cv.ReadinessHint = s.buildReadinessHint(c, s.state.BatterySOC, s.state.PVProductionW, forecastKWh)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// buildReadinessHint returns a short string explaining why a consumer is currently inactive.
|
|
// Returns empty string when the consumer is not forecast/threshold driven.
|
|
func (s *Store) buildReadinessHint(c engine.Consumer, soc, pvW, forecastKWh float64) string {
|
|
cc := s.carCharging
|
|
switch c {
|
|
case engine.ConsumerWallboxA, engine.ConsumerWallboxB:
|
|
if cc.PVThresholdAW == 0 && cc.PVThresholdBW == 0 {
|
|
return "" // proactive not configured
|
|
}
|
|
midKWh := s.strategic.ForecastMidKWh
|
|
if forecastKWh == 0 {
|
|
return "Keine Prognose verfügbar"
|
|
}
|
|
if forecastKWh < midKWh {
|
|
return fmt.Sprintf("Prognose zu gering (%.0f kWh)", forecastKWh)
|
|
}
|
|
if float64(cc.MinSOC) > 0 && soc < float64(cc.MinSOC) {
|
|
return fmt.Sprintf("SOC zu niedrig (min. %d%%)", cc.MinSOC)
|
|
}
|
|
threshold := cc.PVThresholdAW
|
|
if c == engine.ConsumerWallboxB {
|
|
threshold = cc.PVThresholdBW
|
|
}
|
|
if pvW < threshold {
|
|
return fmt.Sprintf("Warte auf PV ≥ %.0f W (aktuell %.0f W)", threshold, pvW)
|
|
}
|
|
return "Bereit — aktiviert bei nächstem Zyklus"
|
|
case engine.ConsumerWW:
|
|
if s.strategic.ForecastMidKWh > 0 && forecastKWh < s.strategic.ForecastMidKWh {
|
|
return fmt.Sprintf("Prognose zu gering (%.0f kWh)", forecastKWh)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// 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-btn {
|
|
background: none; border: 1px solid #d1d5db; color: #9ca3af;
|
|
border-radius: 8px; padding: 0.25rem 0.75rem; font-size: 0.72rem;
|
|
cursor: pointer; flex-shrink: 0;
|
|
}
|
|
.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;
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
.consumer-detail.active { color: #16a34a; font-weight: 500; }
|
|
.consumer-detail.override { color: #d97706; font-weight: 500; }
|
|
.consumer-sub {
|
|
font-size: 0.72rem;
|
|
color: #9ca3af;
|
|
margin-top: 0.1rem;
|
|
}
|
|
.consumer-hint {
|
|
font-size: 0.72rem;
|
|
color: #6b7280;
|
|
margin-top: 0.1rem;
|
|
font-style: italic;
|
|
}
|
|
|
|
.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>
|
|
<div style="display:flex;align-items:center;gap:0.6rem">
|
|
{{if not .LastUpdate.IsZero}}
|
|
<span class="updated">{{.LastUpdate.Format "15:04:05"}}</span>
|
|
{{end}}
|
|
{{if not .MonitorOnly}}
|
|
<form method="post" action="/monitor">
|
|
<button type="submit" class="monitor-toggle-btn">⏸ Monitor</button>
|
|
</form>
|
|
{{end}}
|
|
</div>
|
|
</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>
|
|
|
|
<div class="section-title">Verbraucher — 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}}{{if .Reason}} — {{.Reason}}{{end}}
|
|
{{else}}
|
|
Ausgeschaltet
|
|
{{end}}
|
|
</div>
|
|
{{if .SubDetail}}<div class="consumer-sub">{{.SubDetail}}</div>{{end}}
|
|
{{if and (not .Active) (not .ManualOverride) .ReadinessHint}}
|
|
<div class="consumer-hint">{{.ReadinessHint}}</div>
|
|
{{end}}
|
|
</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}}
|
|
|
|
{{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 ({{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>`
|