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}} +
+
+ 🚗 Fahrt geplant +
+ +
+
+
{{.Trip.CarName}} · {{.Trip.WallboxLabel}}
+
Ladebedarf: {{printf "%.1f" .Trip.EnergyKWh}} kWh  ({{printf "%.0f" .Trip.CurrentSOC}}% → 100%) · ca. {{.Trip.DurationStr}}
+ {{if .Trip.ChargingStarted}} +
⚡ Lädt seit {{.Trip.ChargingSince}}
+ {{else if .Trip.IsTight}} +
⚠ Zeitfenster knapp — sofort einstecken!
+ {{else}} +
Laden startet: {{.Trip.StartsAtStr}}
+ {{end}} +
Bereit bis: {{.Trip.DeadlineStr}}
+
+{{else if .CarOptions}} +
+
🚗 Fahrtziel planen
+
+
+ + +
+
+ Ladestand jetzt + % +
+
+ Bereit bis + +
+ +
+
+{{end}} + ` diff --git a/internal/trip/trip.go b/internal/trip/trip.go new file mode 100644 index 0000000..32524a5 --- /dev/null +++ b/internal/trip/trip.go @@ -0,0 +1,297 @@ +// Package trip manages trip-mode charging goals and session learning. +// A Goal records the user's intent ("car ready by HH:MM") and is persisted +// across restarts. Completed sessions are appended to a JSONL log so the +// charge rate estimate improves over time. +package trip + +import ( + "bufio" + "encoding/json" + "math" + "os" + "path/filepath" + "sync" + "time" +) + +const ( + sessionHistory = 10 // sessions used for rate averaging + minSessionsForLearning = 3 // below this: fall back to rated power + chargingBuffer = 15 * time.Minute + minSessionKWh = 0.1 // ignore sessions shorter than this + minSessionDuration = 5 * time.Minute +) + +// Goal is a user-entered trip charging goal, persisted to disk. +type Goal struct { + Wallbox string `json:"wallbox"` // "wallbox_a" or "wallbox_b" + CarName string `json:"car_name"` // display label + BatteryKWh float64 `json:"battery_kwh"` + CurrentSOC float64 `json:"current_soc"` // % at time of entry + Deadline time.Time `json:"deadline"` // car must be ready by this time + CreatedAt time.Time `json:"created_at"` + ChargingStartedAt time.Time `json:"charging_started_at,omitempty"` // set when trip mode activates the wallbox +} + +// EnergyNeededKWh returns the energy required to charge from CurrentSOC to 100%. +func (g *Goal) EnergyNeededKWh() float64 { + return (100.0 - g.CurrentSOC) / 100.0 * g.BatteryKWh +} + +// ChargeDuration estimates the time required at the given charge rate. +func (g *Goal) ChargeDuration(rateKW float64) time.Duration { + hours := g.EnergyNeededKWh() / rateKW + return time.Duration(hours * float64(time.Hour)) +} + +// StartTime returns when charging must begin to meet the deadline (including buffer). +func (g *Goal) StartTime(rateKW float64) time.Time { + return g.Deadline.Add(-(g.ChargeDuration(rateKW) + chargingBuffer)) +} + +// ShouldStartNow returns true if charging should begin immediately. +func (g *Goal) ShouldStartNow(now time.Time, rateKW float64) bool { + return !now.Before(g.StartTime(rateKW)) +} + +// IsTight returns true if the start time has already passed (deadline at risk). +func (g *Goal) IsTight(now time.Time, rateKW float64) bool { + return g.StartTime(rateKW).Before(now) && g.ChargingStartedAt.IsZero() +} + +// Session records a completed charging session for rate learning. +type Session struct { + Timestamp time.Time `json:"ts"` + Wallbox string `json:"wallbox"` + DurationMin float64 `json:"duration_min"` + KWhDelivered float64 `json:"kwh_delivered"` + AvgKW float64 `json:"avg_kw"` +} + +// Manager coordinates trip goals, session energy accumulation, and rate learning. +// All methods are safe for concurrent use (HTTP handler vs control loop). +type Manager struct { + mu sync.Mutex + goal *Goal + goalFile string + sessionFile string + + // per-wallbox energy accumulation for the in-progress charging session + accEnergy map[string]float64 + accStart map[string]time.Time + + // previous active state per wallbox — used to detect active→inactive transitions + prevActive map[string]bool +} + +// New creates a Manager and loads any persisted goal from disk. +func New(goalFile, sessionFile string) *Manager { + m := &Manager{ + goalFile: goalFile, + sessionFile: sessionFile, + accEnergy: make(map[string]float64), + accStart: make(map[string]time.Time), + prevActive: make(map[string]bool), + } + m.loadGoal() + return m +} + +func (m *Manager) loadGoal() { + if m.goalFile == "" { + return + } + data, err := os.ReadFile(m.goalFile) + if err != nil { + return // no file = no active goal + } + var g Goal + if json.Unmarshal(data, &g) == nil { + m.goal = &g + } +} + +// ActiveGoal returns a snapshot of the current goal, or nil if none is set. +func (m *Manager) ActiveGoal() *Goal { + m.mu.Lock() + defer m.mu.Unlock() + if m.goal == nil { + return nil + } + g := *m.goal + return &g +} + +// SetGoal persists and activates a new trip goal. +func (m *Manager) SetGoal(g Goal) error { + m.mu.Lock() + defer m.mu.Unlock() + m.goal = &g + return m.saveGoalLocked() +} + +// ClearGoal removes the active trip goal from memory and disk. +func (m *Manager) ClearGoal() error { + m.mu.Lock() + defer m.mu.Unlock() + m.goal = nil + if m.goalFile == "" { + return nil + } + err := os.Remove(m.goalFile) + if os.IsNotExist(err) { + return nil + } + return err +} + +func (m *Manager) saveGoalLocked() error { + if m.goalFile == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(m.goalFile), 0755); err != nil { + return err + } + data, err := json.MarshalIndent(m.goal, "", " ") + if err != nil { + return err + } + return os.WriteFile(m.goalFile, data, 0644) +} + +// MarkChargingStarted records when trip-mode charging began in the persisted goal. +func (m *Manager) MarkChargingStarted(now time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.goal == nil || !m.goal.ChargingStartedAt.IsZero() { + return nil // already marked, or no goal + } + m.goal.ChargingStartedAt = now + return m.saveGoalLocked() +} + +// Tick must be called once per cycle. It: +// 1. Accumulates energy from active PM readings into the running session total. +// 2. Detects active→inactive wallbox transitions and finalises those sessions. +// +// Returns the list of wallboxes whose sessions just completed this cycle. +// The caller uses this to auto-clear a trip goal when its wallbox finishes. +func (m *Manager) Tick( + activeStates map[string]bool, + powerReadingsW map[string]float64, + intervalMin float64, +) []string { + completed := m.tickLocked(activeStates, powerReadingsW, intervalMin) + for _, wb := range completed { + m.finalizeSession(wb) + } + return completed +} + +func (m *Manager) tickLocked(activeStates map[string]bool, powerW map[string]float64, intervalMin float64) []string { + m.mu.Lock() + defer m.mu.Unlock() + + for wb, active := range activeStates { + if active { + if _, ok := m.accStart[wb]; !ok { + m.accStart[wb] = time.Now() + m.accEnergy[wb] = 0 + } + if pw := powerW[wb]; pw > 0 { + m.accEnergy[wb] += pw / 1000.0 * (intervalMin / 60.0) + } + } + } + + var completed []string + for wb, active := range activeStates { + if m.prevActive[wb] && !active { + completed = append(completed, wb) + } + m.prevActive[wb] = active + } + return completed +} + +// finalizeSession writes a completed session record to the log file. +// Called without m.mu held. +func (m *Manager) finalizeSession(wallbox string) { + m.mu.Lock() + start, ok := m.accStart[wallbox] + kwh := m.accEnergy[wallbox] + delete(m.accStart, wallbox) + delete(m.accEnergy, wallbox) + m.mu.Unlock() + + if !ok { + return + } + duration := time.Since(start) + if kwh < minSessionKWh || duration < minSessionDuration { + return // too short / too little energy — ignore + } + + s := Session{ + Timestamp: time.Now(), + Wallbox: wallbox, + DurationMin: math.Round(duration.Minutes()*10) / 10, + KWhDelivered: math.Round(kwh*100) / 100, + AvgKW: math.Round(kwh/duration.Hours()*100) / 100, + } + _ = m.appendSession(s) +} + +func (m *Manager) appendSession(s Session) error { + if m.sessionFile == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(m.sessionFile), 0755); err != nil { + return err + } + f, err := os.OpenFile(m.sessionFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + return json.NewEncoder(f).Encode(s) +} + +// LearnedRateKW returns the average charging rate (kW) from recent sessions +// for the given wallbox. Falls back to fallbackKW if fewer than +// minSessionsForLearning exist. +func (m *Manager) LearnedRateKW(wallbox string, fallbackKW float64) float64 { + sessions := m.recentSessions(wallbox, sessionHistory) + if len(sessions) < minSessionsForLearning { + return fallbackKW + } + var total float64 + for _, s := range sessions { + total += s.AvgKW + } + return total / float64(len(sessions)) +} + +func (m *Manager) recentSessions(wallbox string, n int) []Session { + if m.sessionFile == "" { + return nil + } + f, err := os.Open(m.sessionFile) + if err != nil { + return nil + } + defer f.Close() + + var matched []Session + sc := bufio.NewScanner(f) + for sc.Scan() { + var s Session + if json.Unmarshal(sc.Bytes(), &s) == nil && s.Wallbox == wallbox { + matched = append(matched, s) + } + } + if len(matched) <= n { + return matched + } + return matched[len(matched)-n:] +} diff --git a/main.go b/main.go index 06a6fe3..4302035 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,8 @@ import ( "os" "os/signal" "path/filepath" + "sort" + "strconv" "syscall" "time" @@ -22,6 +24,7 @@ import ( "github.com/tb/ems/internal/metrics" "github.com/tb/ems/internal/monitor" "github.com/tb/ems/internal/status" + "github.com/tb/ems/internal/trip" "github.com/tb/ems/internal/viessmann" ) @@ -112,9 +115,22 @@ func main() { ) } + // Trip mode manager (goal persistence + session learning) + tripMgr := trip.New(cfg.EMS.TripGoalFile, cfg.EMS.SessionLogFile) + if g := tripMgr.ActiveGoal(); g != nil { + logger.Info("trip goal loaded from disk", + "wallbox", g.Wallbox, + "car", g.CarName, + "deadline", g.Deadline.Format("02.01. 15:04"), + ) + } + + // Build sorted car option list for the status page form + carOptions := buildCarOptions(cfg.Cars) + // Status store (shared between HTTP handler and control loop) wwConfigured := cfg.Viessmann.InstallationID != "" - statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, monitorMode) + statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, monitorMode, tripMgr, carOptions) // Metrics HTTP server mux := http.NewServeMux() @@ -125,6 +141,8 @@ func main() { }) mux.HandleFunc("/override", overrideHandler(act, eng, logger)) mux.HandleFunc("/monitor", monitorHandler(monitorMode, logger)) + mux.HandleFunc("/trip", tripSetHandler(tripMgr, cfg, logger)) + mux.HandleFunc("/trip/cancel", tripCancelHandler(tripMgr, logger)) mux.HandleFunc("/", statusStore.Handler()) srv := &http.Server{ @@ -153,12 +171,12 @@ func main() { logger.Info("EMS control loop started") // Run once immediately - runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode) + runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode, tripMgr) for { select { case <-ticker.C: - runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode) + runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode, tripMgr) case sig := <-sigCh: logger.Info("received signal, shutting down", "signal", sig) @@ -185,8 +203,10 @@ func runCycle( logger *slog.Logger, dryRun bool, monitorMode *monitor.Mode, + tripMgr *trip.Manager, ) { now := time.Now() + pollMin := cfg.EMS.PollIntervalParsed().Minutes() // Step 1: Collect current state from Prometheus state, err := coll.Collect(ctx) @@ -200,10 +220,12 @@ func runCycle( writeHeartbeat(stateFile, logger) // Step 1b: Read Shelly states and sync to engine (detects manual overrides). - // Partial results are fine — unreachable devices are logged inside ReadAllStates. - if shellyStates, err := act.ReadAllStates(ctx); err != nil { + // Keep shellyStates accessible for trip mode energy accumulation. + var shellyStates map[engine.Consumer]engine.DeviceStatus + if states, err := act.ReadAllStates(ctx); err != nil { logger.Warn("all Shelly devices unreachable, skipping override detection", "error", err) } else { + shellyStates = states eng.SyncHardwareState(shellyStates, now, cfg.EMS.OverrideTimeoutParsed()) store.SyncDeviceStates(shellyStates) } @@ -213,8 +235,8 @@ func runCycle( m.BatterySOC.Set(state.BatterySOC) m.PVProductionW.Set(state.PVProductionW) - // Track energy flow (rough estimation based on 2-min intervals) - intervalHours := 2.0 / 60.0 + // Track energy flow (rough estimation based on poll interval) + intervalHours := pollMin / 60.0 if state.GridPowerW > 0 { m.GridImportKWh.Add(state.GridPowerW / 1000.0 * intervalHours) } else { @@ -233,6 +255,58 @@ func runCycle( actions := eng.Decide(state, now, wwBoostC) m.DecisionDuration.Observe(time.Since(decisionStart).Seconds()) + // Step 3b: Trip mode — session energy tracking + wallbox activation + consumerStates := eng.ConsumerStates() + wallboxActive := map[string]bool{ + engine.ConsumerWallboxA.String(): consumerStates[engine.ConsumerWallboxA], + engine.ConsumerWallboxB.String(): consumerStates[engine.ConsumerWallboxB], + } + wallboxPowerW := map[string]float64{} + if shellyStates != nil { + if s, ok := shellyStates[engine.ConsumerWallboxA]; ok { + wallboxPowerW[engine.ConsumerWallboxA.String()] = s.PowerW + } + if s, ok := shellyStates[engine.ConsumerWallboxB]; ok { + wallboxPowerW[engine.ConsumerWallboxB.String()] = s.PowerW + } + } + completed := tripMgr.Tick(wallboxActive, wallboxPowerW, pollMin) + + // If a trip goal's wallbox just went idle → car is full, clear the goal + if goal := tripMgr.ActiveGoal(); goal != nil { + for _, wb := range completed { + if wb == goal.Wallbox { + logger.Info("trip mode: charging complete, clearing goal", + "wallbox", wb, "car", goal.CarName) + _ = tripMgr.ClearGoal() + } + } + } + + // If trip goal's start time has arrived and wallbox is not yet active, force it on + if goal := tripMgr.ActiveGoal(); goal != nil { + consumer := tripConsumer(goal.Wallbox) + ratedKW := tripRatedKW(cfg, consumer) + learnedKW := tripMgr.LearnedRateKW(goal.Wallbox, ratedKW) + if goal.ShouldStartNow(now, learnedKW) && !consumerStates[consumer] { + actions = append(actions, engine.Action{ + Consumer: consumer, + TurnOn: true, + Reason: fmt.Sprintf("trip mode: %s, bereit bis %s", + goal.CarName, goal.Deadline.Format("15:04")), + }) + overrideDur := time.Until(goal.Deadline) + time.Hour + eng.ApplyOverride(consumer, true, overrideDur) + _ = tripMgr.MarkChargingStarted(now) + logger.Info("trip mode: activating wallbox", + "wallbox", goal.Wallbox, + "car", goal.CarName, + "learned_kw", learnedKW, + "deadline", goal.Deadline.Format("15:04"), + ) + } + } + // Update consumer state metrics m.UpdateConsumerStates(eng.ConsumerStates()) @@ -271,6 +345,114 @@ func runCycle( } } +// tripConsumer maps a wallbox config key to an engine Consumer. +func tripConsumer(wallbox string) engine.Consumer { + if wallbox == "wallbox_b" { + return engine.ConsumerWallboxB + } + return engine.ConsumerWallboxA +} + +// tripRatedKW returns the configured rated power for the given consumer in kW. +func tripRatedKW(cfg *config.Config, c engine.Consumer) float64 { + if c == engine.ConsumerWallboxB { + return float64(cfg.Shelly.WallboxB.PowerW) / 1000.0 + } + return float64(cfg.Shelly.WallboxA.PowerW) / 1000.0 +} + +// buildCarOptions returns a sorted list of car options for the status page form. +func buildCarOptions(cars map[string]config.CarProfile) []status.CarOption { + keys := make([]string, 0, len(cars)) + for k := range cars { + keys = append(keys, k) + } + sort.Strings(keys) + opts := make([]status.CarOption, 0, len(keys)) + for _, k := range keys { + c := cars[k] + opts = append(opts, status.CarOption{Key: k, Name: c.Name, BatteryKWh: c.BatteryKWh}) + } + return opts +} + +// tripSetHandler handles the trip mode form submission. +func tripSetHandler(tm *trip.Manager, cfg *config.Config, logger *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + carKey := r.FormValue("car") + wallbox := r.FormValue("wallbox") + socStr := r.FormValue("current_soc") + deadlineStr := r.FormValue("deadline") // "2006-01-02T15:04" + + car, ok := cfg.Cars[carKey] + if !ok { + http.Error(w, "unknown car", http.StatusBadRequest) + return + } + if wallbox != "wallbox_a" && wallbox != "wallbox_b" { + http.Error(w, "unknown wallbox", http.StatusBadRequest) + return + } + + soc, err := strconv.ParseFloat(socStr, 64) + if err != nil || soc < 0 || soc >= 100 { + http.Error(w, "invalid SOC (must be 0–99)", http.StatusBadRequest) + return + } + + deadline, err := time.ParseInLocation("2006-01-02T15:04", deadlineStr, time.Local) + if err != nil { + http.Error(w, "invalid deadline format", http.StatusBadRequest) + return + } + if deadline.Before(time.Now()) { + http.Error(w, "deadline is in the past", http.StatusBadRequest) + return + } + + goal := trip.Goal{ + Wallbox: wallbox, + CarName: car.Name, + BatteryKWh: car.BatteryKWh, + CurrentSOC: soc, + Deadline: deadline, + CreatedAt: time.Now(), + } + if err := tm.SetGoal(goal); err != nil { + logger.Error("failed to save trip goal", "error", err) + http.Error(w, "could not save goal — check logs", http.StatusInternalServerError) + return + } + logger.Info("trip goal set", + "wallbox", wallbox, + "car", car.Name, + "soc", soc, + "deadline", deadline.Format("02.01. 15:04"), + ) + http.Redirect(w, r, "/", http.StatusSeeOther) + } +} + +// tripCancelHandler clears the active trip goal. +func tripCancelHandler(tm *trip.Manager, logger *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + if err := tm.ClearGoal(); err != nil { + logger.Error("failed to clear trip goal", "error", err) + } + logger.Info("trip goal cancelled by user") + http.Redirect(w, r, "/", http.StatusSeeOther) + } +} + // monitorHandler toggles monitor-only mode on POST and redirects to the status page. func monitorHandler(mode *monitor.Mode, logger *slog.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) {