6 Commits

Author SHA1 Message Date
0cd481a5ea Add Docker Compose stack for son's installation (evcc + Prometheus + Grafana)
Self-contained stack for DS218 Synology targeting:
- 2x Fronius inverters (11+22 kW) via shared DataManager, Solar.API v1
- BYD 12 kWh battery connected through Fronius
- 2x Elli Charger Connect 2 via OCPP 1.6j
- evcc handles PV surplus charging, OCPP server built-in
- Prometheus scrapes evcc /metrics, 90d retention
- Grafana auto-provisioned with evcc dashboard and Prometheus datasource

Setup: edit .env (3 IPs + 2 station IDs), point Elli app to OCPP backend,
docker compose up -d. README in German for non-IT handover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 15:53:44 +02:00
ca0f9ed1c3 house/son: placeholder config for son's installation
All house-specific values (IPs, coordinates, Viessmann credentials, kWp)
replaced with <...> placeholders. Ready to fill in once hardware is known.
Notable differences from house/lutz template:
- installation_id left empty (disables WW boost until credentials obtained)
- base_load_w set to 400W (adjust after measuring)
- forecast lat/lon/declination/azimuth/kwp all require local values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:19:30 +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
15 changed files with 561 additions and 46 deletions

View File

@@ -1,10 +1,11 @@
# EMS Configuration # EMS Configuration — house/son
# Fill in all values marked with <...> before deploying.
# All thresholds and timing parameters are configurable here. # All thresholds and timing parameters are configurable here.
prometheus: prometheus:
url: "http://192.168.0.23:9090" url: "http://<prometheus-ip>:9090"
# Metric names from the custom Viessmann exporter # Metric names from the custom Viessmann exporter (same as house/lutz)
metrics: metrics:
grid_power_exchange: "pcc_transfer_power_exchange_value" # positive = import, negative = export grid_power_exchange: "pcc_transfer_power_exchange_value" # positive = import, negative = export
battery_soc: "ess_stateOfCharge_value" # 0-100% battery_soc: "ess_stateOfCharge_value" # 0-100%
@@ -19,32 +20,33 @@ prometheus:
# Shelly actuators # Shelly actuators
shelly: shelly:
sg_ready: sg_ready:
ip: "192.168.42.90" ip: "<shelly-sg-ready-ip>"
gen: 1 # Gen1: /relay/0?turn=on|off gen: 1 # Gen1: /relay/0?turn=on|off
wallbox_a: wallbox_a:
ip: "192.168.42.185" ip: "<shelly-wallbox-a-ip>"
gen: 2 # Gen2: /rpc/Switch.Set gen: 2 # Gen2: /rpc/Switch.Set
power_w: 2000 power_w: 2000 # rated power of the wallbox (W)
password: "" # set Shelly admin password if auth is enabled password: "" # set Shelly admin password if auth is enabled
wallbox_b: wallbox_b:
ip: "192.168.42.51" ip: "<shelly-wallbox-b-ip>"
gen: 2 # Gen2: /rpc/Switch.Set gen: 2 # Gen2: /rpc/Switch.Set
power_w: 4000 power_w: 4000 # rated power of the wallbox (W)
password: "" # set Shelly admin password if auth is enabled password: "" # set Shelly admin password if auth is enabled
# Viessmann API (write access for WW temperature) # Viessmann API (write access for WW temperature)
# Leave installation_id empty to disable WW boost (EMS runs without it)
viessmann: viessmann:
token_file: "/etc/ems/viessmann-token.json" # OAuth2 refresh token token_file: "/etc/ems/viessmann-token.json" # OAuth2 refresh token
client_id: "5d1235548dfaa01ce37db8f865d2df4d" client_id: "<viessmann-oauth2-client-id>"
installation_id: "1253124" installation_id: "" # leave empty to disable WW boost
gateway_serial: "7637415006796210" gateway_serial: "<gateway-serial>"
device_id: "0" device_id: "0"
# SOC thresholds — which consumers are allowed at which battery level # SOC thresholds — which consumers are allowed at which battery level
soc_thresholds: soc_thresholds:
block_all: 50 # below 50%: no consumers block_all: 50 # below 50%: no consumers
sg_ready_only: 70 # 50-70%: only SG-Ready (heating period) sg_ready_only: 70 # 50-70%: only SG-Ready (heating period)
plus_wallbox_a: 90 # 70-90%: + Wallbox A (2kW) plus_wallbox_a: 90 # 70-90%: + Wallbox A
all_consumers: 90 # above 90%: all consumers all_consumers: 90 # above 90%: all consumers
# Hysteresis settings # Hysteresis settings
@@ -68,7 +70,7 @@ thresholds:
strategic: strategic:
forecast_high_kwh: 25 forecast_high_kwh: 25
forecast_mid_kwh: 15 forecast_mid_kwh: 15
ww_base_c: 48 # normal WW setpoint (°C) ww_base_c: 48 # normal WW setpoint (°C) — adjust to your heat pump
ww_boost_high_c: 5 # +5°C on high-forecast days (>25 kWh) ww_boost_high_c: 5 # +5°C on high-forecast days (>25 kWh)
ww_boost_mid_c: 3 # +3°C on medium-forecast days (>15 kWh) ww_boost_mid_c: 3 # +3°C on medium-forecast days (>15 kWh)
ww_window_start: "12:30" # WW boost only allowed from 12:30 ww_window_start: "12:30" # WW boost only allowed from 12:30
@@ -86,20 +88,21 @@ consumers:
season: season:
heating_start_month: 10 # October heating_start_month: 10 # October
heating_end_month: 4 # April 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) # PV forecast — forecast.solar (free, no API key required)
forecast: forecast:
enabled: true enabled: true
lat: 48.7839 lat: <latitude> # decimal degrees, e.g. 48.7839
lon: 9.3889 lon: <longitude> # decimal degrees, e.g. 9.3889
declination: 20 # panel tilt in degrees declination: <tilt> # panel tilt in degrees from horizontal (0=flat, 90=vertical)
azimuth: 5 # degrees from south (south=0, west=90, east=-90) azimuth: <azimuth> # degrees from south (south=0, west=90, east=-90)
kwp: 7.0 # installed peak power kwp: <kwp> # installed peak power in kWp, e.g. 8.5
fetch_interval: "4h" # re-fetch during the day (free tier: 12 req/day → 3 fetches) fetch_interval: "4h" # re-fetch during the day (free tier: 12 req/day → 3 fetches)
fetch_window_start: "07:00" fetch_window_start: "07:00"
fetch_window_end: "19:00" fetch_window_end: "19:00"
base_load_w: 450 # steady-state house consumption (W) — spikes from kettle/dishwasher are hourly averages and negligible base_load_w: 400 # steady-state house consumption (W) — measure and adjust
min_surplus_w: 1800 # min export surplus to trigger wallbox — matches wallbox_a threshold min_surplus_w: 1800 # min export surplus to trigger wallbox charging (W)
# EMS operational settings # EMS operational settings
ems: ems:

15
deploy/son/.env Normal file
View File

@@ -0,0 +1,15 @@
# ── Son's EMS stack — edit these values before first start ──────────────────
# Static IP of this Synology on the LAN (no trailing slash)
SYNOLOGY_IP=192.168.1.100
# Fronius DataManager IP (the box with the display / web interface)
FRONIUS_IP=192.168.1.XXX
# Elli Charger Connect 2 — OCPP station IDs
# Find these in the Elli app under Settings → Charging Station → Station ID
ELLI_A_STATION_ID=ELLI-XXXXXXXXXX-1
ELLI_B_STATION_ID=ELLI-XXXXXXXXXX-2
# Timezone
TZ=Europe/Berlin

101
deploy/son/README.md Normal file
View File

@@ -0,0 +1,101 @@
# EMS Stack — Einrichtung auf der Synology
## Was läuft hier?
| Dienst | Adresse | Funktion |
|---|---|---|
| evcc | `http://SYNOLOGY-IP:7070` | Ladesteuerung, PV-Überschuss, Haupt-UI |
| Grafana | `http://SYNOLOGY-IP:3000` | Historische Auswertungen |
| Prometheus | `http://SYNOLOGY-IP:9090` | Metrik-Speicher (intern) |
**Passwort Grafana:** `ems2024` (Benutzer: `admin`)
---
## Einmalige Einrichtung
### 1. Synology vorbereiten
Im Synology **Package Center**: **Container Manager** installieren (kostenlos).
### 2. Dateien auf die Synology kopieren
Den gesamten Ordner `deploy/son/` per FileStation oder SCP auf die Synology kopieren,
z.B. nach `/volume1/docker/ems/`.
### 3. `.env` anpassen
Die Datei `.env` im Texteditor öffnen und ausfüllen:
```
SYNOLOGY_IP=192.168.1.100 ← IP-Adresse der Synology
FRONIUS_IP=192.168.1.XXX ← IP-Adresse des Fronius DataManagers
ELLI_A_STATION_ID=ELLI-... ← Station-ID aus der Elli-App (Wallbox A)
ELLI_B_STATION_ID=ELLI-... ← Station-ID aus der Elli-App (Wallbox B)
```
**Station-ID in der Elli-App:** Einstellungen → Ladestation → Station-ID
### 4. Elli Wallboxes konfigurieren
In der **Elli-App** für jede Wallbox:
- Einstellungen → Backend / OCPP
- Backend-URL: `ws://SYNOLOGY-IP:8887/ocpp`
- Station-ID: wie in `.env` eingetragen
### 5. Stack starten
Im Synology **Container Manager** → **Projekt** → **Erstellen**:
- Projektname: `ems`
- Pfad: Ordner mit der `docker-compose.yaml`
- Starten
Oder per SSH:
```bash
cd /volume1/docker/ems
docker compose up -d
```
### 6. Prüfen ob alles läuft
- evcc öffnen: `http://SYNOLOGY-IP:7070`
- Unter **Geräte** sollten Fronius (PV + Batterie + Netz) und beide Wallboxen grün erscheinen
- Grafana: `http://SYNOLOGY-IP:3000` → Dashboard **evcc — Laden & PV**
---
## Wenn ein Fronius-Wechselrichter eine eigene IP hat
Falls doch zwei getrennte DataManager (zwei IP-Adressen):
In `evcc.yaml` den Abschnitt `meters` → `pv` auf zwei Einträge aufteilen:
```yaml
- name: pv1
type: template
template: fronius-solarapi-v1
host: 192.168.1.XXX # Wechselrichter 1 (11 kW)
usage: pv
- name: pv2
type: template
template: fronius-solarapi-v1
host: 192.168.1.YYY # Wechselrichter 2 (22 kW)
usage: pv
```
Und in `site.meters.pv` beide eintragen:
```yaml
pv:
- pv1
- pv2
```
---
## Updates
```bash
cd /volume1/docker/ems
docker compose pull
docker compose up -d
```

View File

@@ -0,0 +1,64 @@
services:
# ── evcc — EV charging controller ─────────────────────────────────────────
evcc:
image: evcc/evcc:latest
container_name: evcc
restart: unless-stopped
ports:
- "7070:7070" # evcc web UI
- "8887:8887" # OCPP server (Elli wallboxes connect here)
volumes:
- ./evcc.yaml:/etc/evcc.yaml:ro
- evcc-data:/root/.evcc
environment:
- TZ=${TZ}
networks:
- ems-net
# ── Prometheus — metrics storage ───────────────────────────────────────────
prometheus:
image: prom/prometheus:latest
container_name: prometheus
restart: unless-stopped
ports:
- "9090:9090"
volumes:
- ./prometheus/prometheus.yaml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.retention.time=90d
- --storage.tsdb.path=/prometheus
environment:
- TZ=${TZ}
networks:
- ems-net
# ── Grafana — dashboards ───────────────────────────────────────────────────
grafana:
image: grafana/grafana-oss:latest
container_name: grafana
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- grafana-data:/var/lib/grafana
environment:
- TZ=${TZ}
- GF_SECURITY_ADMIN_PASSWORD=ems2024
- GF_USERS_ALLOW_SIGN_UP=false
- GF_ANALYTICS_REPORTING_ENABLED=false
- GF_SERVER_ROOT_URL=http://${SYNOLOGY_IP}:3000
networks:
- ems-net
volumes:
evcc-data:
prometheus-data:
grafana-data:
networks:
ems-net:
driver: bridge

94
deploy/son/evcc.yaml Normal file
View File

@@ -0,0 +1,94 @@
# evcc configuration — son's installation
# Edit the IPs/IDs below to match your hardware.
# All values with XXX must be filled in before starting.
network:
schema: http
host: 0.0.0.0 # listen on all interfaces inside container
port: 7070
log: warn
levels:
core: info
charger: info
# ── Interval ─────────────────────────────────────────────────────────────────
interval: 30s # control loop interval
# ── Grid meter (Fronius Smart Meter via DataManager) ─────────────────────────
meters:
- name: grid
type: template
template: fronius-solarapi-v1
host: ${FRONIUS_IP} # Fronius DataManager IP
usage: grid
- name: pv
type: template
template: fronius-solarapi-v1
host: ${FRONIUS_IP} # same DataManager — covers both inverters aggregated
usage: pv
- name: battery
type: template
template: fronius-solarapi-v1
host: ${FRONIUS_IP} # BYD battery connected to Fronius
usage: battery
# ── Site ─────────────────────────────────────────────────────────────────────
site:
title: "Haus"
meters:
grid: grid
pv:
- pv
battery:
- battery
# ── Chargers (Elli Charger Connect 2 via OCPP) ───────────────────────────────
# The Elli app: Settings → Charging Station → Backend URL
# Set to: ws://<SYNOLOGY_IP>:8887/ocpp
# The station ID is shown in the same screen.
chargers:
- name: wallbox_a
type: template
template: ocpp
stationid: ${ELLI_A_STATION_ID}
connecttimeout: 5m
- name: wallbox_b
type: template
template: ocpp
stationid: ${ELLI_B_STATION_ID}
connecttimeout: 5m
# ── Load points ──────────────────────────────────────────────────────────────
loadpoints:
- title: "Wallbox A (11 kW)"
charger: wallbox_a
mode: pv # default: charge on PV surplus only
mincurrent: 6 # A — minimum OCPP current (legal minimum = 6A)
maxcurrent: 16 # A — 16A × 3 phases × 230V ≈ 11 kW
phases: 3
- title: "Wallbox B (8 kW)"
charger: wallbox_b
mode: pv
mincurrent: 6
maxcurrent: 12 # A — 12A × 3 phases × 230V ≈ 8.3 kW
phases: 3
# ── Tariff (optional — enables cost display) ─────────────────────────────────
# Uncomment and adjust if you want cost/CO2 tracking
# tariffs:
# currency: EUR
# grid:
# type: fixed
# price: 0.30 # €/kWh
# ── OCPP server ───────────────────────────────────────────────────────────────
ocpp:
port: 8887
# ── Prometheus metrics ────────────────────────────────────────────────────────
# evcc exposes /metrics on the main port (7070)

View File

@@ -0,0 +1,10 @@
apiVersion: 1
providers:
- name: evcc
type: file
disableDeletion: false
updateIntervalSeconds: 60
options:
path: /etc/grafana/provisioning/dashboards
foldersFromFilesStructure: false

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,9 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false

View File

@@ -0,0 +1,9 @@
global:
scrape_interval: 30s
evaluation_interval: 30s
scrape_configs:
- job_name: evcc
static_configs:
- targets: ['evcc:7070']
metrics_path: /metrics

View File

@@ -120,10 +120,11 @@ type StrategicConfig struct {
ScheduleOff string `yaml:"schedule_off"` 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 { type SeasonConfig struct {
HeatingStartMonth int `yaml:"heating_start_month"` HeatingStartMonth int `yaml:"heating_start_month"`
HeatingEndMonth int `yaml:"heating_end_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. // ForecastConfig holds forecast.solar API parameters.

View File

@@ -107,7 +107,7 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo
soc := state.BatterySOC soc := state.BatterySOC
gridW := state.GridPowerW // positive = import, negative = export gridW := state.GridPowerW // positive = import, negative = export
heatingPeriod := e.isHeatingPeriod(now) heatingPeriod := e.isHeatingPeriod(now, state.AmbientTempC)
wwWindow := e.isWWWindow(now) wwWindow := e.isWWWindow(now)
allowed := e.allowedConsumers(soc) 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. // isHeatingPeriod returns true if heating is appropriate given the current month
func (e *Engine) isHeatingPeriod(now time.Time) bool { // 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()) month := int(now.Month())
start := e.cfg.Season.HeatingStartMonth start := e.cfg.Season.HeatingStartMonth
end := e.cfg.Season.HeatingEndMonth end := e.cfg.Season.HeatingEndMonth
// Handles wrap-around: e.g. October(10) to April(4) // Handles wrap-around: e.g. October(10) to April(4)
var inMonth bool
if start > end { 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. // minRuntime returns the minimum runtime for a consumer.

View File

@@ -208,6 +208,7 @@ func TestShutdownReverseOrder(t *testing.T) {
func TestHeatingPeriodDetection(t *testing.T) { func TestHeatingPeriodDetection(t *testing.T) {
eng := NewEngine(testConfig(), testLogger()) eng := NewEngine(testConfig(), testLogger())
cold := 5.0 // well below any threshold — pure calendar test
tests := []struct { tests := []struct {
month time.Month month time.Month
@@ -230,13 +231,36 @@ func TestHeatingPeriodDetection(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.month.String(), func(t *testing.T) { t.Run(tt.month.String(), func(t *testing.T) {
date := time.Date(2025, tt.month, 15, 12, 0, 0, 0, time.UTC) 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) 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) { func TestWallboxMutualExclusion(t *testing.T) {
cfg := testConfig() cfg := testConfig()
cfg.Hysteresis.ExportOnDuration = "0s" cfg.Hysteresis.ExportOnDuration = "0s"

View File

@@ -111,6 +111,7 @@ func (s *Store) Update(state collector.SystemState, actions []engine.Action, ove
type consumerView struct { type consumerView struct {
Icon string Icon string
Label string Label string
Priority string // e.g. "①"
ConsumerKey string // e.g. "wallbox_a" — used for the override form ConsumerKey string // e.g. "wallbox_a" — used for the override form
Active bool Active bool
Since time.Time Since time.Time
@@ -281,12 +282,18 @@ func (s *Store) Handler() http.HandlerFunc {
} }
} }
} }
for _, c := range consumerOrder { prioritySymbols := []string{"①", "②", "③", "④"}
for i, c := range consumerOrder {
rec := s.consumers[c] rec := s.consumers[c]
meta := consumerMeta[c] meta := consumerMeta[c]
prio := ""
if i < len(prioritySymbols) {
prio = prioritySymbols[i]
}
cv := consumerView{ cv := consumerView{
Icon: meta.Icon, Icon: meta.Icon,
Label: meta.Label, Label: meta.Label,
Priority: prio,
ConsumerKey: c.String(), ConsumerKey: c.String(),
Active: rec.active, Active: rec.active,
Since: rec.since, Since: rec.since,
@@ -482,6 +489,14 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
margin-right: 0.25rem; 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 */ /* Consumer list */
.section-title { .section-title {
font-size: 0.7rem; font-size: 0.7rem;
@@ -658,14 +673,16 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
</div> </div>
{{end}} {{end}}
<div class="section-title">Verbraucher</div> <div class="section-title">Verbraucher &mdash; Priorität ↓</div>
{{range .Consumers}} {{range .Consumers}}
<div class="consumer{{if .Unconfigured}} unconfigured{{end}}"> <div class="consumer{{if .Unconfigured}} unconfigured{{end}}">
<div class="indicator {{if .ManualOverride}}override{{else if .Active}}on{{else}}off{{end}}"></div> <div class="indicator {{if .ManualOverride}}override{{else if .Active}}on{{else}}off{{end}}"></div>
<div class="consumer-icon">{{.Icon}}</div> <div class="consumer-icon">{{.Icon}}</div>
<div class="consumer-body"> <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}}"> <div class="consumer-detail{{if and .Active (not .ManualOverride)}} active{{end}}{{if .ManualOverride}} override{{end}}">
{{if .Unconfigured}} {{if .Unconfigured}}
nicht konfiguriert nicht konfiguriert

View File

@@ -17,7 +17,7 @@ import (
const ( const (
tokenEndpoint = "https://iam.viessmann-climatesolutions.com/idp/v3/token" 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. // 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. // SetDHWTemperature sets the domestic hot water target temperature via the Viessmann API.
func (c *Client) SetDHWTemperature(ctx context.Context, tempC float64) error { func (c *Client) SetDHWTemperature(ctx context.Context, tempC float64) error {
c.mu.Lock() c.mu.Lock()

125
main.go
View File

@@ -27,6 +27,7 @@ import (
func main() { func main() {
configPath := flag.String("config", "configs/ems-config.yaml", "Path to config file") configPath := flag.String("config", "configs/ems-config.yaml", "Path to config file")
dryRun := flag.Bool("dry-run", false, "Run without executing actions (log only)") 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() flag.Parse()
// Load configuration // Load configuration
@@ -50,6 +51,27 @@ func main() {
Level: logLevel, 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", logger.Info("starting EMS",
"config", *configPath, "config", *configPath,
"dry_run", *dryRun, "dry_run", *dryRun,
@@ -62,18 +84,6 @@ func main() {
eng := engine.NewEngine(cfg, logger) eng := engine.NewEngine(cfg, logger)
fc := forecast.NewClient(cfg.Forecast, cfg.Strategic, 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) act := actuator.NewActuator(cfg, vc, logger)
// Startup: attempt state recovery from Shelly read-back // Startup: attempt state recovery from Shelly read-back
@@ -343,6 +353,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. // writeHeartbeat updates the heartbeat file timestamp each cycle.
func writeHeartbeat(stateFile string, logger *slog.Logger) { func writeHeartbeat(stateFile string, logger *slog.Logger) {
if stateFile == "" { if stateFile == "" {