diff --git a/configs/ems-config.yaml b/configs/ems-config.yaml index 4a91cef..f592b09 100644 --- a/configs/ems-config.yaml +++ b/configs/ems-config.yaml @@ -112,3 +112,14 @@ ems: override_timeout: "1h" # default lockout when EMS detects external Shelly change override_max_import_w: 800 # cancel override immediately if importing more than this (0 = disabled) monitor_only_file: "/etc/ems/monitor-only" # presence of this file = monitor-only mode active + trip_goal_file: "/var/lib/ems/trip-goal.json" # persisted active trip goal + session_log_file: "/var/lib/ems/sessions.jsonl" # JSONL log of completed charge sessions + +# Known car profiles for trip mode +cars: + mini: + name: "Mini Cooper SE" + battery_kwh: 50.0 + bmw: + name: "BMW ix2 eDrive20" + battery_kwh: 63.0 diff --git a/internal/config/config.go b/internal/config/config.go index d8bd49b..ad04bcc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,17 +10,24 @@ import ( // Config is the top-level EMS configuration. type Config struct { - Prometheus PrometheusConfig `yaml:"prometheus"` - Shelly ShellyConfig `yaml:"shelly"` - Viessmann ViessmannConfig `yaml:"viessmann"` - SOC SOCThresholds `yaml:"soc_thresholds"` - Hysteresis HysteresisConfig `yaml:"hysteresis"` - Thresholds PowerThresholds `yaml:"thresholds"` - Consumers ConsumersConfig `yaml:"consumers"` - Strategic StrategicConfig `yaml:"strategic"` - Season SeasonConfig `yaml:"season"` - Forecast ForecastConfig `yaml:"forecast"` - EMS EMSConfig `yaml:"ems"` + Prometheus PrometheusConfig `yaml:"prometheus"` + Shelly ShellyConfig `yaml:"shelly"` + Viessmann ViessmannConfig `yaml:"viessmann"` + SOC SOCThresholds `yaml:"soc_thresholds"` + Hysteresis HysteresisConfig `yaml:"hysteresis"` + Thresholds PowerThresholds `yaml:"thresholds"` + Consumers ConsumersConfig `yaml:"consumers"` + Strategic StrategicConfig `yaml:"strategic"` + Season SeasonConfig `yaml:"season"` + Forecast ForecastConfig `yaml:"forecast"` + Cars map[string]CarProfile `yaml:"cars"` + EMS EMSConfig `yaml:"ems"` +} + +// CarProfile holds the display name and battery capacity of a known vehicle. +type CarProfile struct { + Name string `yaml:"name"` + BatteryKWh float64 `yaml:"battery_kwh"` } // PrometheusConfig holds Prometheus connection settings. @@ -160,6 +167,8 @@ type EMSConfig struct { OverrideTimeout string `yaml:"override_timeout"` OverrideMaxImportW float64 `yaml:"override_max_import_w"` // cancel override if grid import exceeds this (0 = disabled) MonitorOnlyFile string `yaml:"monitor_only_file"` // flag file path: presence = monitor-only mode active + TripGoalFile string `yaml:"trip_goal_file"` // persisted active trip goal + SessionLogFile string `yaml:"session_log_file"` // JSONL log of completed charge sessions } func (e *EMSConfig) PollIntervalParsed() time.Duration { diff --git a/internal/status/status.go b/internal/status/status.go index c4f13e7..906392e 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -12,6 +12,7 @@ import ( "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. @@ -40,6 +41,13 @@ type consumerRecord struct { 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 @@ -52,10 +60,12 @@ type Store struct { 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) *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{} @@ -66,6 +76,8 @@ func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, strategic: strategic, consumers: consumers, monitorMode: mode, + tripMgr: tm, + carOptions: cars, } } @@ -147,6 +159,20 @@ type chargingAdviceView struct { 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 @@ -164,6 +190,8 @@ type pageData struct { Forecast forecastView ChargingAdvice chargingAdviceView Consumers []consumerView + Trip tripView + CarOptions []CarOption } // SyncConsumerStates updates active flags for all consumers directly from the @@ -232,6 +260,7 @@ func (s *Store) Handler() http.HandlerFunc { } 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 @@ -273,7 +302,6 @@ func (s *Store) Handler() http.HandlerFunc { // 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 { @@ -317,6 +345,12 @@ func (s *Store) Handler() http.HandlerFunc { } 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) @@ -324,6 +358,74 @@ func (s *Store) Handler() http.HandlerFunc { } } +// 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 @@ -395,6 +497,47 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; } } .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; @@ -770,5 +913,52 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; } {{end}} +{{if .Trip.Active}} +