diff --git a/internal/status/status.go b/internal/status/status.go index 4ca0e7e..17866da 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -57,6 +57,7 @@ type Store struct { dryRun bool wwConfigured bool strategic config.StrategicConfig + carCharging config.CarChargingConfig consumers map[engine.Consumer]*consumerRecord fcResult *forecast.Result monitorMode *monitor.Mode @@ -66,7 +67,7 @@ type Store struct { } // 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 { +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{} @@ -75,6 +76,7 @@ func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, dryRun: dryRun, wwConfigured: wwConfigured, strategic: strategic, + carCharging: carCharging, consumers: consumers, monitorMode: mode, wwBoostDisabled: wwBoostDisabled, @@ -126,20 +128,22 @@ func (s *Store) Update(state collector.SystemState, actions []engine.Action, ove // --- 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 + 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 { @@ -156,13 +160,6 @@ type forecastView struct { 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 @@ -188,12 +185,12 @@ type pageData struct { PVProductionW float64 AmbientTempC float64 CompressorPowerW float64 + WWTopTempC float64 IsExporting bool AbsGridW float64 HasPhaseData bool Phases []phaseView Forecast forecastView - ChargingAdvice chargingAdviceView Consumers []consumerView Trip tripView CarOptions []CarOption @@ -271,6 +268,10 @@ func (s *Store) Handler() http.HandlerFunc { 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, @@ -282,12 +283,16 @@ func (s *Store) Handler() http.HandlerFunc { 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 }{ + for _, ph := range []struct { + label string + w float64 + }{ {"L1", l1}, {"L2", l2}, {"L3", l3}, } { data.Phases = append(data.Phases, phaseView{ @@ -305,24 +310,6 @@ func (s *Store) Handler() http.HandlerFunc { 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 { @@ -333,19 +320,36 @@ func (s *Store) Handler() http.HandlerFunc { 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, + 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, + 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 @@ -367,6 +371,41 @@ func (s *Store) Handler() http.HandlerFunc { } } +// 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() @@ -496,13 +535,10 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; } 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; + 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; } @@ -627,33 +663,6 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; } 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; @@ -720,12 +729,20 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; } 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; } +.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; @@ -757,9 +774,16 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }

☀️ Solar Status

- {{if not .LastUpdate.IsZero}} - {{.LastUpdate.Format "15:04:05"}} - {{end}} +
+ {{if not .LastUpdate.IsZero}} + {{.LastUpdate.Format "15:04:05"}} + {{end}} + {{if not .MonitorOnly}} +
+ +
+ {{end}} +
{{if .MonitorOnly}} @@ -848,16 +872,6 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; } -{{if .ChargingAdvice.Show}} -
-
🔌 Ladeempfehlung
-
Auto einstecken bis {{.ChargingAdvice.PlugInBy}} Uhr
-
- Erwartetes Überschuss-Fenster: {{.ChargingAdvice.WindowStart}} – {{.ChargingAdvice.WindowEnd}} Uhr -
-
-{{end}} -
Verbraucher — Priorität ↓
{{range .Consumers}} @@ -874,13 +888,15 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; } {{else if .ManualOverride}} Manuell bis {{.OverrideUntil.Format "15:04"}} {{else if .Active}} - {{formatSince .Since}} - {{else if .Reason}} - {{.Reason}} + {{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}} @@ -923,21 +939,6 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; } {{end}} - -{{if .CompressorPowerW}} -
- Kompressor: {{formatW .CompressorPowerW}} -
-{{end}} - -{{if not .MonitorOnly}} -
-
- -
-
-{{end}} - {{if .Trip.Active}}
diff --git a/main.go b/main.go index dce1432..f291e90 100644 --- a/main.go +++ b/main.go @@ -136,7 +136,7 @@ func main() { // Status store (shared between HTTP handler and control loop) wwConfigured := cfg.Viessmann.InstallationID != "" - statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, monitorMode, wwBoostDisabled, tripMgr, carOptions) + statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, cfg.CarCharging, monitorMode, wwBoostDisabled, tripMgr, carOptions) // Metrics HTTP server mux := http.NewServeMux()