Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cd481a5ea | |||
| ca0f9ed1c3 |
@@ -1,16 +1,17 @@
|
|||||||
# 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%
|
||||||
pv_production: "photovoltaic_production_current_value" # watts
|
pv_production: "photovoltaic_production_current_value" # watts
|
||||||
battery_power: "ess_power_value" # watts
|
battery_power: "ess_power_value" # watts
|
||||||
compressor_power: "heating_compressors_0_sensors_power_value * heating_compressors_0_power_value * 10" # actual watts: sensors(%) × rated_kW(10) × 10
|
compressor_power: "heating_compressors_0_power_value" # watts
|
||||||
ambient_temp: "heating_sensors_temperature_outside_value" # °C
|
ambient_temp: "heating_sensors_temperature_outside_value" # °C
|
||||||
phase_l1_power: "pcc_ac_active_power_phaseOne" # per-phase grid power L1 (W)
|
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)
|
phase_l2_power: "pcc_ac_active_power_phaseTwo" # per-phase grid power L2 (W)
|
||||||
@@ -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
|
||||||
@@ -78,7 +80,7 @@ strategic:
|
|||||||
|
|
||||||
# Consumer behavior — idle detection thresholds
|
# Consumer behavior — idle detection thresholds
|
||||||
consumers:
|
consumers:
|
||||||
compressor_idle_w: 500 # heat pump compressor below this = idle (W); running starts at ~2700W, so 500W cleanly separates idle (0W) from running
|
compressor_idle_w: 50 # heat pump compressor below this = idle (W)
|
||||||
wallbox_min_charge_w: 50 # wallbox below this = car not charging (W)
|
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)
|
idle_cycles: 3 # consecutive idle cycles before early release (~6 min at 2-min poll)
|
||||||
|
|
||||||
@@ -91,16 +93,16 @@ season:
|
|||||||
# 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:
|
||||||
@@ -111,16 +113,3 @@ ems:
|
|||||||
recovery_timeout: "1h" # ignore Shelly state if EMS was down longer
|
recovery_timeout: "1h" # ignore Shelly state if EMS was down longer
|
||||||
override_timeout: "1h" # default lockout when EMS detects external Shelly change
|
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)
|
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
|
|
||||||
|
|||||||
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
|
||||||
@@ -20,16 +20,9 @@ type Config struct {
|
|||||||
Strategic StrategicConfig `yaml:"strategic"`
|
Strategic StrategicConfig `yaml:"strategic"`
|
||||||
Season SeasonConfig `yaml:"season"`
|
Season SeasonConfig `yaml:"season"`
|
||||||
Forecast ForecastConfig `yaml:"forecast"`
|
Forecast ForecastConfig `yaml:"forecast"`
|
||||||
Cars map[string]CarProfile `yaml:"cars"`
|
|
||||||
EMS EMSConfig `yaml:"ems"`
|
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.
|
// PrometheusConfig holds Prometheus connection settings.
|
||||||
type PrometheusConfig struct {
|
type PrometheusConfig struct {
|
||||||
URL string `yaml:"url"`
|
URL string `yaml:"url"`
|
||||||
@@ -166,10 +159,6 @@ type EMSConfig struct {
|
|||||||
RecoveryTimeout string `yaml:"recovery_timeout"`
|
RecoveryTimeout string `yaml:"recovery_timeout"`
|
||||||
OverrideTimeout string `yaml:"override_timeout"`
|
OverrideTimeout string `yaml:"override_timeout"`
|
||||||
OverrideMaxImportW float64 `yaml:"override_max_import_w"` // cancel override if grid import exceeds this (0 = disabled)
|
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 {
|
func (e *EMSConfig) PollIntervalParsed() time.Duration {
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,6 @@ import (
|
|||||||
"github.com/tb/ems/internal/config"
|
"github.com/tb/ems/internal/config"
|
||||||
"github.com/tb/ems/internal/engine"
|
"github.com/tb/ems/internal/engine"
|
||||||
"github.com/tb/ems/internal/forecast"
|
"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.
|
// consumerMeta holds static display info for each consumer.
|
||||||
@@ -41,13 +39,6 @@ type consumerRecord struct {
|
|||||||
livePowerW float64 // from Shelly PM; 0 for non-PM devices
|
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.
|
// Store holds the latest EMS snapshot and is safe for concurrent use.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -59,14 +50,10 @@ type Store struct {
|
|||||||
strategic config.StrategicConfig
|
strategic config.StrategicConfig
|
||||||
consumers map[engine.Consumer]*consumerRecord
|
consumers map[engine.Consumer]*consumerRecord
|
||||||
fcResult *forecast.Result
|
fcResult *forecast.Result
|
||||||
monitorMode *monitor.Mode
|
|
||||||
wwBoostDisabled *monitor.Mode
|
|
||||||
tripMgr *trip.Manager
|
|
||||||
carOptions []CarOption
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStore creates a new status store.
|
// NewStore creates a new status store.
|
||||||
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, mode *monitor.Mode, wwBoostDisabled *monitor.Mode, tm *trip.Manager, cars []CarOption) *Store {
|
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig) *Store {
|
||||||
consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder))
|
consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder))
|
||||||
for _, c := range consumerOrder {
|
for _, c := range consumerOrder {
|
||||||
consumers[c] = &consumerRecord{}
|
consumers[c] = &consumerRecord{}
|
||||||
@@ -76,10 +63,6 @@ func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig,
|
|||||||
wwConfigured: wwConfigured,
|
wwConfigured: wwConfigured,
|
||||||
strategic: strategic,
|
strategic: strategic,
|
||||||
consumers: consumers,
|
consumers: consumers,
|
||||||
monitorMode: mode,
|
|
||||||
wwBoostDisabled: wwBoostDisabled,
|
|
||||||
tripMgr: tm,
|
|
||||||
carOptions: cars,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,8 +118,6 @@ type consumerView struct {
|
|||||||
Reason string
|
Reason string
|
||||||
Unconfigured bool
|
Unconfigured bool
|
||||||
CanOverride bool // true for Shelly consumers (hardware read-back available)
|
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
|
ManualOverride bool
|
||||||
OverrideUntil time.Time
|
OverrideUntil time.Time
|
||||||
LivePowerW float64 // from Shelly PM; 0 for non-PM devices
|
LivePowerW float64 // from Shelly PM; 0 for non-PM devices
|
||||||
@@ -163,26 +144,10 @@ type chargingAdviceView struct {
|
|||||||
WindowEnd string // e.g. "16:00"
|
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 {
|
type pageData struct {
|
||||||
LastUpdate time.Time
|
LastUpdate time.Time
|
||||||
ErrMsg string
|
ErrMsg string
|
||||||
DryRun bool
|
DryRun bool
|
||||||
MonitorOnly bool
|
|
||||||
WWBoostDisabled bool
|
|
||||||
BatterySOC float64
|
BatterySOC float64
|
||||||
GridPowerW float64
|
GridPowerW float64
|
||||||
PVProductionW float64
|
PVProductionW float64
|
||||||
@@ -195,8 +160,6 @@ type pageData struct {
|
|||||||
Forecast forecastView
|
Forecast forecastView
|
||||||
ChargingAdvice chargingAdviceView
|
ChargingAdvice chargingAdviceView
|
||||||
Consumers []consumerView
|
Consumers []consumerView
|
||||||
Trip tripView
|
|
||||||
CarOptions []CarOption
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncConsumerStates updates active flags for all consumers directly from the
|
// SyncConsumerStates updates active flags for all consumers directly from the
|
||||||
@@ -264,10 +227,6 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
monitorOnly := s.monitorMode != nil && s.monitorMode.IsActive()
|
|
||||||
wwBoostDisabled := s.wwBoostDisabled != nil && s.wwBoostDisabled.IsActive()
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
l1, l2, l3 := s.state.PhaseL1PowerW, s.state.PhaseL2PowerW, s.state.PhaseL3PowerW
|
l1, l2, l3 := s.state.PhaseL1PowerW, s.state.PhaseL2PowerW, s.state.PhaseL3PowerW
|
||||||
hasPhase := l1 != 0 || l2 != 0 || l3 != 0
|
hasPhase := l1 != 0 || l2 != 0 || l3 != 0
|
||||||
@@ -275,8 +234,6 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
LastUpdate: s.lastUpdate,
|
LastUpdate: s.lastUpdate,
|
||||||
ErrMsg: s.errMsg,
|
ErrMsg: s.errMsg,
|
||||||
DryRun: s.dryRun,
|
DryRun: s.dryRun,
|
||||||
MonitorOnly: monitorOnly,
|
|
||||||
WWBoostDisabled: wwBoostDisabled,
|
|
||||||
BatterySOC: s.state.BatterySOC,
|
BatterySOC: s.state.BatterySOC,
|
||||||
GridPowerW: s.state.GridPowerW,
|
GridPowerW: s.state.GridPowerW,
|
||||||
PVProductionW: s.state.PVProductionW,
|
PVProductionW: s.state.PVProductionW,
|
||||||
@@ -309,6 +266,7 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
// Charging advice: show when surplus window is ahead and no wallbox is active
|
// Charging advice: show when surplus window is ahead and no wallbox is active
|
||||||
ws := s.fcResult.SurplusWindowStart
|
ws := s.fcResult.SurplusWindowStart
|
||||||
we := s.fcResult.SurplusWindowEnd
|
we := s.fcResult.SurplusWindowEnd
|
||||||
|
now := time.Now()
|
||||||
wallboxActive := s.consumers[engine.ConsumerWallboxA].active ||
|
wallboxActive := s.consumers[engine.ConsumerWallboxA].active ||
|
||||||
s.consumers[engine.ConsumerWallboxB].active
|
s.consumers[engine.ConsumerWallboxB].active
|
||||||
if !ws.IsZero() && now.Before(ws) && !wallboxActive {
|
if !ws.IsZero() && now.Before(ws) && !wallboxActive {
|
||||||
@@ -341,8 +299,6 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
Since: rec.since,
|
Since: rec.since,
|
||||||
Reason: rec.reason,
|
Reason: rec.reason,
|
||||||
CanOverride: c != engine.ConsumerWW,
|
CanOverride: c != engine.ConsumerWW,
|
||||||
IsWW: c == engine.ConsumerWW,
|
|
||||||
WWBoostDisabled: c == engine.ConsumerWW && wwBoostDisabled,
|
|
||||||
ManualOverride: rec.manualOverride,
|
ManualOverride: rec.manualOverride,
|
||||||
OverrideUntil: rec.overrideUntil,
|
OverrideUntil: rec.overrideUntil,
|
||||||
LivePowerW: rec.livePowerW,
|
LivePowerW: rec.livePowerW,
|
||||||
@@ -354,12 +310,6 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
s.mu.RUnlock()
|
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")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := tmpl.Execute(w, data); err != nil {
|
if err := tmpl.Execute(w, data); err != nil {
|
||||||
http.Error(w, "template error", http.StatusInternalServerError)
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
@@ -367,74 +317,6 @@ 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 {
|
func abs(v float64) float64 {
|
||||||
if v < 0 {
|
if v < 0 {
|
||||||
return -v
|
return -v
|
||||||
@@ -486,66 +368,6 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
}
|
}
|
||||||
.banner.warn { background: #fef9c3; color: #854d0e; border: 1px solid #fde68a; }
|
.banner.warn { background: #fef9c3; color: #854d0e; border: 1px solid #fde68a; }
|
||||||
.banner.error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
|
.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 {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -762,14 +584,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
{{end}}
|
{{end}}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{{if .MonitorOnly}}
|
{{if .DryRun}}
|
||||||
<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>
|
<div class="banner warn">⚠️ Testmodus — keine echten Schaltvorgänge</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .ErrMsg}}
|
{{if .ErrMsg}}
|
||||||
@@ -904,22 +719,6 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
{{end}}
|
{{end}}
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
@@ -930,60 +729,5 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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 ({{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>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|||||||
@@ -1,297 +0,0 @@
|
|||||||
// 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:]
|
|
||||||
}
|
|
||||||
303
main.go
303
main.go
@@ -9,8 +9,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -22,9 +20,7 @@ import (
|
|||||||
"github.com/tb/ems/internal/engine"
|
"github.com/tb/ems/internal/engine"
|
||||||
"github.com/tb/ems/internal/forecast"
|
"github.com/tb/ems/internal/forecast"
|
||||||
"github.com/tb/ems/internal/metrics"
|
"github.com/tb/ems/internal/metrics"
|
||||||
"github.com/tb/ems/internal/monitor"
|
|
||||||
"github.com/tb/ems/internal/status"
|
"github.com/tb/ems/internal/status"
|
||||||
"github.com/tb/ems/internal/trip"
|
|
||||||
"github.com/tb/ems/internal/viessmann"
|
"github.com/tb/ems/internal/viessmann"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -107,36 +103,9 @@ func main() {
|
|||||||
reg := prometheus.NewRegistry()
|
reg := prometheus.NewRegistry()
|
||||||
m := metrics.NewMetrics(reg)
|
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)
|
// Status store (shared between HTTP handler and control loop)
|
||||||
wwConfigured := cfg.Viessmann.InstallationID != ""
|
wwConfigured := cfg.Viessmann.InstallationID != ""
|
||||||
statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, monitorMode, wwBoostDisabled, tripMgr, carOptions)
|
statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic)
|
||||||
|
|
||||||
// Metrics HTTP server
|
// Metrics HTTP server
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -146,11 +115,6 @@ func main() {
|
|||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/override", overrideHandler(act, eng, logger))
|
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())
|
mux.HandleFunc("/", statusStore.Handler())
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@@ -179,12 +143,12 @@ func main() {
|
|||||||
logger.Info("EMS control loop started")
|
logger.Info("EMS control loop started")
|
||||||
|
|
||||||
// Run once immediately
|
// Run once immediately
|
||||||
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode, wwBoostDisabled, tripMgr)
|
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode, wwBoostDisabled, tripMgr)
|
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun)
|
||||||
|
|
||||||
case sig := <-sigCh:
|
case sig := <-sigCh:
|
||||||
logger.Info("received signal, shutting down", "signal", sig)
|
logger.Info("received signal, shutting down", "signal", sig)
|
||||||
@@ -210,12 +174,8 @@ func runCycle(
|
|||||||
stateFile string,
|
stateFile string,
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
dryRun bool,
|
dryRun bool,
|
||||||
monitorMode *monitor.Mode,
|
|
||||||
wwBoostDisabled *monitor.Mode,
|
|
||||||
tripMgr *trip.Manager,
|
|
||||||
) {
|
) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
pollMin := cfg.EMS.PollIntervalParsed().Minutes()
|
|
||||||
|
|
||||||
// Step 1: Collect current state from Prometheus
|
// Step 1: Collect current state from Prometheus
|
||||||
state, err := coll.Collect(ctx)
|
state, err := coll.Collect(ctx)
|
||||||
@@ -229,12 +189,10 @@ func runCycle(
|
|||||||
writeHeartbeat(stateFile, logger)
|
writeHeartbeat(stateFile, logger)
|
||||||
|
|
||||||
// Step 1b: Read Shelly states and sync to engine (detects manual overrides).
|
// Step 1b: Read Shelly states and sync to engine (detects manual overrides).
|
||||||
// Keep shellyStates accessible for trip mode energy accumulation.
|
// Partial results are fine — unreachable devices are logged inside ReadAllStates.
|
||||||
var shellyStates map[engine.Consumer]engine.DeviceStatus
|
if shellyStates, err := act.ReadAllStates(ctx); err != nil {
|
||||||
if states, err := act.ReadAllStates(ctx); err != nil {
|
|
||||||
logger.Warn("all Shelly devices unreachable, skipping override detection", "error", err)
|
logger.Warn("all Shelly devices unreachable, skipping override detection", "error", err)
|
||||||
} else {
|
} else {
|
||||||
shellyStates = states
|
|
||||||
eng.SyncHardwareState(shellyStates, now, cfg.EMS.OverrideTimeoutParsed())
|
eng.SyncHardwareState(shellyStates, now, cfg.EMS.OverrideTimeoutParsed())
|
||||||
store.SyncDeviceStates(shellyStates)
|
store.SyncDeviceStates(shellyStates)
|
||||||
}
|
}
|
||||||
@@ -244,8 +202,8 @@ func runCycle(
|
|||||||
m.BatterySOC.Set(state.BatterySOC)
|
m.BatterySOC.Set(state.BatterySOC)
|
||||||
m.PVProductionW.Set(state.PVProductionW)
|
m.PVProductionW.Set(state.PVProductionW)
|
||||||
|
|
||||||
// Track energy flow (rough estimation based on poll interval)
|
// Track energy flow (rough estimation based on 2-min intervals)
|
||||||
intervalHours := pollMin / 60.0
|
intervalHours := 2.0 / 60.0
|
||||||
if state.GridPowerW > 0 {
|
if state.GridPowerW > 0 {
|
||||||
m.GridImportKWh.Add(state.GridPowerW / 1000.0 * intervalHours)
|
m.GridImportKWh.Add(state.GridPowerW / 1000.0 * intervalHours)
|
||||||
} else {
|
} else {
|
||||||
@@ -259,66 +217,11 @@ func runCycle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Run decision engine
|
// Step 3: Run decision engine
|
||||||
wwBoostC := 0.0
|
wwBoostC := computeWWBoost(fcResult, cfg)
|
||||||
if !wwBoostDisabled.IsActive() {
|
|
||||||
wwBoostC = computeWWBoost(fcResult, cfg)
|
|
||||||
}
|
|
||||||
decisionStart := time.Now()
|
decisionStart := time.Now()
|
||||||
actions := eng.Decide(state, now, wwBoostC)
|
actions := eng.Decide(state, now, wwBoostC)
|
||||||
m.DecisionDuration.Observe(time.Since(decisionStart).Seconds())
|
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
|
// Update consumer state metrics
|
||||||
m.UpdateConsumerStates(eng.ConsumerStates())
|
m.UpdateConsumerStates(eng.ConsumerStates())
|
||||||
|
|
||||||
@@ -337,13 +240,9 @@ func runCycle(
|
|||||||
// Step 4: Execute actions
|
// Step 4: Execute actions
|
||||||
m.RecordActions(actions)
|
m.RecordActions(actions)
|
||||||
|
|
||||||
if dryRun || monitorMode.IsActive() {
|
if dryRun {
|
||||||
mode := "DRY RUN"
|
|
||||||
if monitorMode.IsActive() {
|
|
||||||
mode = "MONITOR-ONLY"
|
|
||||||
}
|
|
||||||
for _, a := range actions {
|
for _, a := range actions {
|
||||||
logger.Info(fmt.Sprintf("[%s] would execute", mode),
|
logger.Info("[DRY RUN] would execute",
|
||||||
"consumer", a.Consumer,
|
"consumer", a.Consumer,
|
||||||
"turn_on", a.TurnOn,
|
"turn_on", a.TurnOn,
|
||||||
"reason", a.Reason,
|
"reason", a.Reason,
|
||||||
@@ -357,188 +256,6 @@ func runCycle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// tripConsumer maps a wallbox config key to an engine Consumer.
|
|
||||||
func tripConsumer(wallbox string) engine.Consumer {
|
|
||||||
if wallbox == "wallbox_b" {
|
|
||||||
return engine.ConsumerWallboxB
|
|
||||||
}
|
|
||||||
return engine.ConsumerWallboxA
|
|
||||||
}
|
|
||||||
|
|
||||||
// tripRatedKW returns the configured rated power for the given consumer in kW.
|
|
||||||
func tripRatedKW(cfg *config.Config, c engine.Consumer) float64 {
|
|
||||||
if c == engine.ConsumerWallboxB {
|
|
||||||
return float64(cfg.Shelly.WallboxB.PowerW) / 1000.0
|
|
||||||
}
|
|
||||||
return float64(cfg.Shelly.WallboxA.PowerW) / 1000.0
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildCarOptions returns a sorted list of car options for the status page form.
|
|
||||||
func buildCarOptions(cars map[string]config.CarProfile) []status.CarOption {
|
|
||||||
keys := make([]string, 0, len(cars))
|
|
||||||
for k := range cars {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
opts := make([]status.CarOption, 0, len(keys))
|
|
||||||
for _, k := range keys {
|
|
||||||
c := cars[k]
|
|
||||||
opts = append(opts, status.CarOption{Key: k, Name: c.Name, BatteryKWh: c.BatteryKWh})
|
|
||||||
}
|
|
||||||
return opts
|
|
||||||
}
|
|
||||||
|
|
||||||
// tripSetHandler handles the trip mode form submission.
|
|
||||||
func tripSetHandler(tm *trip.Manager, cfg *config.Config, logger *slog.Logger) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
carKey := r.FormValue("car")
|
|
||||||
wallbox := r.FormValue("wallbox")
|
|
||||||
socStr := r.FormValue("current_soc")
|
|
||||||
deadlineStr := r.FormValue("deadline") // "2006-01-02T15:04"
|
|
||||||
|
|
||||||
car, ok := cfg.Cars[carKey]
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "unknown car", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if wallbox != "wallbox_a" && wallbox != "wallbox_b" {
|
|
||||||
http.Error(w, "unknown wallbox", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
soc, err := strconv.ParseFloat(socStr, 64)
|
|
||||||
if err != nil || soc < 0 || soc >= 100 {
|
|
||||||
http.Error(w, "invalid SOC (must be 0–99)", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
deadline, err := time.ParseInLocation("2006-01-02T15:04", deadlineStr, time.Local)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "invalid deadline format", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if deadline.Before(time.Now()) {
|
|
||||||
http.Error(w, "deadline is in the past", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
goal := trip.Goal{
|
|
||||||
Wallbox: wallbox,
|
|
||||||
CarName: car.Name,
|
|
||||||
BatteryKWh: car.BatteryKWh,
|
|
||||||
CurrentSOC: soc,
|
|
||||||
Deadline: deadline,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
if err := tm.SetGoal(goal); err != nil {
|
|
||||||
logger.Error("failed to save trip goal", "error", err)
|
|
||||||
http.Error(w, "could not save goal — check logs", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.Info("trip goal set",
|
|
||||||
"wallbox", wallbox,
|
|
||||||
"car", car.Name,
|
|
||||||
"soc", soc,
|
|
||||||
"deadline", deadline.Format("02.01. 15:04"),
|
|
||||||
)
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// tripCancelHandler clears the active trip goal.
|
|
||||||
func tripCancelHandler(tm *trip.Manager, logger *slog.Logger) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := tm.ClearGoal(); err != nil {
|
|
||||||
logger.Error("failed to clear trip goal", "error", err)
|
|
||||||
}
|
|
||||||
logger.Info("trip goal cancelled by user")
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
// shouldRecover checks the heartbeat file to decide whether to read back
|
||||||
// Shelly states on startup. Returns false if the file is missing (first run)
|
// Shelly states on startup. Returns false if the file is missing (first run)
|
||||||
// or older than the recovery timeout.
|
// or older than the recovery timeout.
|
||||||
|
|||||||
Reference in New Issue
Block a user