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 csrfToken string hasAuth bool } // 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, csrfToken string, hasAuth bool) *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, csrfToken: csrfToken, hasAuth: hasAuth, } } // 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 PVYieldTodayKWh float64 AmbientTempC float64 CompressorPowerW float64 WWTopTempC float64 IsExporting bool AbsGridW float64 HasPhaseData bool Phases []phaseView Forecast forecastView Consumers []consumerView Trip tripView CarOptions []CarOption CSRFToken string HasAuth bool // true when login is required — shows logout button } // 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, PVYieldTodayKWh: s.state.PVYieldTodayKWh, AmbientTempC: s.state.AmbientTempC, CompressorPowerW: s.state.CompressorPowerW, WWTopTempC: s.state.WWTopTempC, IsExporting: s.state.GridPowerW < 0, AbsGridW: abs(s.state.GridPowerW), HasPhaseData: hasPhase, CSRFToken: s.csrfToken, HasAuth: s.hasAuth, } 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 = ` Solar Status

☀️ Solar Status

{{if not .LastUpdate.IsZero}} {{.LastUpdate.Format "15:04:05"}} {{end}} {{if not .MonitorOnly}}
{{end}} {{if .HasAuth}} Abmelden {{end}}
{{if .MonitorOnly}} {{else if .DryRun}} {{end}} {{if .ErrMsg}} {{end}}
Batterie
{{printf "%.0f" .BatterySOC}} %
Netz
{{if .IsExporting}}
{{formatW .AbsGridW}}
Einspeisung
{{else}}
{{formatW .AbsGridW}}
Bezug
{{end}}
PV-Leistung
{{formatW .PVProductionW}}
{{if gt .PVYieldTodayKWh 0.0}}
Heute: {{printf "%.1f" .PVYieldTodayKWh}} kWh
{{end}}
Außentemperatur
{{printf "%.1f" .AmbientTempC}} °C
{{if .HasPhaseData}}
Phasen (Netz)
{{range .Phases}}
{{.Label}}
{{if .IsExport}}↑{{else}}↓{{end}}{{formatW .AbsPowerW}}
{{end}}
{{end}} {{if .Forecast.Available}}
Prognose heute
{{.Forecast.Icon}} {{printf "%.1f" .Forecast.KWh}} kWh {{if gt .PVYieldTodayKWh 0.0}}Ist: {{printf "%.1f" .PVYieldTodayKWh}} kWh{{end}}
{{.Forecast.Quality}}
{{end}}
Verbraucher — Priorität ↓
{{range .Consumers}}
{{.Icon}}
{{.Priority}}{{.Label}}
{{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}}
{{if .SubDetail}}
{{.SubDetail}}
{{end}} {{if and (not .Active) (not .ManualOverride) .ReadinessHint}}
{{.ReadinessHint}}
{{end}}
{{if and .Active .LivePowerW}} {{formatW .LivePowerW}} {{end}} {{if .CanOverride}}
{{if .Active}} {{else}} {{end}}
{{end}} {{if .IsWW}}
{{if not .Unconfigured}}
{{end}}
{{if .WWBoostDisabled}} {{else}} {{end}}
{{end}}
{{end}} {{if .Trip.Active}}
🚗 Fahrt geplant
{{.Trip.CarName}} · {{.Trip.WallboxLabel}}
Ladebedarf: {{printf "%.1f" .Trip.EnergyKWh}} kWh  ({{printf "%.0f" .Trip.CurrentSOC}}% → 100%) · ca. {{.Trip.DurationStr}}
{{if .Trip.ChargingStarted}}
⚡ Lädt seit {{.Trip.ChargingSince}}
{{else if .Trip.IsTight}}
⚠ Zeitfenster knapp — sofort einstecken!
{{else}}
Laden startet: {{.Trip.StartsAtStr}}
{{end}}
Bereit bis: {{.Trip.DeadlineStr}}
{{else if .CarOptions}}
🚗 Fahrtziel planen
Ladestand jetzt %
Bereit bis
{{end}} `