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}}
+