14 Commits
v0.9 ... main

Author SHA1 Message Date
eaefe4c519 Merge house/lutz — compressor fix, WW boost toggle, button layout
- Compressor: PromQL expression returns actual watts (sensors% × kW × 10),
  idle threshold raised to 500W; fixes silent early-release of SG-Ready
- WW boost toggle: persistent flag file (/etc/ems/ww-boost-off) disables
  WW boost globally; / button on WW card, survives reboots
- WW reset button: restores base temp + blocks boost until midnight
- WW buttons stacked vertically in consumer card
- Grafana dashboard: full EMS dashboard pushed to Grafana instance
2026-04-06 16:23:35 +02:00
009f8b05f3 Stack WW boost buttons vertically in consumer card
The reset and boost toggle buttons were side by side, looking cramped.
Wrapped them in a flex column div so they stack neatly below each other.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:23:27 +02:00
4d4b7fb047 Fix WW boost toggle: move WWBoostDisabled into consumerView
Template range loop sets . to consumerView, not pageData, so
.WWBoostDisabled was undefined. Added the field to consumerView
and populated it from the wwBoostDisabled local in the handler.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:18:18 +02:00
afbb7bf62c Add WW boost enable/disable toggle (persistent flag file)
In summer the WW boost interferes with car charging priority.
A new / toggle on the WW consumer card persists to
/etc/ems/ww-boost-off (presence = disabled, absence = enabled).

- config: ww_boost_disable_file flag file path
- main: wwBoostDisabled mode, wwBoostToggleHandler, runCycle skips
  computeWWBoost when disabled
- status: WWBoostDisabled in pageData, toggle button in WW card
  showing  Boost aus /  Boost ein depending on state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:09:33 +02:00
1b57293da9 Fix compressor watts, add WW boost reset button
Compressor:
- metric changed to PromQL expression that returns actual watts:
  sensors_power_value(%) × power_value(kW) × 10
  e.g. 27% × 10 kW = 2700 W; idle = 0 W
- idle threshold raised 20→500 W (clean gap: 0W idle vs ≥2700W running)
- status page now displays real kW instead of percent

WW boost reset:
- Added 🌡️ Zurücksetzen button to WW consumer card (when configured)
- POST /ww/reset: restores WWBaseC via Viessmann API + blocks re-boost
  via engine override until midnight
- IsWW field added to consumerView for template control

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:00:42 +02:00
2936078faa 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
2026-04-06 14:49:44 +02:00
a537b02adb Fix compressor metric comments: sensors_power_value is % of rated power
heating_compressors_0_sensors_power_value returns 0-100% of rated
compressor power, not watts. heating_compressors_0_power_value is the
constant rated power in kW (10 kW). Actual watts = sensors% × kW × 10.

Idle threshold of 20 remains correct: values are either 0% (idle) or
≥27% (running), so 20 cleanly separates them.

Grafana panel updated to show computed actual watts using the formula.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 14:49:28 +02:00
9f34fbd393 Fix compressor idle detection: use sensors_power_value, lower threshold to 20W
heating_compressors_0_power_value is a Viessmann load level (stuck at 10),
not actual watts. The real power sensor is sensors_power_value (0W idle,
27-80W when running). With the wrong metric SG-Ready was always released
early after ~6 min regardless of min_runtime_sg_ready.

Threshold lowered 50→20W to cleanly separate idle (0W) from low-modulation
running states.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 14:39:04 +02:00
406046c3a9 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>
2026-04-06 12:51:41 +02:00
00f8f3cdde Add monitor-only mode: suppress all actions without stopping EMS
New internal/monitor package provides a thread-safe, file-backed toggle.
Flag file presence (/etc/ems/monitor-only) = monitor-only active, survives
reboots. Deletion = resume normal operation.

Behaviour when active:
- EMS continues polling Prometheus, running the decision engine, and
  updating the status page every 2 minutes — full visibility maintained
- All actuator calls suppressed (Shelly switches + Viessmann WW writes)
- Suppressed actions logged as [MONITOR-ONLY] for audit trail
- Startup warns if flag file is already present

Web UI:
- Amber banner "⏸ Monitor-Only — Keine Schaltvorgänge" with inline
  "▶ Automatik" resume button when active
- Small unobtrusive "⏸ Monitor-Only" button at page bottom when inactive
- POST /monitor endpoint toggles state and redirects back to status page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:27:53 +02:00
0b51ce5240 Add ambient temperature gate to heating period detection
Adds heating_min_ambient_c (default 15°C) to season config. When outdoor
temperature is at or above this threshold, SG-Ready is suppressed even if
the calendar month is within the heating season. Prevents unnecessary heat
pump boost activation on warm spring/autumn days.

Logic: heating active = in_heating_month AND ambient < threshold
Zero value (unset) disables the temperature gate (calendar-only, old behaviour).

New test: TestHeatingPeriodAmbientSuppression covers warm-day suppression,
cold-day pass-through, and summer month independence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:13:05 +02:00
cc33add507 Show consumer priority order on status page
Section title changed to "Verbraucher — Priorität ↓" and each consumer
row now shows a circled number (① ② ③ ④) so it's immediately clear
which consumer has precedence for turn-on and reverse order for shutdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:08:34 +02:00
56710b4328 Fix Viessmann API hostname and harden test propagation waits
- Update apiBase: api.viessmann.com → api.viessmann-climatesolutions.com
  (Viessmann renamed their IoT API host; old domain is NXDOMAIN)
- Extend --test-viessmann context to 120s
- Add 30s propagation wait before read-back in both Stage 3 and Restore:
  cloud accepts writes immediately but gateway polls on its own schedule,
  so GET returns the last confirmed device value with ~30s lag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:05:33 +02:00
8ba86f8fc8 Add --test-viessmann flag for end-to-end API validation
Adds GetDHWTemperature() to the Viessmann client (GET on the DHW feature
endpoint) and a --test-viessmann CLI flag that runs three stages without
starting the EMS control loop:

  Stage 1: read current DHW setpoint (confirms auth + endpoint path)
  Stage 2: write same value back, read-back to confirm round-trip
  Stage 3: write +1°C, read-back to confirm, restore original

The +1°C delta is below the heat pump's 5°C hysteresis so the compressor
will not fire, but the change is visible in the Viessmann app for visual
confirmation before trusting the WW boost logic in production.

Usage: ./ems --config /etc/ems/ems-config.yaml --test-viessmann

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:59:57 +02:00
9 changed files with 1192 additions and 61 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 * heating_compressors_0_power_value * 10" # actual watts: sensors(%) × rated_kW(10) × 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: 500 # heat pump compressor below this = idle (W); running starts at ~2700W, so 500W cleanly separates idle (0W) from running
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)
@@ -86,6 +86,7 @@ consumers:
season:
heating_start_month: 10 # October
heating_end_month: 4 # April
heating_min_ambient_c: 15 # above 15°C outdoor temp: no SG-Ready even within heating months
# PV forecast — forecast.solar (free, no API key required)
forecast:
@@ -110,3 +111,16 @@ 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
ww_boost_disable_file: "/etc/ems/ww-boost-off" # presence of this file = WW boost disabled
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.
@@ -120,10 +127,11 @@ type StrategicConfig struct {
ScheduleOff string `yaml:"schedule_off"`
}
// SeasonConfig defines the heating season by month range.
// SeasonConfig defines the heating season by month range and ambient temperature.
type SeasonConfig struct {
HeatingStartMonth int `yaml:"heating_start_month"`
HeatingEndMonth int `yaml:"heating_end_month"`
HeatingStartMonth int `yaml:"heating_start_month"`
HeatingEndMonth int `yaml:"heating_end_month"`
HeatingMinAmbientC float64 `yaml:"heating_min_ambient_c"` // above this: non-heating regardless of month (0 = disabled)
}
// ForecastConfig holds forecast.solar API parameters.
@@ -158,6 +166,10 @@ 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
WWBoostDisableFile string `yaml:"ww_boost_disable_file"` // flag file path: presence = WW boost disabled
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 {

View File

@@ -107,7 +107,7 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo
soc := state.BatterySOC
gridW := state.GridPowerW // positive = import, negative = export
heatingPeriod := e.isHeatingPeriod(now)
heatingPeriod := e.isHeatingPeriod(now, state.AmbientTempC)
wwWindow := e.isWWWindow(now)
allowed := e.allowedConsumers(soc)
@@ -502,17 +502,37 @@ func (e *Engine) ApplyOverride(consumer Consumer, on bool, duration time.Duratio
)
}
// isHeatingPeriod returns true if the current month is within the heating season.
func (e *Engine) isHeatingPeriod(now time.Time) bool {
// isHeatingPeriod returns true if heating is appropriate given the current month
// and outdoor temperature. If HeatingMinAmbientC is configured (> 0), ambient
// temperatures above that threshold suppress SG-Ready even within the heating months.
func (e *Engine) isHeatingPeriod(now time.Time, ambientC float64) bool {
month := int(now.Month())
start := e.cfg.Season.HeatingStartMonth
end := e.cfg.Season.HeatingEndMonth
// Handles wrap-around: e.g. October(10) to April(4)
var inMonth bool
if start > end {
return month >= start || month <= end
inMonth = month >= start || month <= end
} else {
inMonth = month >= start && month <= end
}
return month >= start && month <= end
if !inMonth {
return false
}
// Temperature override: warm day within heating months → not a heating day
threshold := e.cfg.Season.HeatingMinAmbientC
if threshold > 0 && ambientC >= threshold {
e.logger.Debug("heating period suppressed by ambient temperature",
"ambient_c", ambientC,
"threshold_c", threshold,
)
return false
}
return true
}
// minRuntime returns the minimum runtime for a consumer.

View File

@@ -208,6 +208,7 @@ func TestShutdownReverseOrder(t *testing.T) {
func TestHeatingPeriodDetection(t *testing.T) {
eng := NewEngine(testConfig(), testLogger())
cold := 5.0 // well below any threshold — pure calendar test
tests := []struct {
month time.Month
@@ -230,13 +231,36 @@ func TestHeatingPeriodDetection(t *testing.T) {
for _, tt := range tests {
t.Run(tt.month.String(), func(t *testing.T) {
date := time.Date(2025, tt.month, 15, 12, 0, 0, 0, time.UTC)
if got := eng.isHeatingPeriod(date); got != tt.expected {
if got := eng.isHeatingPeriod(date, cold); got != tt.expected {
t.Errorf("month %s: got %v, want %v", tt.month, got, tt.expected)
}
})
}
}
func TestHeatingPeriodAmbientSuppression(t *testing.T) {
cfg := testConfig()
cfg.Season.HeatingMinAmbientC = 15.0
eng := NewEngine(cfg, testLogger())
// Winter month, but warm day — should be suppressed
warmWinterDay := time.Date(2025, time.January, 15, 12, 0, 0, 0, time.UTC)
if eng.isHeatingPeriod(warmWinterDay, 18.0) {
t.Error("expected heating period suppressed when ambient (18°C) >= threshold (15°C)")
}
// Winter month, cold day — should be active
if !eng.isHeatingPeriod(warmWinterDay, 8.0) {
t.Error("expected heating period active when ambient (8°C) < threshold (15°C)")
}
// Summer month, cold day — still not heating (month gate takes precedence)
summerDay := time.Date(2025, time.July, 15, 12, 0, 0, 0, time.UTC)
if eng.isHeatingPeriod(summerDay, 5.0) {
t.Error("expected heating period inactive in summer regardless of temperature")
}
}
func TestWallboxMutualExclusion(t *testing.T) {
cfg := testConfig()
cfg.Hysteresis.ExportOnDuration = "0s"

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,19 +59,27 @@ type Store struct {
strategic config.StrategicConfig
consumers map[engine.Consumer]*consumerRecord
fcResult *forecast.Result
monitorMode *monitor.Mode
wwBoostDisabled *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, wwBoostDisabled *monitor.Mode, tm *trip.Manager, cars []CarOption) *Store {
consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder))
for _, c := range consumerOrder {
consumers[c] = &consumerRecord{}
}
return &Store{
dryRun: dryRun,
wwConfigured: wwConfigured,
strategic: strategic,
consumers: consumers,
dryRun: dryRun,
wwConfigured: wwConfigured,
strategic: strategic,
consumers: consumers,
monitorMode: mode,
wwBoostDisabled: wwBoostDisabled,
tripMgr: tm,
carOptions: cars,
}
}
@@ -111,13 +128,16 @@ func (s *Store) Update(state collector.SystemState, actions []engine.Action, ove
type consumerView struct {
Icon string
Label string
Priority string // e.g. "①"
ConsumerKey string // e.g. "wallbox_a" — used for the override form
Active bool
Since time.Time
Reason string
Unconfigured bool
CanOverride bool // true for Shelly consumers (hardware read-back available)
ManualOverride bool
CanOverride bool // true for Shelly consumers (hardware read-back available)
IsWW bool // true for ConsumerWW — shows reset button instead of override
WWBoostDisabled bool // copied from pageData for template access inside range
ManualOverride bool
OverrideUntil time.Time
LivePowerW float64 // from Shelly PM; 0 for non-PM devices
}
@@ -143,10 +163,26 @@ 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
WWBoostDisabled bool
BatterySOC float64
GridPowerW float64
PVProductionW float64
@@ -159,6 +195,8 @@ type pageData struct {
Forecast forecastView
ChargingAdvice chargingAdviceView
Consumers []consumerView
Trip tripView
CarOptions []CarOption
}
// SyncConsumerStates updates active flags for all consumers directly from the
@@ -226,6 +264,10 @@ func (s *Store) Handler() http.HandlerFunc {
return
}
monitorOnly := s.monitorMode != nil && s.monitorMode.IsActive()
wwBoostDisabled := s.wwBoostDisabled != nil && s.wwBoostDisabled.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
@@ -233,6 +275,8 @@ func (s *Store) Handler() http.HandlerFunc {
LastUpdate: s.lastUpdate,
ErrMsg: s.errMsg,
DryRun: s.dryRun,
MonitorOnly: monitorOnly,
WWBoostDisabled: wwBoostDisabled,
BatterySOC: s.state.BatterySOC,
GridPowerW: s.state.GridPowerW,
PVProductionW: s.state.PVProductionW,
@@ -265,7 +309,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 {
@@ -281,18 +324,26 @@ func (s *Store) Handler() http.HandlerFunc {
}
}
}
for _, c := range consumerOrder {
prioritySymbols := []string{"①", "②", "③", "④"}
for i, c := range consumerOrder {
rec := s.consumers[c]
meta := consumerMeta[c]
prio := ""
if i < len(prioritySymbols) {
prio = prioritySymbols[i]
}
cv := consumerView{
Icon: meta.Icon,
Label: meta.Label,
Priority: prio,
ConsumerKey: c.String(),
Active: rec.active,
Since: rec.since,
Reason: rec.reason,
CanOverride: c != engine.ConsumerWW,
ManualOverride: rec.manualOverride,
CanOverride: c != engine.ConsumerWW,
IsWW: c == engine.ConsumerWW,
WWBoostDisabled: c == engine.ConsumerWW && wwBoostDisabled,
ManualOverride: rec.manualOverride,
OverrideUntil: rec.overrideUntil,
LivePowerW: rec.livePowerW,
}
@@ -303,6 +354,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)
@@ -310,6 +367,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
@@ -359,8 +484,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;
@@ -482,6 +667,14 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
margin-right: 0.25rem;
}
/* Priority badge */
.prio-badge {
font-size: 0.75rem;
color: #9ca3af;
margin-right: 0.35rem;
font-variant-numeric: tabular-nums;
}
/* Consumer list */
.section-title {
font-size: 0.7rem;
@@ -569,7 +762,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}}
@@ -658,14 +858,16 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
</div>
{{end}}
<div class="section-title">Verbraucher</div>
<div class="section-title">Verbraucher &mdash; Priorität ↓</div>
{{range .Consumers}}
<div class="consumer{{if .Unconfigured}} unconfigured{{end}}">
<div class="indicator {{if .ManualOverride}}override{{else if .Active}}on{{else}}off{{end}}"></div>
<div class="consumer-icon">{{.Icon}}</div>
<div class="consumer-body">
<div class="consumer-name">{{.Label}}</div>
<div class="consumer-name">
<span class="prio-badge">{{.Priority}}</span>{{.Label}}
</div>
<div class="consumer-detail{{if and .Active (not .ManualOverride)}} active{{end}}{{if .ManualOverride}} override{{end}}">
{{if .Unconfigured}}
nicht konfiguriert
@@ -702,6 +904,22 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
{{end}}
</form>
{{end}}
{{if .IsWW}}
<div style="display:flex;flex-direction:column;gap:0.3rem;flex-shrink:0">
{{if not .Unconfigured}}
<form method="post" action="/ww/reset">
<button type="submit" class="override-btn turn-off" style="width:100%" title="WW-Solltemperatur auf Basiswert zurücksetzen, Boost für heute sperren">🌡️ Zurücksetzen</button>
</form>
{{end}}
<form method="post" action="/ww/boost">
{{if .WWBoostDisabled}}
<button type="submit" class="override-btn turn-on" style="width:100%" title="WW Boost durch PV-Überschuss wieder erlauben">✅ Boost ein</button>
{{else}}
<button type="submit" class="override-btn turn-off" style="width:100%" title="WW Boost dauerhaft deaktivieren (z.B. im Sommer)">⛔ Boost aus</button>
{{end}}
</form>
</div>
{{end}}
</div>
{{end}}
@@ -712,5 +930,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:]
}

View File

@@ -17,7 +17,7 @@ import (
const (
tokenEndpoint = "https://iam.viessmann-climatesolutions.com/idp/v3/token"
apiBase = "https://api.viessmann.com/iot/v2"
apiBase = "https://api.viessmann-climatesolutions.com/iot/v2"
)
// tokenFile mirrors the JSON structure stored on disk.
@@ -137,6 +137,52 @@ func (c *Client) featureURL(feature string) string {
)
}
// dhwFeatureResponse is the minimal structure returned by GET on the DHW temperature feature.
type dhwFeatureResponse struct {
Data struct {
Properties struct {
Value struct {
Value float64 `json:"value"`
} `json:"value"`
} `json:"properties"`
} `json:"data"`
}
// GetDHWTemperature reads the current domestic hot water target temperature from the Viessmann API.
func (c *Client) GetDHWTemperature(ctx context.Context) (float64, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureToken(ctx); err != nil {
return 0, fmt.Errorf("ensuring token: %w", err)
}
featureURL := c.featureURL("heating.dhw.temperature.main")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, featureURL, nil)
if err != nil {
return 0, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token.AccessToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, fmt.Errorf("API call: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("Viessmann API returned %d", resp.StatusCode)
}
var result dhwFeatureResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return 0, fmt.Errorf("decoding response: %w", err)
}
return result.Data.Properties.Value.Value, nil
}
// SetDHWTemperature sets the domestic hot water target temperature via the Viessmann API.
func (c *Client) SetDHWTemperature(ctx context.Context, tempC float64) error {
c.mu.Lock()

428
main.go
View File

@@ -9,6 +9,8 @@ import (
"os"
"os/signal"
"path/filepath"
"sort"
"strconv"
"syscall"
"time"
@@ -20,13 +22,16 @@ 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"
)
func main() {
configPath := flag.String("config", "configs/ems-config.yaml", "Path to config file")
dryRun := flag.Bool("dry-run", false, "Run without executing actions (log only)")
testViessmann := flag.Bool("test-viessmann", false, "Test Viessmann API: read setpoint, round-trip write, +1°C delta, then restore")
flag.Parse()
// Load configuration
@@ -50,6 +55,27 @@ func main() {
Level: logLevel,
}))
// Viessmann client — needed for both normal operation and --test-viessmann
var vc *viessmann.Client
if cfg.Viessmann.InstallationID != "" && cfg.Viessmann.ClientID != "" {
var err error
vc, err = viessmann.NewClient(cfg.Viessmann, logger)
if err != nil {
logger.Warn("Viessmann client init failed, WW boost disabled", "error", err)
} else {
logger.Info("Viessmann client initialized")
}
}
// --test-viessmann: run the API test and exit — do not start the control loop
if *testViessmann {
if vc == nil {
fmt.Fprintln(os.Stderr, "ERROR: Viessmann credentials not configured or token failed to load")
os.Exit(1)
}
os.Exit(runViessmannTest(vc))
}
logger.Info("starting EMS",
"config", *configPath,
"dry_run", *dryRun,
@@ -62,18 +88,6 @@ func main() {
eng := engine.NewEngine(cfg, logger)
fc := forecast.NewClient(cfg.Forecast, cfg.Strategic, logger)
// Viessmann client — optional, only if credentials are configured
var vc *viessmann.Client
if cfg.Viessmann.InstallationID != "" && cfg.Viessmann.ClientID != "" {
var err error
vc, err = viessmann.NewClient(cfg.Viessmann, logger)
if err != nil {
logger.Warn("Viessmann client init failed, WW boost disabled", "error", err)
} else {
logger.Info("Viessmann client initialized")
}
}
act := actuator.NewActuator(cfg, vc, logger)
// Startup: attempt state recovery from Shelly read-back
@@ -93,9 +107,36 @@ 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,
)
}
// WW boost disable flag (persistent; presence of file = boost disabled)
wwBoostDisabled := monitor.New(cfg.EMS.WWBoostDisableFile)
if wwBoostDisabled.IsActive() {
logger.Info("WW boost disabled via flag file", "flag_file", cfg.EMS.WWBoostDisableFile)
}
// 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, wwBoostDisabled, tripMgr, carOptions)
// Metrics HTTP server
mux := http.NewServeMux()
@@ -105,6 +146,11 @@ 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("/ww/reset", wwResetHandler(act, eng, cfg, logger))
mux.HandleFunc("/ww/boost", wwBoostToggleHandler(wwBoostDisabled, logger))
mux.HandleFunc("/", statusStore.Handler())
srv := &http.Server{
@@ -133,12 +179,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, wwBoostDisabled, 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, wwBoostDisabled, tripMgr)
case sig := <-sigCh:
logger.Info("received signal, shutting down", "signal", sig)
@@ -164,8 +210,12 @@ func runCycle(
stateFile string,
logger *slog.Logger,
dryRun bool,
monitorMode *monitor.Mode,
wwBoostDisabled *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)
@@ -179,10 +229,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)
}
@@ -192,8 +244,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 {
@@ -207,11 +259,66 @@ func runCycle(
}
// Step 3: Run decision engine
wwBoostC := computeWWBoost(fcResult, cfg)
wwBoostC := 0.0
if !wwBoostDisabled.IsActive() {
wwBoostC = computeWWBoost(fcResult, cfg)
}
decisionStart := time.Now()
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())
@@ -230,9 +337,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,
@@ -246,6 +357,188 @@ 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)
}
}
// wwBoostToggleHandler toggles the WW boost disable flag on POST.
func wwBoostToggleHandler(disabled *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 := !disabled.IsActive()
if err := disabled.Set(newState); err != nil {
logger.Error("WW boost toggle failed", "error", err)
} else {
if newState {
logger.Info("WW boost disabled via web UI")
} else {
logger.Info("WW boost enabled via web UI")
}
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
// wwResetHandler resets the WW temperature to the configured base setpoint and
// blocks further boosts for the rest of the day via an engine override.
func wwResetHandler(act *actuator.Actuator, eng *engine.Engine, 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
}
// Block re-boost until midnight.
now := time.Now()
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
lockDur := time.Until(midnight)
eng.ApplyOverride(engine.ConsumerWW, false, lockDur)
// Immediately restore base WW temperature via Viessmann API.
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
action := engine.Action{
Consumer: engine.ConsumerWW,
TurnOn: false,
TargetTempC: cfg.Strategic.WWBaseC,
Reason: "manual WW reset via web UI",
}
if err := act.Execute(ctx, []engine.Action{action}); err != nil {
logger.Error("WW reset: actuator failed", "error", err)
} else {
logger.Info("WW boost reset", "base_c", cfg.Strategic.WWBaseC, "locked_until", midnight.Format("15:04"))
}
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.
@@ -343,6 +636,97 @@ func computeWWBoost(fc *forecast.Result, cfg *config.Config) float64 {
}
}
// runViessmannTest exercises the Viessmann API read/write path without starting
// the EMS control loop. It performs three stages:
// 1. Read current DHW setpoint (confirms auth + endpoint)
// 2. Write the same value back (confirms write path with zero net change)
// 3. Write setpoint +1°C, read back to confirm, then restore original
//
// Returns 0 on success, 1 on any failure.
func runViessmannTest(vc *viessmann.Client) int {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
pass := func(format string, args ...any) { fmt.Printf(" PASS "+format+"\n", args...) }
fail := func(format string, args ...any) { fmt.Fprintf(os.Stderr, " FAIL "+format+"\n", args...) }
step := func(format string, args ...any) { fmt.Printf("\n[test] "+format+"\n", args...) }
fmt.Println("=== Viessmann API test ===")
// Stage 1: read current setpoint
step("Stage 1 — read current DHW setpoint")
original, err := vc.GetDHWTemperature(ctx)
if err != nil {
fail("GetDHWTemperature: %v", err)
return 1
}
pass("current setpoint = %.1f°C", original)
// Stage 2: write same value back (round-trip, no observable effect)
step("Stage 2 — write same value back (%.1f°C → %.1f°C, no net change)", original, original)
if err := vc.SetDHWTemperature(ctx, original); err != nil {
fail("SetDHWTemperature(%.1f): %v", original, err)
return 1
}
readback, err := vc.GetDHWTemperature(ctx)
if err != nil {
fail("read-back after round-trip: %v", err)
return 1
}
if readback != original {
fail("round-trip mismatch: wrote %.1f, read back %.1f", original, readback)
return 1
}
pass("round-trip confirmed (%.1f°C)", readback)
// Stage 3: +1°C delta — heat pump won't notice (5°C hysteresis), but API change is visible.
// The Viessmann cloud queues the write to the gateway; the GET endpoint returns the last
// *confirmed* device value, so we wait up to 30s for the change to propagate.
delta := original + 1
step("Stage 3 — +1°C delta test (%.1f°C → %.1f°C)", original, delta)
if err := vc.SetDHWTemperature(ctx, delta); err != nil {
fail("SetDHWTemperature(%.1f): %v", delta, err)
_ = vc.SetDHWTemperature(ctx, original)
return 1
}
fmt.Println(" ...waiting 30s for cloud→gateway propagation...")
time.Sleep(30 * time.Second)
readback, err = vc.GetDHWTemperature(ctx)
if err != nil {
fail("read-back after +1°C: %v", err)
_ = vc.SetDHWTemperature(ctx, original)
return 1
}
if readback != delta {
fail("+1°C mismatch: wrote %.1f, read back %.1f after 30s — gateway may be offline or propagation takes longer", delta, readback)
_ = vc.SetDHWTemperature(ctx, original)
return 1
}
pass("+1°C confirmed (%.1f°C) — check Viessmann app now if you want visual confirmation", readback)
// Restore original
step("Restore — writing original setpoint back (%.1f°C)", original)
if err := vc.SetDHWTemperature(ctx, original); err != nil {
fail("restore SetDHWTemperature(%.1f): %v", original, err)
return 1
}
fmt.Println(" ...waiting 30s for cloud→gateway propagation...")
time.Sleep(30 * time.Second)
readback, err = vc.GetDHWTemperature(ctx)
if err != nil {
fail("read-back after restore: %v", err)
return 1
}
if readback != original {
fail("restore mismatch: wrote %.1f, read back %.1f", original, readback)
return 1
}
pass("restored to %.1f°C", readback)
fmt.Println("\n=== ALL STAGES PASSED — Viessmann API read/write confirmed ===")
return 0
}
// writeHeartbeat updates the heartbeat file timestamp each cycle.
func writeHeartbeat(stateFile string, logger *slog.Logger) {
if stateFile == "" {