Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cd481a5ea | |||
| ca0f9ed1c3 | |||
| 0b51ce5240 | |||
| cc33add507 | |||
| 56710b4328 | |||
| 8ba86f8fc8 |
@@ -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.
|
||||
|
||||
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:
|
||||
grid_power_exchange: "pcc_transfer_power_exchange_value" # positive = import, negative = export
|
||||
battery_soc: "ess_stateOfCharge_value" # 0-100%
|
||||
@@ -19,32 +20,33 @@ prometheus:
|
||||
# Shelly actuators
|
||||
shelly:
|
||||
sg_ready:
|
||||
ip: "192.168.42.90"
|
||||
ip: "<shelly-sg-ready-ip>"
|
||||
gen: 1 # Gen1: /relay/0?turn=on|off
|
||||
wallbox_a:
|
||||
ip: "192.168.42.185"
|
||||
ip: "<shelly-wallbox-a-ip>"
|
||||
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
|
||||
wallbox_b:
|
||||
ip: "192.168.42.51"
|
||||
ip: "<shelly-wallbox-b-ip>"
|
||||
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
|
||||
|
||||
# Viessmann API (write access for WW temperature)
|
||||
# Leave installation_id empty to disable WW boost (EMS runs without it)
|
||||
viessmann:
|
||||
token_file: "/etc/ems/viessmann-token.json" # OAuth2 refresh token
|
||||
client_id: "5d1235548dfaa01ce37db8f865d2df4d"
|
||||
installation_id: "1253124"
|
||||
gateway_serial: "7637415006796210"
|
||||
client_id: "<viessmann-oauth2-client-id>"
|
||||
installation_id: "" # leave empty to disable WW boost
|
||||
gateway_serial: "<gateway-serial>"
|
||||
device_id: "0"
|
||||
|
||||
# SOC thresholds — which consumers are allowed at which battery level
|
||||
soc_thresholds:
|
||||
block_all: 50 # below 50%: no consumers
|
||||
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
|
||||
|
||||
# Hysteresis settings
|
||||
@@ -68,7 +70,7 @@ thresholds:
|
||||
strategic:
|
||||
forecast_high_kwh: 25
|
||||
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_mid_c: 3 # +3°C on medium-forecast days (>15 kWh)
|
||||
ww_window_start: "12:30" # WW boost only allowed from 12:30
|
||||
@@ -86,20 +88,21 @@ 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:
|
||||
enabled: true
|
||||
lat: 48.7839
|
||||
lon: 9.3889
|
||||
declination: 20 # panel tilt in degrees
|
||||
azimuth: 5 # degrees from south (south=0, west=90, east=-90)
|
||||
kwp: 7.0 # installed peak power
|
||||
lat: <latitude> # decimal degrees, e.g. 48.7839
|
||||
lon: <longitude> # decimal degrees, e.g. 9.3889
|
||||
declination: <tilt> # panel tilt in degrees from horizontal (0=flat, 90=vertical)
|
||||
azimuth: <azimuth> # degrees from south (south=0, west=90, east=-90)
|
||||
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_window_start: "07:00"
|
||||
fetch_window_end: "19:00"
|
||||
base_load_w: 450 # steady-state house consumption (W) — spikes from kettle/dishwasher are hourly averages and negligible
|
||||
min_surplus_w: 1800 # min export surplus to trigger wallbox — matches wallbox_a threshold
|
||||
base_load_w: 400 # steady-state house consumption (W) — measure and adjust
|
||||
min_surplus_w: 1800 # min export surplus to trigger wallbox charging (W)
|
||||
|
||||
# EMS operational settings
|
||||
ems:
|
||||
|
||||
15
deploy/son/.env
Normal file
15
deploy/son/.env
Normal 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
101
deploy/son/README.md
Normal 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
|
||||
```
|
||||
64
deploy/son/docker-compose.yaml
Normal file
64
deploy/son/docker-compose.yaml
Normal 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
94
deploy/son/evcc.yaml
Normal 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)
|
||||
10
deploy/son/grafana/provisioning/dashboards/dashboard.yaml
Normal file
10
deploy/son/grafana/provisioning/dashboards/dashboard.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: evcc
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 60
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards
|
||||
foldersFromFilesStructure: false
|
||||
1
deploy/son/grafana/provisioning/dashboards/evcc.json
Normal file
1
deploy/son/grafana/provisioning/dashboards/evcc.json
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
9
deploy/son/prometheus/prometheus.yaml
Normal file
9
deploy/son/prometheus/prometheus.yaml
Normal 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
|
||||
@@ -120,10 +120,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"`
|
||||
HeatingMinAmbientC float64 `yaml:"heating_min_ambient_c"` // above this: non-heating regardless of month (0 = disabled)
|
||||
}
|
||||
|
||||
// ForecastConfig holds forecast.solar API parameters.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -111,6 +111,7 @@ 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
|
||||
@@ -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]
|
||||
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,
|
||||
@@ -482,6 +489,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;
|
||||
@@ -658,14 +673,16 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="section-title">Verbraucher</div>
|
||||
<div class="section-title">Verbraucher — 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
|
||||
|
||||
@@ -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()
|
||||
|
||||
125
main.go
125
main.go
@@ -27,6 +27,7 @@ import (
|
||||
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 +51,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 +84,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
|
||||
@@ -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.
|
||||
func writeHeartbeat(stateFile string, logger *slog.Logger) {
|
||||
if stateFile == "" {
|
||||
|
||||
Reference in New Issue
Block a user