Add trip mode: self-learning EV charging scheduler
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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
</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>`
|
||||
|
||||
297
internal/trip/trip.go
Normal file
297
internal/trip/trip.go
Normal file
@@ -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:]
|
||||
}
|
||||
196
main.go
196
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) {
|
||||
|
||||
Reference in New Issue
Block a user