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 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 = `