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" ) // 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 monitorMode *monitor.Mode } // NewStore creates a new status store. func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, mode *monitor.Mode) *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, } } // 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) 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 MonitorOnly 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 } monitorOnly := s.monitorMode != nil && s.monitorMode.IsActive() 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, 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"), } } } 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, 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 = `