Improve status page ergonomics
- WW card shows current tank temperature (Speicher: 48.7°C) - SG-Ready card shows inline compressor power (Kompressor: 1.5 kW / Standby) - Consumer reason text now wraps instead of being silently truncated - Add readiness hints on inactive wallboxes: explains what's blocking activation (SOC too low / forecast insufficient / waiting for PV ≥ Xw) - Add WW readiness hint when forecast is below threshold - Move Monitor-Only toggle button to header (next to timestamp) for symmetric placement with the resume button in the monitor banner - Remove charging advice card (outdated for proactive charging logic) - Remove compressor power footnote from page bottom (now inline in card) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,7 @@ type Store struct {
|
|||||||
dryRun bool
|
dryRun bool
|
||||||
wwConfigured bool
|
wwConfigured bool
|
||||||
strategic config.StrategicConfig
|
strategic config.StrategicConfig
|
||||||
|
carCharging config.CarChargingConfig
|
||||||
consumers map[engine.Consumer]*consumerRecord
|
consumers map[engine.Consumer]*consumerRecord
|
||||||
fcResult *forecast.Result
|
fcResult *forecast.Result
|
||||||
monitorMode *monitor.Mode
|
monitorMode *monitor.Mode
|
||||||
@@ -66,7 +67,7 @@ type Store struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewStore creates a new status store.
|
// 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))
|
consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder))
|
||||||
for _, c := range consumerOrder {
|
for _, c := range consumerOrder {
|
||||||
consumers[c] = &consumerRecord{}
|
consumers[c] = &consumerRecord{}
|
||||||
@@ -75,6 +76,7 @@ func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig,
|
|||||||
dryRun: dryRun,
|
dryRun: dryRun,
|
||||||
wwConfigured: wwConfigured,
|
wwConfigured: wwConfigured,
|
||||||
strategic: strategic,
|
strategic: strategic,
|
||||||
|
carCharging: carCharging,
|
||||||
consumers: consumers,
|
consumers: consumers,
|
||||||
monitorMode: mode,
|
monitorMode: mode,
|
||||||
wwBoostDisabled: wwBoostDisabled,
|
wwBoostDisabled: wwBoostDisabled,
|
||||||
@@ -133,6 +135,8 @@ type consumerView struct {
|
|||||||
Active bool
|
Active bool
|
||||||
Since time.Time
|
Since time.Time
|
||||||
Reason string
|
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
|
Unconfigured bool
|
||||||
CanOverride bool // true for Shelly consumers (hardware read-back available)
|
CanOverride bool // true for Shelly consumers (hardware read-back available)
|
||||||
IsWW bool // true for ConsumerWW — shows reset button instead of override
|
IsWW bool // true for ConsumerWW — shows reset button instead of override
|
||||||
@@ -156,13 +160,6 @@ type forecastView struct {
|
|||||||
Quality 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 tripView struct {
|
type tripView struct {
|
||||||
Active bool
|
Active bool
|
||||||
CarName string
|
CarName string
|
||||||
@@ -188,12 +185,12 @@ type pageData struct {
|
|||||||
PVProductionW float64
|
PVProductionW float64
|
||||||
AmbientTempC float64
|
AmbientTempC float64
|
||||||
CompressorPowerW float64
|
CompressorPowerW float64
|
||||||
|
WWTopTempC float64
|
||||||
IsExporting bool
|
IsExporting bool
|
||||||
AbsGridW float64
|
AbsGridW float64
|
||||||
HasPhaseData bool
|
HasPhaseData bool
|
||||||
Phases []phaseView
|
Phases []phaseView
|
||||||
Forecast forecastView
|
Forecast forecastView
|
||||||
ChargingAdvice chargingAdviceView
|
|
||||||
Consumers []consumerView
|
Consumers []consumerView
|
||||||
Trip tripView
|
Trip tripView
|
||||||
CarOptions []CarOption
|
CarOptions []CarOption
|
||||||
@@ -271,6 +268,10 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
l1, l2, l3 := s.state.PhaseL1PowerW, s.state.PhaseL2PowerW, s.state.PhaseL3PowerW
|
l1, l2, l3 := s.state.PhaseL1PowerW, s.state.PhaseL2PowerW, s.state.PhaseL3PowerW
|
||||||
hasPhase := l1 != 0 || l2 != 0 || l3 != 0
|
hasPhase := l1 != 0 || l2 != 0 || l3 != 0
|
||||||
|
forecastKWh := 0.0
|
||||||
|
if s.fcResult != nil {
|
||||||
|
forecastKWh = s.fcResult.TotalKWh
|
||||||
|
}
|
||||||
data := pageData{
|
data := pageData{
|
||||||
LastUpdate: s.lastUpdate,
|
LastUpdate: s.lastUpdate,
|
||||||
ErrMsg: s.errMsg,
|
ErrMsg: s.errMsg,
|
||||||
@@ -282,12 +283,16 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
PVProductionW: s.state.PVProductionW,
|
PVProductionW: s.state.PVProductionW,
|
||||||
AmbientTempC: s.state.AmbientTempC,
|
AmbientTempC: s.state.AmbientTempC,
|
||||||
CompressorPowerW: s.state.CompressorPowerW,
|
CompressorPowerW: s.state.CompressorPowerW,
|
||||||
|
WWTopTempC: s.state.WWTopTempC,
|
||||||
IsExporting: s.state.GridPowerW < 0,
|
IsExporting: s.state.GridPowerW < 0,
|
||||||
AbsGridW: abs(s.state.GridPowerW),
|
AbsGridW: abs(s.state.GridPowerW),
|
||||||
HasPhaseData: hasPhase,
|
HasPhaseData: hasPhase,
|
||||||
}
|
}
|
||||||
if 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},
|
{"L1", l1}, {"L2", l2}, {"L3", l3},
|
||||||
} {
|
} {
|
||||||
data.Phases = append(data.Phases, phaseView{
|
data.Phases = append(data.Phases, phaseView{
|
||||||
@@ -305,24 +310,6 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
Icon: s.fcResult.QualityIcon(s.strategic),
|
Icon: s.fcResult.QualityIcon(s.strategic),
|
||||||
Quality: s.fcResult.Quality(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{"①", "②", "③", "④"}
|
prioritySymbols := []string{"①", "②", "③", "④"}
|
||||||
for i, c := range consumerOrder {
|
for i, c := range consumerOrder {
|
||||||
@@ -347,6 +334,23 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
OverrideUntil: rec.overrideUntil,
|
OverrideUntil: rec.overrideUntil,
|
||||||
LivePowerW: rec.livePowerW,
|
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 {
|
if c == engine.ConsumerWW && !s.wwConfigured {
|
||||||
cv.Unconfigured = true
|
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.
|
// buildTripView constructs the trip card data for the current cycle.
|
||||||
func buildTripView(tm *trip.Manager, now time.Time) tripView {
|
func buildTripView(tm *trip.Manager, now time.Time) tripView {
|
||||||
goal := tm.ActiveGoal()
|
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;
|
font-weight: 700; cursor: pointer; white-space: nowrap; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.resume-btn:active { opacity: 0.7; }
|
.resume-btn:active { opacity: 0.7; }
|
||||||
.monitor-toggle {
|
|
||||||
text-align: center; margin-top: 1.5rem; padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
.monitor-toggle-btn {
|
.monitor-toggle-btn {
|
||||||
background: none; border: 1px solid #d1d5db; color: #9ca3af;
|
background: none; border: 1px solid #d1d5db; color: #9ca3af;
|
||||||
border-radius: 8px; padding: 0.35rem 1rem; font-size: 0.75rem;
|
border-radius: 8px; padding: 0.25rem 0.75rem; font-size: 0.72rem;
|
||||||
cursor: pointer;
|
cursor: pointer; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.monitor-toggle-btn:hover { border-color: #9ca3af; color: #6b7280; }
|
.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;
|
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 */
|
/* Live power badge on consumers */
|
||||||
.power-badge {
|
.power-badge {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
@@ -720,12 +729,20 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
.consumer-detail.active { color: #16a34a; font-weight: 500; }
|
.consumer-detail.active { color: #16a34a; font-weight: 500; }
|
||||||
.consumer-detail.override { color: #d97706; 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 {
|
.dur-select {
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
@@ -757,9 +774,16 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
|
|
||||||
<header>
|
<header>
|
||||||
<h1>☀️ Solar Status</h1>
|
<h1>☀️ Solar Status</h1>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.6rem">
|
||||||
{{if not .LastUpdate.IsZero}}
|
{{if not .LastUpdate.IsZero}}
|
||||||
<span class="updated">{{.LastUpdate.Format "15:04:05"}}</span>
|
<span class="updated">{{.LastUpdate.Format "15:04:05"}}</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if not .MonitorOnly}}
|
||||||
|
<form method="post" action="/monitor">
|
||||||
|
<button type="submit" class="monitor-toggle-btn">⏸ Monitor</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{{if .MonitorOnly}}
|
{{if .MonitorOnly}}
|
||||||
@@ -848,16 +872,6 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .ChargingAdvice.Show}}
|
|
||||||
<div class="advice-card">
|
|
||||||
<div class="advice-title">🔌 Ladeempfehlung</div>
|
|
||||||
<div class="advice-main">Auto einstecken bis {{.ChargingAdvice.PlugInBy}} Uhr</div>
|
|
||||||
<div class="advice-sub">
|
|
||||||
Erwartetes Überschuss-Fenster: {{.ChargingAdvice.WindowStart}} – {{.ChargingAdvice.WindowEnd}} Uhr
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<div class="section-title">Verbraucher — Priorität ↓</div>
|
<div class="section-title">Verbraucher — Priorität ↓</div>
|
||||||
|
|
||||||
{{range .Consumers}}
|
{{range .Consumers}}
|
||||||
@@ -874,13 +888,15 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
{{else if .ManualOverride}}
|
{{else if .ManualOverride}}
|
||||||
Manuell bis {{.OverrideUntil.Format "15:04"}}
|
Manuell bis {{.OverrideUntil.Format "15:04"}}
|
||||||
{{else if .Active}}
|
{{else if .Active}}
|
||||||
{{formatSince .Since}}
|
{{formatSince .Since}}{{if .Reason}} — {{.Reason}}{{end}}
|
||||||
{{else if .Reason}}
|
|
||||||
{{.Reason}}
|
|
||||||
{{else}}
|
{{else}}
|
||||||
Ausgeschaltet
|
Ausgeschaltet
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
{{if .SubDetail}}<div class="consumer-sub">{{.SubDetail}}</div>{{end}}
|
||||||
|
{{if and (not .Active) (not .ManualOverride) .ReadinessHint}}
|
||||||
|
<div class="consumer-hint">{{.ReadinessHint}}</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{if and .Active .LivePowerW}}
|
{{if and .Active .LivePowerW}}
|
||||||
<span class="power-badge">{{formatW .LivePowerW}}</span>
|
<span class="power-badge">{{formatW .LivePowerW}}</span>
|
||||||
@@ -923,21 +939,6 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- Compressor status (shown when SG-Ready context is relevant) -->
|
|
||||||
{{if .CompressorPowerW}}
|
|
||||||
<div style="margin-top:0.5rem;font-size:0.75rem;color:#9ca3af;text-align:right">
|
|
||||||
Kompressor: {{formatW .CompressorPowerW}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if not .MonitorOnly}}
|
|
||||||
<div class="monitor-toggle">
|
|
||||||
<form method="post" action="/monitor">
|
|
||||||
<button type="submit" class="monitor-toggle-btn">⏸ Monitor-Only</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if .Trip.Active}}
|
{{if .Trip.Active}}
|
||||||
<div class="trip-card{{if .Trip.IsTight}} tight{{end}}">
|
<div class="trip-card{{if .Trip.IsTight}} tight{{end}}">
|
||||||
<div class="trip-header">
|
<div class="trip-header">
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -136,7 +136,7 @@ func main() {
|
|||||||
|
|
||||||
// Status store (shared between HTTP handler and control loop)
|
// Status store (shared between HTTP handler and control loop)
|
||||||
wwConfigured := cfg.Viessmann.InstallationID != ""
|
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
|
// Metrics HTTP server
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|||||||
Reference in New Issue
Block a user