Merge house/lutz into main — v0.9

- Monitor-only mode: flag-file toggle, amber banner, suppresses all actuator calls
- Trip mode: self-learning EV charging scheduler (Mini Cooper SE / BMW ix2,
  selectable per trip, learned rate from session JSONL log)
- Compressor idle detection: correct metric (sensors_power_value = % of rated)
  and threshold (20 cleanly separates 0% idle from ≥27% running)
- Grafana dashboard: full EMS dashboard with power flow, consumer states,
  energy totals, raw Viessmann data, health panels, log analysis rows
This commit is contained in:
2026-04-06 14:49:44 +02:00
6 changed files with 853 additions and 27 deletions

View File

@@ -10,7 +10,7 @@ prometheus:
battery_soc: "ess_stateOfCharge_value" # 0-100%
pv_production: "photovoltaic_production_current_value" # watts
battery_power: "ess_power_value" # watts
compressor_power: "heating_compressors_0_power_value" # watts
compressor_power: "heating_compressors_0_sensors_power_value" # % of rated power (0=idle, 27-92=running); _power_value is rated kW (constant 10)
ambient_temp: "heating_sensors_temperature_outside_value" # °C
phase_l1_power: "pcc_ac_active_power_phaseOne" # per-phase grid power L1 (W)
phase_l2_power: "pcc_ac_active_power_phaseTwo" # per-phase grid power L2 (W)
@@ -78,7 +78,7 @@ strategic:
# Consumer behavior — idle detection thresholds
consumers:
compressor_idle_w: 50 # heat pump compressor below this = idle (W)
compressor_idle_w: 20 # compressor idle threshold: sensors_power_value is % of rated power; 0%=idle, ≥27%=running → 20 cleanly separates them
wallbox_min_charge_w: 50 # wallbox below this = car not charging (W)
idle_cycles: 3 # consecutive idle cycles before early release (~6 min at 2-min poll)
@@ -111,3 +111,15 @@ ems:
recovery_timeout: "1h" # ignore Shelly state if EMS was down longer
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

View File

@@ -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.
@@ -159,6 +166,9 @@ type EMSConfig struct {
RecoveryTimeout string `yaml:"recovery_timeout"`
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 {

61
internal/monitor/mode.go Normal file
View File

@@ -0,0 +1,61 @@
// Package monitor provides a persistent monitor-only mode toggle.
// When active, the EMS continues polling and showing the status page
// but suppresses all actuator calls (Shelly switches, Viessmann API).
// State is stored as a flag file: presence = active, absence = normal.
package monitor
import (
"os"
"sync"
)
// Mode is a thread-safe, file-backed monitor-only flag.
type Mode struct {
mu sync.RWMutex
active bool
filePath string
}
// New creates a Mode and loads the current state from the flag file.
// If filePath is empty, the mode is in-memory only (not persistent).
func New(filePath string) *Mode {
m := &Mode{filePath: filePath}
if filePath != "" {
_, err := os.Stat(filePath)
m.active = err == nil // file exists → monitor-only
}
return m
}
// IsActive reports whether monitor-only mode is currently enabled.
func (m *Mode) IsActive() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.active
}
// Set enables or disables monitor-only mode and persists the change to disk.
func (m *Mode) Set(active bool) error {
m.mu.Lock()
defer m.mu.Unlock()
m.active = active
if m.filePath == "" {
return nil // in-memory only
}
if active {
f, err := os.Create(m.filePath)
if err != nil {
return err
}
return f.Close()
}
err := os.Remove(m.filePath)
if os.IsNotExist(err) {
return nil
}
return err
}

View File

@@ -11,6 +11,8 @@ import (
"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.
@@ -39,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
@@ -50,10 +59,13 @@ type Store struct {
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) *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{}
@@ -63,6 +75,9 @@ func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig)
wwConfigured: wwConfigured,
strategic: strategic,
consumers: consumers,
monitorMode: mode,
tripMgr: tm,
carOptions: cars,
}
}
@@ -144,10 +159,25 @@ 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
DryRun bool
MonitorOnly bool
BatterySOC float64
GridPowerW float64
PVProductionW float64
@@ -160,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
@@ -227,6 +259,9 @@ func (s *Store) Handler() http.HandlerFunc {
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
@@ -234,6 +269,7 @@ func (s *Store) Handler() http.HandlerFunc {
LastUpdate: s.lastUpdate,
ErrMsg: s.errMsg,
DryRun: s.dryRun,
MonitorOnly: monitorOnly,
BatterySOC: s.state.BatterySOC,
GridPowerW: s.state.GridPowerW,
PVProductionW: s.state.PVProductionW,
@@ -266,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 {
@@ -310,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)
@@ -317,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
@@ -366,8 +475,68 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
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.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;
@@ -584,7 +753,14 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
{{end}}
</header>
{{if .DryRun}}
{{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}}
@@ -729,5 +905,60 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
</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 &nbsp;({{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
View 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:]
}

233
main.go
View File

@@ -9,6 +9,8 @@ import (
"os"
"os/signal"
"path/filepath"
"sort"
"strconv"
"syscall"
"time"
@@ -20,7 +22,9 @@ import (
"github.com/tb/ems/internal/engine"
"github.com/tb/ems/internal/forecast"
"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"
)
@@ -103,9 +107,30 @@ func main() {
reg := prometheus.NewRegistry()
m := metrics.NewMetrics(reg)
// Monitor-only mode (persistent flag file)
monitorMode := monitor.New(cfg.EMS.MonitorOnlyFile)
if monitorMode.IsActive() {
logger.Warn("starting in monitor-only mode — actuator calls suppressed",
"flag_file", cfg.EMS.MonitorOnlyFile,
)
}
// 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)
statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, monitorMode, tripMgr, carOptions)
// Metrics HTTP server
mux := http.NewServeMux()
@@ -115,6 +140,9 @@ func main() {
w.Write([]byte("ok"))
})
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{
@@ -143,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)
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)
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)
@@ -174,8 +202,11 @@ func runCycle(
stateFile string,
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)
@@ -189,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)
}
@@ -202,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 {
@@ -222,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())
@@ -240,9 +325,13 @@ func runCycle(
// Step 4: Execute actions
m.RecordActions(actions)
if dryRun {
if dryRun || monitorMode.IsActive() {
mode := "DRY RUN"
if monitorMode.IsActive() {
mode = "MONITOR-ONLY"
}
for _, a := range actions {
logger.Info("[DRY RUN] would execute",
logger.Info(fmt.Sprintf("[%s] would execute", mode),
"consumer", a.Consumer,
"turn_on", a.TurnOn,
"reason", a.Reason,
@@ -256,6 +345,132 @@ 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 099)", 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) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
newState := !mode.IsActive()
if err := mode.Set(newState); err != nil {
logger.Error("failed to set monitor-only mode", "active", newState, "error", err)
http.Error(w, "could not update monitor mode — check logs", http.StatusInternalServerError)
return
}
logger.Info("monitor-only mode changed", "active", newState)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
// shouldRecover checks the heartbeat file to decide whether to read back
// Shelly states on startup. Returns false if the file is missing (first run)
// or older than the recovery timeout.