User enters car SOC + departure deadline; EMS computes when to start charging and activates the wallbox at the right time (grid import OK for trip mode). Charging rate is learned from completed PM sessions, improving over time. Car profiles (Mini Cooper SE 50 kWh, BMW ix2 63 kWh) are config-driven and selectable per trip. - internal/trip/trip.go: Goal/Session types, Manager with per-cycle Tick() that accumulates energy, detects session completion, learns avg charge rate from JSONL session log - internal/config: CarProfile + Cars map, TripGoalFile, SessionLogFile - internal/status: trip card (active goal) + trip form (set new goal), CarOption list, formatDay/formatDur helpers - main.go: tripMgr lifecycle, /trip + /trip/cancel HTTP handlers, runCycle integration (ShouldStartNow → ApplyOverride, auto-clear) - configs/ems-config.yaml: cars section, trip_goal_file, session_log_file Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
965 lines
26 KiB
Go
965 lines
26 KiB
Go
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
|
||
consumers map[engine.Consumer]*consumerRecord
|
||
fcResult *forecast.Result
|
||
monitorMode *monitor.Mode
|
||
tripMgr *trip.Manager
|
||
carOptions []CarOption
|
||
}
|
||
|
||
// NewStore creates a new status store.
|
||
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, mode *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,
|
||
consumers: consumers,
|
||
monitorMode: mode,
|
||
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
|
||
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 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
|
||
BatterySOC float64
|
||
GridPowerW float64
|
||
PVProductionW float64
|
||
AmbientTempC float64
|
||
CompressorPowerW float64
|
||
IsExporting bool
|
||
AbsGridW float64
|
||
HasPhaseData bool
|
||
Phases []phaseView
|
||
Forecast forecastView
|
||
ChargingAdvice chargingAdviceView
|
||
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()
|
||
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
|
||
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
|
||
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()
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 = `<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<meta http-equiv="refresh" content="30">
|
||
<title>Solar Status</title>
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: #f0fdf4;
|
||
color: #1a1a1a;
|
||
min-height: 100vh;
|
||
padding: 1.25rem 1rem 2rem;
|
||
max-width: 480px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: baseline;
|
||
margin-bottom: 1.25rem;
|
||
}
|
||
h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||
.updated { font-size: 0.75rem; color: #6b7280; }
|
||
|
||
.banner {
|
||
border-radius: 10px;
|
||
padding: 0.6rem 0.9rem;
|
||
font-size: 0.85rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
.banner.warn { background: #fef9c3; color: #854d0e; border: 1px solid #fde68a; }
|
||
.banner.error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
|
||
.banner.monitor {
|
||
background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa;
|
||
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
|
||
}
|
||
.resume-btn {
|
||
background: #fff; border: 1px solid #9a3412; color: #9a3412;
|
||
border-radius: 7px; padding: 0.25rem 0.7rem; font-size: 0.8rem;
|
||
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;
|
||
}
|
||
.monitor-toggle-btn:hover { border-color: #9ca3af; color: #6b7280; }
|
||
|
||
/* Trip mode card */
|
||
.trip-card {
|
||
background: #f0fdf4; border: 1px solid #86efac;
|
||
border-radius: 14px; padding: 0.85rem 1rem; margin-bottom: 1.25rem;
|
||
}
|
||
.trip-card.tight { background: #fff7ed; border-color: #fed7aa; }
|
||
.trip-header {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.trip-title { font-size: 0.78rem; font-weight: 700; color: #15803d; text-transform: uppercase; letter-spacing: 0.05em; }
|
||
.trip-card.tight .trip-title { color: #9a3412; }
|
||
.trip-cancel { background: none; border: 1px solid #d1d5db; color: #9ca3af; border-radius: 7px; padding: 0.2rem 0.55rem; font-size: 0.75rem; cursor: pointer; }
|
||
.trip-cancel:hover { border-color: #9ca3af; color: #6b7280; }
|
||
.trip-car { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.2rem; }
|
||
.trip-detail { font-size: 0.78rem; color: #6b7280; }
|
||
.trip-timing { font-size: 0.85rem; font-weight: 600; color: #15803d; margin-top: 0.35rem; }
|
||
.trip-card.tight .trip-timing { color: #9a3412; }
|
||
.trip-deadline { font-size: 0.78rem; color: #6b7280; margin-top: 0.1rem; }
|
||
|
||
/* Trip form */
|
||
.trip-form-card {
|
||
background: #fff; border-radius: 14px; padding: 0.85rem 1rem;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.07); margin-top: 1.25rem;
|
||
}
|
||
.trip-form { display: flex; flex-direction: column; gap: 0.6rem; margin-top: 0.5rem; }
|
||
.trip-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||
.trip-select, .trip-number, .trip-datetime {
|
||
border: 1px solid #d1d5db; border-radius: 8px; padding: 0.35rem 0.5rem;
|
||
font-size: 0.82rem; background: #f9fafb; color: #374151;
|
||
}
|
||
.trip-select { flex: 1; min-width: 0; }
|
||
.trip-number { width: 4.5rem; }
|
||
.trip-datetime { flex: 1; min-width: 0; }
|
||
.trip-label { font-size: 0.75rem; color: #6b7280; white-space: nowrap; }
|
||
.trip-submit {
|
||
background: #16a34a; color: #fff; border: none; border-radius: 8px;
|
||
padding: 0.45rem 1.2rem; font-size: 0.85rem; font-weight: 700; cursor: pointer; align-self: flex-end;
|
||
}
|
||
.trip-submit:active { opacity: 0.8; }
|
||
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 0.75rem;
|
||
margin-bottom: 1.25rem;
|
||
}
|
||
|
||
.card {
|
||
background: #ffffff;
|
||
border-radius: 14px;
|
||
padding: 1rem;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.07);
|
||
}
|
||
.card-label {
|
||
font-size: 0.7rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: #9ca3af;
|
||
margin-bottom: 0.4rem;
|
||
}
|
||
.card-value {
|
||
font-size: 1.8rem;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
}
|
||
.card-sub {
|
||
font-size: 0.78rem;
|
||
color: #6b7280;
|
||
margin-top: 0.3rem;
|
||
}
|
||
|
||
/* SOC bar */
|
||
.soc-bar {
|
||
background: #e5e7eb;
|
||
border-radius: 999px;
|
||
height: 8px;
|
||
margin-top: 0.6rem;
|
||
overflow: hidden;
|
||
}
|
||
.soc-fill {
|
||
height: 100%;
|
||
border-radius: 999px;
|
||
transition: width 0.4s ease;
|
||
}
|
||
|
||
/* Forecast card — full width */
|
||
.card.full { grid-column: 1 / -1; }
|
||
.forecast-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.5rem;
|
||
}
|
||
.forecast-icon { font-size: 1.4rem; line-height: 1; }
|
||
.forecast-quality { font-size: 0.85rem; color: #6b7280; margin-top: 0.2rem; }
|
||
|
||
/* Grid card direction arrow */
|
||
.arrow { font-size: 1.1rem; margin-right: 0.1rem; }
|
||
|
||
/* Phase grid */
|
||
.phase-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 0.5rem;
|
||
margin-top: 0.75rem;
|
||
}
|
||
.phase-item {
|
||
text-align: center;
|
||
}
|
||
.phase-label {
|
||
font-size: 0.65rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: #9ca3af;
|
||
}
|
||
.phase-value {
|
||
font-size: 0.9rem;
|
||
font-weight: 700;
|
||
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;
|
||
font-weight: 600;
|
||
background: #f0fdf4;
|
||
color: #15803d;
|
||
border: 1px solid #bbf7d0;
|
||
border-radius: 6px;
|
||
padding: 0.15rem 0.45rem;
|
||
flex-shrink: 0;
|
||
margin-right: 0.25rem;
|
||
}
|
||
|
||
/* Priority badge */
|
||
.prio-badge {
|
||
font-size: 0.75rem;
|
||
color: #9ca3af;
|
||
margin-right: 0.35rem;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
/* Consumer list */
|
||
.section-title {
|
||
font-size: 0.7rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: #9ca3af;
|
||
margin-bottom: 0.6rem;
|
||
}
|
||
|
||
.consumer {
|
||
background: #ffffff;
|
||
border-radius: 14px;
|
||
padding: 0.85rem 1rem;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.07);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.85rem;
|
||
margin-bottom: 0.6rem;
|
||
}
|
||
.consumer:last-child { margin-bottom: 0; }
|
||
.consumer.unconfigured { opacity: 0.45; }
|
||
|
||
.indicator {
|
||
width: 13px;
|
||
height: 13px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
.indicator.on {
|
||
background: #22c55e;
|
||
box-shadow: 0 0 0 3px #bbf7d0;
|
||
}
|
||
.indicator.off { background: #d1d5db; }
|
||
.indicator.override {
|
||
background: #f59e0b;
|
||
box-shadow: 0 0 0 3px #fde68a;
|
||
}
|
||
|
||
.consumer-icon { font-size: 1.2rem; flex-shrink: 0; }
|
||
.consumer-body { flex: 1; min-width: 0; }
|
||
.consumer-name { font-weight: 600; font-size: 0.9rem; }
|
||
.consumer-detail {
|
||
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; }
|
||
|
||
.dur-select {
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 8px;
|
||
padding: 0.3rem 0.4rem;
|
||
font-size: 0.75rem;
|
||
background: #f9fafb;
|
||
color: #374151;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.override-btn {
|
||
border: none;
|
||
border-radius: 8px;
|
||
padding: 0.35rem 0.75rem;
|
||
font-size: 0.78rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
transition: opacity 0.15s;
|
||
}
|
||
.override-btn:active { opacity: 0.7; }
|
||
.override-btn.turn-on { background: #dcfce7; color: #15803d; }
|
||
.override-btn.turn-off { background: #fee2e2; color: #b91c1c; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<h1>☀️ Solar Status</h1>
|
||
{{if not .LastUpdate.IsZero}}
|
||
<span class="updated">{{.LastUpdate.Format "15:04:05"}}</span>
|
||
{{end}}
|
||
</header>
|
||
|
||
{{if .MonitorOnly}}
|
||
<div class="banner monitor">
|
||
<span>⏸ Monitor-Only — Keine Schaltvorgänge</span>
|
||
<form method="post" action="/monitor">
|
||
<button type="submit" class="resume-btn">▶ Automatik</button>
|
||
</form>
|
||
</div>
|
||
{{else if .DryRun}}
|
||
<div class="banner warn">⚠️ Testmodus — keine echten Schaltvorgänge</div>
|
||
{{end}}
|
||
{{if .ErrMsg}}
|
||
<div class="banner error">⚠️ {{.ErrMsg}}</div>
|
||
{{end}}
|
||
|
||
<div class="grid">
|
||
|
||
<!-- Battery -->
|
||
<div class="card">
|
||
<div class="card-label">Batterie</div>
|
||
<div class="card-value" style="color: {{socColor .BatterySOC}}">
|
||
{{printf "%.0f" .BatterySOC}}<span style="font-size:1rem;font-weight:400"> %</span>
|
||
</div>
|
||
<div class="soc-bar">
|
||
<div class="soc-fill" style="width:{{printf "%.0f" .BatterySOC}}%; background:{{socColor .BatterySOC}}"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Grid -->
|
||
<div class="card">
|
||
<div class="card-label">Netz</div>
|
||
{{if .IsExporting}}
|
||
<div class="card-value" style="color:#16a34a">
|
||
<span class="arrow">↑</span>{{formatW .AbsGridW}}
|
||
</div>
|
||
<div class="card-sub">Einspeisung</div>
|
||
{{else}}
|
||
<div class="card-value" style="color:#dc2626">
|
||
<span class="arrow">↓</span>{{formatW .AbsGridW}}
|
||
</div>
|
||
<div class="card-sub">Bezug</div>
|
||
{{end}}
|
||
</div>
|
||
|
||
<!-- PV -->
|
||
<div class="card">
|
||
<div class="card-label">PV-Leistung</div>
|
||
<div class="card-value" style="color:#d97706">{{formatW .PVProductionW}}</div>
|
||
</div>
|
||
|
||
<!-- Temperature -->
|
||
<div class="card">
|
||
<div class="card-label">Außentemperatur</div>
|
||
<div class="card-value">{{printf "%.1f" .AmbientTempC}}<span style="font-size:1rem;font-weight:400"> °C</span></div>
|
||
</div>
|
||
|
||
<!-- Phase power (full width, only if data available) -->
|
||
{{if .HasPhaseData}}
|
||
<div class="card full">
|
||
<div class="card-label">Phasen (Netz)</div>
|
||
<div class="phase-grid">
|
||
{{range .Phases}}
|
||
<div class="phase-item">
|
||
<div class="phase-label">{{.Label}}</div>
|
||
<div class="phase-value" style="color:{{if .IsExport}}#16a34a{{else}}#dc2626{{end}}">
|
||
{{if .IsExport}}↑{{else}}↓{{end}}{{formatW .AbsPowerW}}
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
|
||
<!-- Forecast (full width) -->
|
||
{{if .Forecast.Available}}
|
||
<div class="card full">
|
||
<div class="card-label">Prognose heute</div>
|
||
<div class="forecast-row">
|
||
<span class="forecast-icon">{{.Forecast.Icon}}</span>
|
||
<span class="card-value" style="color:#d97706">{{printf "%.1f" .Forecast.KWh}} kWh</span>
|
||
</div>
|
||
<div class="forecast-quality">{{.Forecast.Quality}}</div>
|
||
</div>
|
||
{{end}}
|
||
|
||
</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>
|
||
|
||
{{range .Consumers}}
|
||
<div class="consumer{{if .Unconfigured}} unconfigured{{end}}">
|
||
<div class="indicator {{if .ManualOverride}}override{{else if .Active}}on{{else}}off{{end}}"></div>
|
||
<div class="consumer-icon">{{.Icon}}</div>
|
||
<div class="consumer-body">
|
||
<div class="consumer-name">
|
||
<span class="prio-badge">{{.Priority}}</span>{{.Label}}
|
||
</div>
|
||
<div class="consumer-detail{{if and .Active (not .ManualOverride)}} active{{end}}{{if .ManualOverride}} override{{end}}">
|
||
{{if .Unconfigured}}
|
||
nicht konfiguriert
|
||
{{else if .ManualOverride}}
|
||
Manuell bis {{.OverrideUntil.Format "15:04"}}
|
||
{{else if .Active}}
|
||
{{formatSince .Since}}
|
||
{{else if .Reason}}
|
||
{{.Reason}}
|
||
{{else}}
|
||
Ausgeschaltet
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
{{if and .Active .LivePowerW}}
|
||
<span class="power-badge">{{formatW .LivePowerW}}</span>
|
||
{{end}}
|
||
{{if .CanOverride}}
|
||
<form method="post" action="/override" style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0">
|
||
<input type="hidden" name="consumer" value="{{.ConsumerKey}}">
|
||
{{if .Active}}
|
||
<input type="hidden" name="state" value="off">
|
||
<input type="hidden" name="duration" value="1h">
|
||
<button type="submit" class="override-btn turn-off">Aus</button>
|
||
{{else}}
|
||
<input type="hidden" name="state" value="on">
|
||
<select name="duration" class="dur-select">
|
||
<option value="30m">30 min</option>
|
||
<option value="1h" selected>1 Std</option>
|
||
<option value="2h">2 Std</option>
|
||
<option value="4h">4 Std</option>
|
||
</select>
|
||
<button type="submit" class="override-btn turn-on">Ein</button>
|
||
{{end}}
|
||
</form>
|
||
{{end}}
|
||
</div>
|
||
{{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}}
|
||
<div class="trip-card{{if .Trip.IsTight}} tight{{end}}">
|
||
<div class="trip-header">
|
||
<span class="trip-title">🚗 Fahrt geplant</span>
|
||
<form method="post" action="/trip/cancel">
|
||
<button type="submit" class="trip-cancel">Abbrechen</button>
|
||
</form>
|
||
</div>
|
||
<div class="trip-car">{{.Trip.CarName}} · {{.Trip.WallboxLabel}}</div>
|
||
<div class="trip-detail">Ladebedarf: {{printf "%.1f" .Trip.EnergyKWh}} kWh ({{printf "%.0f" .Trip.CurrentSOC}}% → 100%) · ca. {{.Trip.DurationStr}}</div>
|
||
{{if .Trip.ChargingStarted}}
|
||
<div class="trip-timing">⚡ Lädt seit {{.Trip.ChargingSince}}</div>
|
||
{{else if .Trip.IsTight}}
|
||
<div class="trip-timing">⚠ Zeitfenster knapp — sofort einstecken!</div>
|
||
{{else}}
|
||
<div class="trip-timing">Laden startet: {{.Trip.StartsAtStr}}</div>
|
||
{{end}}
|
||
<div class="trip-deadline">Bereit bis: {{.Trip.DeadlineStr}}</div>
|
||
</div>
|
||
{{else if .CarOptions}}
|
||
<div class="trip-form-card">
|
||
<div class="section-title">🚗 Fahrtziel planen</div>
|
||
<form method="post" action="/trip" class="trip-form">
|
||
<div class="trip-row">
|
||
<select name="wallbox" class="trip-select">
|
||
<option value="wallbox_a">Wallbox A</option>
|
||
<option value="wallbox_b">Wallbox B</option>
|
||
</select>
|
||
<select name="car" class="trip-select">
|
||
{{range .CarOptions}}
|
||
<option value="{{.Key}}">{{.Name}} ({{.BatteryKWh}} kWh)</option>
|
||
{{end}}
|
||
</select>
|
||
</div>
|
||
<div class="trip-row">
|
||
<span class="trip-label">Ladestand jetzt</span>
|
||
<input type="number" name="current_soc" min="1" max="99" value="50" class="trip-number"> %
|
||
</div>
|
||
<div class="trip-row">
|
||
<span class="trip-label">Bereit bis</span>
|
||
<input type="datetime-local" name="deadline" class="trip-datetime" required>
|
||
</div>
|
||
<button type="submit" class="trip-submit">Planen</button>
|
||
</form>
|
||
</div>
|
||
{{end}}
|
||
|
||
</body>
|
||
</html>`
|