2 Commits

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:19:30 +02:00
32 changed files with 628 additions and 2540 deletions

View File

@@ -1,133 +0,0 @@
name: CI/CD
on:
push:
pull_request:
workflow_dispatch:
# Registry on the local Gitea instance (HTTP — add to Docker insecure-registries if needed)
env:
REGISTRY: 192.168.0.234:8765
# Deployment directory on the host — compose file lives here so relative
# volume paths (./infra/traefik/...) resolve correctly on the host.
DEPLOY_DIR: /opt/ems
jobs:
# ── Test ──────────────────────────────────────────────────────────────────
# Runs on every push and every PR.
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Go 1.23
run: |
wget -qO go.tar.gz https://go.dev/dl/go1.23.4.linux-arm64.tar.gz
sudo tar -C /usr/local -xzf go.tar.gz
rm go.tar.gz
echo "/usr/local/go/bin" >> $GITHUB_PATH
- name: Run unit tests
run: go test ./...
- name: Build binary (compile check)
run: CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /dev/null .
# ── Build & Push ──────────────────────────────────────────────────────────
# Builds the Docker image and pushes it to the Gitea container registry.
# Runs on house/lutz and main; skipped on other branches and PRs.
#
# Required secret:
# GITEA_TOKEN — personal access token with write:packages permission
build-push:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/house/lutz' || github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea registry
run: |
echo "${{ secrets.MY_GITEA_CI_TOKEN }}" | \
docker login ${{ env.REGISTRY }} -u ${{ gitea.actor }} --password-stdin
- name: Build and push image
run: |
IMAGE=${{ env.REGISTRY }}/${{ gitea.repository_owner }}/ems
docker build \
-t ${IMAGE}:${{ gitea.sha }} \
-t ${IMAGE}:latest \
.
docker push ${IMAGE}:${{ gitea.sha }}
docker push ${IMAGE}:latest
echo "IMAGE=${IMAGE}:${{ gitea.sha }}" >> $GITHUB_ENV
# ── Deploy ────────────────────────────────────────────────────────────────
# Syncs compose + infra files to DEPLOY_DIR on the host, then restarts
# only the EMS container. Traefik keeps running untouched.
#
# Uses a docker:cli helper container so that:
# - relative paths in docker-compose.yml resolve from DEPLOY_DIR on the host
# - Docker socket gives access to the host daemon from within the job container
deploy:
needs: build-push
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/house/lutz' || github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea registry
run: |
echo "${{ secrets.MY_GITEA_CI_TOKEN }}" | \
docker login ${{ env.REGISTRY }} -u ${{ gitea.actor }} --password-stdin
- name: Sync files and restart EMS
run: |
IMAGE=${{ env.REGISTRY }}/${{ gitea.repository_owner }}/ems:${{ gitea.sha }}
SHA=${{ gitea.sha }}
REPO_URL=http://x:${{ secrets.MY_GITEA_CI_TOKEN }}@192.168.0.234:8765/${{ gitea.repository }}.git
# The helper container mounts /opt/ems from the HOST (absolute path — works).
# It clones/updates the repo there so relative paths in docker-compose.yml
# (./infra/traefik/...) resolve correctly when Docker Compose runs.
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ${{ env.DEPLOY_DIR }}:${{ env.DEPLOY_DIR }} \
-e EMS_IMAGE=${IMAGE} \
docker:cli sh -c "
set -e
apk add -q git
git config --global --add safe.directory ${{ env.DEPLOY_DIR }}
if [ -d ${{ env.DEPLOY_DIR }}/.git ]; then
git -C ${{ env.DEPLOY_DIR }} fetch --quiet
else
git clone --quiet ${REPO_URL} ${{ env.DEPLOY_DIR }}
fi
git -C ${{ env.DEPLOY_DIR }} checkout --quiet ${SHA}
cd ${{ env.DEPLOY_DIR }}
docker compose pull ems
docker compose up -d ems
"
- name: Verify health
run: |
sleep 10
# 1. EMS alive — direct check via internal Docker network
echo -n "EMS: "
docker run --rm --network ems_ems-internal alpine \
wget -qO- http://ems:9099/health && echo " — OK"
# 2. Traefik alive — ping endpoint (no mTLS required)
echo -n "Traefik: "
docker run --rm --network host alpine \
wget -qO- http://127.0.0.1:8082/ping && echo " — OK"
# 3. TCP reachability of the mTLS entrypoint from LAN IP
echo -n "Port 9443: "
docker run --rm --network host alpine sh -c \
"nc -zw5 192.168.0.152 9443 && echo reachable || echo UNREACHABLE"

View File

@@ -1,19 +0,0 @@
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o ems .
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/ems .
# Web UI port (put behind Synology HTTPS reverse proxy)
EXPOSE 9099
# Prometheus metrics port (keep internal — do not expose via reverse proxy)
EXPOSE 9100
ENTRYPOINT ["/app/ems"]
CMD ["-config", "/etc/ems/ems-config.yaml"]

View File

@@ -1 +0,0 @@
0.9.3

View File

@@ -1,52 +1,52 @@
# 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)
phase_l3_power: "pcc_ac_active_power_phaseThree" # per-phase grid power L3 (W) phase_l3_power: "pcc_ac_active_power_phaseThree" # per-phase grid power L3 (W)
ww_top_temp: "heating_dhw_sensors_temperature_dhwCylinder_top_value"
pv_yield_today: "photovoltaic_production_cumulated_currentDay" # Wh, converted to kWh by collector (÷1000) # DHW cylinder top temperature (°C)
# 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: "/var/lib/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
@@ -70,9 +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_max_setpoint_c: 60 # absolute maximum WW setpoint (°C)
ww_hysteresis_c: 5 # Viessmann switchOn = setpoint - hysteresis (°C)
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
@@ -82,12 +80,9 @@ 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)
sg_ready_startup_min: 10 # grace period after SG-Ready activation before compressor idle check starts (min)
wallbox_a_accepted_import_w: 0 # tolerate this much grid import while WallboxA is running (0 = disabled; WallboxA is 2kW, inverter headroom is sufficient)
wallbox_b_accepted_import_w: 600 # tolerate up to 600W import while WallboxB runs (4kW wallbox + 450W house > 4.6kW inverter max on PV dips)
# Season detection # Season detection
season: season:
@@ -98,56 +93,23 @@ 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:
poll_interval: "2m" poll_interval: "2m"
listen_addr: ":9099" # web UI (login-protected; put behind Synology HTTPS proxy for internet access) listen_addr: ":9099" # Prometheus metrics endpoint
metrics_addr: ":9101" # Prometheus /metrics (no auth — keep internal, do not expose to internet)
# Note: :9100 is typically taken by prometheus-node-exporter
log_level: "info" log_level: "info"
http_username: "ems" # login username (empty = no auth required)
http_password: "changeme" # login password — change this; config must be chmod 600
state_file: "/run/ems/heartbeat" # written each cycle for state recovery state_file: "/run/ems/heartbeat" # written each cycle for state recovery
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: "/var/lib/ems/monitor-only" # presence of this file = monitor-only mode active
ww_boost_disable_file: "/var/lib/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
# Battery physical properties (for EOD soft-stop calculation)
battery:
capacity_kwh: 8.0 # usable battery capacity (kWh)
# Proactive car charging strategy (forecast-driven, no export threshold required)
car_charging:
min_soc: 25 # start car charging only if battery SOC ≥ this (%)
soc_floor: 5 # emergency brake floor — stop if SOC drops below this (%)
soc_high_bypass_pct: 90 # activate without forecast check when SOC ≥ this % (battery full → always charge)
eod_soc_target: 90 # desired battery SOC at end of day / sunset (%)
eod_time: "16:00" # start EOD soft-stop checks after this time
no_car_retry_min: 30 # minutes before retrying after no-car detection
pv_threshold_a_w: 1000 # min PV production to start WallboxA (W) — 50% of 2kW rated
pv_threshold_b_w: 2000 # min PV production to start WallboxB (W) — 50% of 4kW rated
grid_delta_thresh_w: 2000 # min grid power shift after WallboxB activation = car detected (W)
# 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
View File

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

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

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

View File

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

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

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -1,59 +0,0 @@
networks:
ems-internal:
driver: bridge
services:
# ── Traefik ─────────────────────────────────────────────────────────────────
# TLS termination + mTLS on port 9443.
# Perimeter firewall forwards external TCP 9443 → Synology:9443.
# Only connections presenting a client cert signed by /certs/ca.crt are forwarded.
traefik:
image: traefik:v3.1
restart: unless-stopped
ports:
- "9443:9443" # HTTPS + mTLS — open on perimeter firewall
- "127.0.0.1:8082:8082" # Traefik ping — LAN only, never expose externally
volumes:
- ./infra/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./infra/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
# Certs: ca.crt, server.crt, server.key (generated by infra/ca/ scripts)
- /etc/ems/certs:/certs:ro
networks:
- ems-internal
depends_on:
- ems
# ── EMS ─────────────────────────────────────────────────────────────────────
# Web UI is NOT exposed externally — Traefik proxies to :9099 internally.
# Prometheus metrics on :9101 stay LAN-accessible for the existing scrape job.
#
# Image source:
# CI deploy: EMS_IMAGE=192.168.0.234:8765/owner/ems:sha docker compose up -d ems
# Local dev: docker compose up --build
ems:
image: ${EMS_IMAGE:-ems:latest}
build: .
restart: unless-stopped
ports:
# Metrics — Prometheus scrape from LAN (192.168.0.23:9090)
# Do NOT route through Traefik (no auth on metrics endpoint by design).
- "9101:9101"
volumes:
# Config (read-only) — must be chmod 600
- /etc/ems:/etc/ems:ro
# Persistent state: trip goals, sessions, auth tokens
- /var/lib/ems:/var/lib/ems
# Heartbeat file
- /run/ems:/run/ems
networks:
- ems-internal
environment:
- TZ=Europe/Berlin
# Health check using the unauthenticated /health endpoint
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:9099/health"]
interval: 30s
timeout: 5s
retries: 3

12
infra/ca/.gitignore vendored
View File

@@ -1,12 +0,0 @@
# Private keys and device certs — NEVER commit
*.key
*.p12
*.csr
*.srl
# ca.crt and server.crt are public — safe to commit if desired,
# but kept out by default so the repo doesn't become a cert store.
# Uncomment the next two lines to allow committing public certs:
# !ca.crt
# !server.crt
*.crt

View File

@@ -1,94 +0,0 @@
# iPhone Enrollment Guide
Two steps: first install the CA as a trusted root, then install your personal client cert.
Both must be done before Safari can reach the EMS.
---
## Step 1 — Install the CA cert (trust anchor)
Do this once. It makes your iPhone trust the EMS server cert and accept the client cert.
### On your Mac / Linux machine (in the `infra/ca/` directory):
```bash
# Serve ca.crt temporarily on the LAN
python3 -m http.server 8080
```
### On the iPhone (Safari — not Chrome):
1. Open **Safari** and navigate to `http://192.168.x.x:8080/ca.crt`
(replace with your Mac's LAN IP — check with `ifconfig | grep 192.168`)
2. Safari shows: *"This website is trying to download a configuration profile. Do you want to allow this?"* → **Allow**
3. Open **Settings** → you will see a banner: **Profile Downloaded** → tap it → **Install**
4. Enter your iPhone passcode if prompted → **Install** (top right) → **Install** again to confirm
5. Go to **Settings → General → About → Certificate Trust Settings**
6. Under *"Enable Full Trust For Root Certificates"*, toggle **EMS Private CA** → **Continue**
The CA is now trusted. You can stop the Python server.
---
## Step 2 — Install the client cert
Do this once per device. Generate the cert first if you haven't:
```bash
cd infra/ca
./issue-client-cert.sh lutz-iphone
```
Then transfer `lutz-iphone.p12` to the iPhone. The easiest ways:
**AirDrop (recommended):**
1. On Mac: right-click `lutz-iphone.p12` → Share → AirDrop → select your iPhone
2. On iPhone: tap Accept
3. Tap the received file → **Settings** opens automatically
4. **Settings → Profile Downloaded** → **Install** → enter PKCS12 password → **Install**
**Alternatively via Files / Mail / Notes:**
- Share the `.p12` file to yourself via any app, then tap it to trigger profile installation.
---
## Step 3 — Test
1. Open **Safari** on the iPhone
2. Navigate to `https://ems.famfi.dyndns.org:9443`
3. Safari will prompt: *"ems.famfi.dyndns.org" wants to use "lutz-iphone EMS"* → **Continue**
4. The EMS login page should appear — no certificate warning
---
## Revoking a device
There is no CRL/OCSP for this private CA (not needed for a home setup).
To revoke a device:
1. On the iPhone: **Settings → General → VPN & Device Management** → select the EMS profile → **Remove**
2. Generate a new CA (`gen-ca.sh`) and re-enroll all remaining devices, OR
regenerate only the server cert and client certs — a revoked client cert is still technically valid
until the Traefik config is updated to exclude it by CN.
For a home setup with 1-2 devices, deleting the profile from the device is sufficient protection.
---
## Cert renewal (annually)
Server and client certs are valid for 825 days (~2.25 years). When they approach expiry:
```bash
cd infra/ca
./gen-server-cert.sh # new server cert
scp server.crt server.key user@synology:/etc/ems/certs/
ssh user@synology "cd /opt/ems && docker compose restart traefik"
./issue-client-cert.sh lutz-iphone # new client cert — repeat enrollment Step 2
```
The CA itself is valid for 10 years. Its expiry date:
```bash
openssl x509 -noout -dates -in infra/ca/ca.crt
```

View File

@@ -1,40 +0,0 @@
#!/usr/bin/env bash
# gen-ca.sh — Generate the EMS root CA (one-time operation).
#
# Outputs: ca.key (KEEP SECRET) and ca.crt (distribute to all devices).
# ca.key must be backed up encrypted and kept off the Synology.
# Losing ca.key means all devices must re-enroll after generating a new CA.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
if [[ -f ca.key ]]; then
echo "ERROR: ca.key already exists."
echo "Delete it explicitly if you intend to replace the CA."
echo "WARNING: a new CA invalidates ALL existing server and client certs — every device must re-enroll."
exit 1
fi
echo "Generating EMS root CA (RSA-4096, 10 years)..."
openssl req -x509 -newkey rsa:4096 -sha256 \
-days 3650 \
-keyout ca.key \
-out ca.crt \
-nodes \
-subj "/CN=EMS Private CA/O=FamFi/C=DE" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-addext "subjectKeyIdentifier=hash"
chmod 600 ca.key
echo ""
echo "Done."
echo " ca.crt — distribute to devices (install as trusted root)"
echo " ca.key — KEEP SECRET: store in encrypted backup, remove from Synology after cert issuance"
echo ""
echo "Next steps:"
echo " ./gen-server-cert.sh # server cert for Traefik"
echo " ./issue-client-cert.sh <name> # one per device"

View File

@@ -1,47 +0,0 @@
#!/usr/bin/env bash
# gen-server-cert.sh — Generate the Traefik server cert for ems.famfi.dyndns.org.
#
# Signed by the private CA. Once ca.crt is trusted on a device,
# this cert is accepted without warnings.
# Renew annually (before 825-day expiry) by re-running this script.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
DOMAIN="${1:-ems.famfi.dyndns.org}"
if [[ ! -f ca.key ]]; then
echo "ERROR: ca.key not found. Run gen-ca.sh first."
exit 1
fi
echo "Generating server cert for ${DOMAIN}..."
openssl req -newkey rsa:2048 -nodes \
-keyout server.key \
-out server.csr \
-subj "/CN=${DOMAIN}/O=FamFi/C=DE"
openssl x509 -req \
-in server.csr \
-CA ca.crt \
-CAkey ca.key \
-CAcreateserial \
-out server.crt \
-days 825 \
-sha256 \
-extfile <(printf "basicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:%s\nsubjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid" "$DOMAIN")
chmod 600 server.key
rm -f server.csr
echo ""
echo "Done: server.crt + server.key for ${DOMAIN}"
echo ""
echo "Deploy certs to Synology (run from infra/ca/):"
echo " ssh user@synology 'mkdir -p /etc/ems/certs'"
echo " scp ca.crt server.crt server.key user@synology:/etc/ems/certs/"
echo " ssh user@synology 'chmod 600 /etc/ems/certs/server.key'"
echo ""
echo "Traefik will pick up the new cert on next container restart."

View File

@@ -1,57 +0,0 @@
#!/usr/bin/env bash
# issue-client-cert.sh — Issue a client cert for one device.
#
# Usage: ./issue-client-cert.sh <device-name>
# Example: ./issue-client-cert.sh lutz-iphone
#
# Outputs <device-name>.p12 — install on the device after enrolling ca.crt.
# See enroll-iphone.md for the full iPhone enrollment flow.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
NAME="${1:?Usage: $0 <device-name> e.g.: $0 lutz-iphone}"
if [[ ! -f ca.key ]]; then
echo "ERROR: ca.key not found. Run gen-ca.sh first."
exit 1
fi
if [[ -f "${NAME}.p12" ]]; then
echo "WARNING: ${NAME}.p12 already exists. Overwriting."
fi
echo "Issuing client cert for: ${NAME}"
echo "You will be prompted for a PKCS12 export password."
echo "Use a strong password — you will need it during iPhone installation."
echo ""
openssl req -newkey rsa:2048 -nodes \
-keyout "${NAME}.key" \
-out "${NAME}.csr" \
-subj "/CN=${NAME}/O=FamFi/C=DE"
openssl x509 -req \
-in "${NAME}.csr" \
-CA ca.crt \
-CAkey ca.key \
-CAcreateserial \
-out "${NAME}.crt" \
-days 825 \
-sha256 \
-extfile <(printf "basicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=clientAuth\nsubjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid")
openssl pkcs12 -export \
-out "${NAME}.p12" \
-inkey "${NAME}.key" \
-in "${NAME}.crt" \
-certfile ca.crt \
-name "${NAME} EMS"
chmod 600 "${NAME}.key" "${NAME}.p12"
rm -f "${NAME}.csr" "${NAME}.crt"
echo ""
echo "Done: ${NAME}.p12"
echo "AirDrop to iPhone, then follow enroll-iphone.md."

View File

@@ -1,37 +0,0 @@
# Traefik dynamic configuration — hot-reloaded by Traefik on change.
#
# TLS: private CA server cert + mandatory client cert (mTLS).
# Any connection without a valid client cert signed by ca.crt is rejected
# at the TLS handshake — before any HTTP reaches EMS.
tls:
certificates:
- certFile: /certs/server.crt
keyFile: /certs/server.key
options:
mtls:
clientAuth:
caFiles:
- /certs/ca.crt
clientAuthType: RequireAndVerifyClientCert
# Minimum TLS 1.2; prefer 1.3
minVersion: VersionTLS12
sniStrict: true
http:
routers:
ems:
rule: "Host(`ems.famfi.dyndns.org`)"
entryPoints:
- websecure
tls:
options: mtls
service: ems
services:
ems:
loadBalancer:
servers:
- url: "http://ems:9099"
passHostHeader: true

View File

@@ -1,24 +0,0 @@
# Traefik static configuration
# Handles TLS termination on port 9443 with private CA mTLS.
# Dynamic routing config is in dynamic.yml (hot-reloaded on change).
entryPoints:
websecure:
address: ":9443"
ping:
address: ":8082" # internal health/ping — not exposed externally
providers:
file:
filename: /etc/traefik/dynamic.yml
watch: true # reload dynamic.yml without container restart
ping:
entryPoint: ping # GET http://localhost:8082/ping → 200 OK when Traefik is up
log:
level: INFO
accessLog:
filePath: "/dev/stdout"
format: common

View File

@@ -38,30 +38,26 @@ func NewActuator(cfg *config.Config, vc *viessmann.Client, logger *slog.Logger)
} }
} }
// Execute performs a list of switching actions and returns one error per action // Execute performs a list of switching actions.
// (nil on success). Failed actions do not prevent subsequent actions from running. func (a *Actuator) Execute(ctx context.Context, actions []engine.Action) error {
// The caller should roll back engine state for any failed action using for _, action := range actions {
// Engine.RollbackAction so that the next SyncHardwareState cycle does not mistake
// the divergence for a manual override.
func (a *Actuator) Execute(ctx context.Context, actions []engine.Action) []error {
errs := make([]error, len(actions))
for i, action := range actions {
if err := a.executeOne(ctx, action); err != nil { if err := a.executeOne(ctx, action); err != nil {
a.logger.Error("action failed", a.logger.Error("action failed",
"consumer", action.Consumer, "consumer", action.Consumer,
"turn_on", action.TurnOn, "turn_on", action.TurnOn,
"error", err, "error", err,
) )
errs[i] = err // Continue with other actions even if one fails
continue continue
} }
a.logger.Info("action executed", a.logger.Info("action executed",
"consumer", action.Consumer, "consumer", action.Consumer,
"turn_on", action.TurnOn, "turn_on", action.TurnOn,
"reason", action.Reason, "reason", action.Reason,
) )
} }
return errs return nil
} }
func (a *Actuator) executeOne(ctx context.Context, action engine.Action) error { func (a *Actuator) executeOne(ctx context.Context, action engine.Action) error {

View File

@@ -88,11 +88,11 @@ func TestExecuteTurnOnSGReady(t *testing.T) {
act := testActuator(t, ipFrom(sgSrv), ipFrom(dummySrv), ipFrom(dummySrv)) act := testActuator(t, ipFrom(sgSrv), ipFrom(dummySrv), ipFrom(dummySrv))
errs := act.Execute(context.Background(), []engine.Action{ err := act.Execute(context.Background(), []engine.Action{
{Consumer: engine.ConsumerSGReady, TurnOn: true, Reason: "test"}, {Consumer: engine.ConsumerSGReady, TurnOn: true, Reason: "test"},
}) })
if errs[0] != nil { if err != nil {
t.Fatalf("Execute failed: %v", errs[0]) t.Fatalf("Execute failed: %v", err)
} }
if !sg.state { if !sg.state {
@@ -112,11 +112,11 @@ func TestExecuteTurnOffWallboxA(t *testing.T) {
act := testActuator(t, ipFrom(dummySrv), ipFrom(wbASrv), ipFrom(dummySrv)) act := testActuator(t, ipFrom(dummySrv), ipFrom(wbASrv), ipFrom(dummySrv))
errs := act.Execute(context.Background(), []engine.Action{ err := act.Execute(context.Background(), []engine.Action{
{Consumer: engine.ConsumerWallboxA, TurnOn: false, Reason: "import"}, {Consumer: engine.ConsumerWallboxA, TurnOn: false, Reason: "import"},
}) })
if errs[0] != nil { if err != nil {
t.Fatalf("Execute failed: %v", errs[0]) t.Fatalf("Execute failed: %v", err)
} }
if wbA.state { if wbA.state {
@@ -164,14 +164,13 @@ func TestExecuteMultipleActions(t *testing.T) {
act := testActuator(t, ipFrom(sgSrv), ipFrom(wbASrv), ipFrom(wbBSrv)) act := testActuator(t, ipFrom(sgSrv), ipFrom(wbASrv), ipFrom(wbBSrv))
for i, err := range act.Execute(context.Background(), []engine.Action{ err := act.Execute(context.Background(), []engine.Action{
{Consumer: engine.ConsumerSGReady, TurnOn: true}, {Consumer: engine.ConsumerSGReady, TurnOn: true},
{Consumer: engine.ConsumerWallboxA, TurnOn: true}, {Consumer: engine.ConsumerWallboxA, TurnOn: true},
{Consumer: engine.ConsumerWallboxB, TurnOn: true}, {Consumer: engine.ConsumerWallboxB, TurnOn: true},
}) { })
if err != nil { if err != nil {
t.Fatalf("Execute action %d failed: %v", i, err) t.Fatalf("Execute failed: %v", err)
}
} }
if !sg.state || !wbA.state || !wbB.state { if !sg.state || !wbA.state || !wbB.state {

View File

@@ -1,221 +0,0 @@
// Package auth provides session-based authentication and CSRF protection
// for the EMS web UI. Both the session token and CSRF token are persisted
// to disk so they survive EMS restarts without logging out open browser tabs.
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"html/template"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
)
const (
cookieName = "ems_session"
csrfField = "csrf_token"
sessionFile = "session.token"
csrfFile = "csrf.token"
)
// Manager handles login, session cookies, and CSRF tokens.
type Manager struct {
username string
password string
tokenDir string
mu sync.RWMutex
session string
csrf string
logger *slog.Logger
}
// New creates a Manager and loads or generates persistent tokens from tokenDir.
// If password is empty, authentication is disabled (open access).
func New(username, password, tokenDir string, logger *slog.Logger) *Manager {
m := &Manager{
username: username,
password: password,
tokenDir: tokenDir,
logger: logger,
}
m.session = m.loadOrGenerate(sessionFile)
m.csrf = m.loadOrGenerate(csrfFile)
return m
}
// Enabled reports whether authentication is active.
func (m *Manager) Enabled() bool { return m.password != "" }
// CSRFToken returns the persistent CSRF token to embed in HTML forms.
func (m *Manager) CSRFToken() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.csrf
}
// RequireAuth is middleware that redirects unauthenticated requests to /login.
// Exempt paths: /login, /health (health checks don't need auth).
func (m *Manager) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !m.Enabled() || r.URL.Path == "/login" || r.URL.Path == "/health" {
next.ServeHTTP(w, r)
return
}
cookie, err := r.Cookie(cookieName)
if err != nil || !m.validSession(cookie.Value) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
// VerifyCSRF is middleware that checks the csrf_token field on POST requests.
// On failure it redirects to / so the user gets a fresh form — avoids a
// confusing error page after an EMS restart regenerates the token.
func (m *Manager) VerifyCSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if m.Enabled() && r.Method == http.MethodPost && r.URL.Path != "/login" && r.URL.Path != "/health" {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
got := r.FormValue(csrfField)
m.mu.RLock()
ok := subtle.ConstantTimeCompare([]byte(got), []byte(m.csrf)) == 1
m.mu.RUnlock()
if !ok {
// Redirect to status page so the user gets a fresh form.
m.logger.Warn("CSRF token mismatch — redirecting to status page", "path", r.URL.Path)
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
}
next.ServeHTTP(w, r)
})
}
// LoginHandler serves GET /login (form) and POST /login (credential check).
func (m *Manager) LoginHandler() http.HandlerFunc {
tmpl := template.Must(template.New("login").Parse(loginTemplate))
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
tmpl.Execute(w, nil) //nolint:errcheck
return
}
if r.Method != http.MethodPost {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
user := r.FormValue("username")
pass := r.FormValue("password")
uOK := subtle.ConstantTimeCompare([]byte(user), []byte(m.username)) == 1
pOK := subtle.ConstantTimeCompare([]byte(pass), []byte(m.password)) == 1
if !uOK || !pOK {
m.logger.Warn("failed login attempt", "remote_addr", r.RemoteAddr)
tmpl.Execute(w, "Ungültige Zugangsdaten") //nolint:errcheck
return
}
m.mu.RLock()
session := m.session
m.mu.RUnlock()
// Set Secure flag when arriving via HTTPS reverse proxy.
secure := r.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: session,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
m.logger.Info("web UI login", "remote_addr", r.RemoteAddr)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
// LogoutHandler clears the session cookie and redirects to /login.
func (m *Manager) LogoutHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
m.logger.Info("web UI logout", "remote_addr", r.RemoteAddr)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
}
func (m *Manager) validSession(token string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
return subtle.ConstantTimeCompare([]byte(token), []byte(m.session)) == 1
}
func (m *Manager) loadOrGenerate(filename string) string {
path := filepath.Join(m.tokenDir, filename)
if data, err := os.ReadFile(path); err == nil {
if t := strings.TrimSpace(string(data)); len(t) == 64 {
return t
}
}
token := newToken()
if err := os.MkdirAll(m.tokenDir, 0o700); err == nil {
_ = os.WriteFile(path, []byte(token), 0o600)
}
return token
}
func newToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("auth: cannot generate random token: " + err.Error())
}
return hex.EncodeToString(b)
}
const loginTemplate = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>EMS · Anmelden</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#f1f5f9;display:flex;align-items:center;justify-content:center;min-height:100vh}
.card{background:#fff;border-radius:1rem;box-shadow:0 2px 12px rgba(0,0,0,.1);padding:2rem;width:100%;max-width:360px}
h1{font-size:1.4rem;margin-bottom:1.5rem;color:#0f172a;text-align:center}
label{display:block;font-size:.85rem;color:#64748b;margin-bottom:.3rem;margin-top:1rem}
input{width:100%;padding:.65rem .8rem;border:1px solid #cbd5e1;border-radius:.5rem;font-size:1rem;outline:none}
input:focus{border-color:#3b82f6;box-shadow:0 0 0 2px #bfdbfe}
button{width:100%;margin-top:1.5rem;padding:.75rem;background:#2563eb;color:#fff;border:none;border-radius:.5rem;font-size:1rem;cursor:pointer}
button:hover{background:#1d4ed8}
.error{background:#fee2e2;color:#b91c1c;border-radius:.5rem;padding:.6rem .9rem;margin-top:1rem;font-size:.9rem;text-align:center}
</style>
</head>
<body>
<div class="card">
<h1>☀️ Solar EMS</h1>
<form method="post" action="/login">
<label for="u">Benutzer</label>
<input type="text" id="u" name="username" autocomplete="username" required autofocus>
<label for="p">Passwort</label>
<input type="password" id="p" name="password" autocomplete="current-password" required>
{{if .}}<div class="error">{{.}}</div>{{end}}
<button type="submit">Anmelden</button>
</form>
</div>
</body>
</html>`

View File

@@ -26,8 +26,6 @@ type SystemState struct {
PhaseL1PowerW float64 // per-phase grid power L1 (positive=import, negative=export) PhaseL1PowerW float64 // per-phase grid power L1 (positive=import, negative=export)
PhaseL2PowerW float64 // per-phase grid power L2 PhaseL2PowerW float64 // per-phase grid power L2
PhaseL3PowerW float64 // per-phase grid power L3 PhaseL3PowerW float64 // per-phase grid power L3
WWTopTempC float64 // DHW cylinder top temperature (°C)
PVYieldTodayKWh float64 // cumulative PV production today (kWh)
} }
// IsExporting returns true if the system is exporting to grid. // IsExporting returns true if the system is exporting to grid.
@@ -93,8 +91,6 @@ func (c *Collector) Collect(ctx context.Context) (SystemState, error) {
{"phase_l1_power", &state.PhaseL1PowerW, 1}, {"phase_l1_power", &state.PhaseL1PowerW, 1},
{"phase_l2_power", &state.PhaseL2PowerW, 1}, {"phase_l2_power", &state.PhaseL2PowerW, 1},
{"phase_l3_power", &state.PhaseL3PowerW, 1}, {"phase_l3_power", &state.PhaseL3PowerW, 1},
{"ww_top_temp", &state.WWTopTempC, 1},
{"pv_yield_today", &state.PVYieldTodayKWh, 0.001}, // Wh → kWh
} }
for _, t := range targets { for _, t := range targets {
@@ -120,13 +116,10 @@ func (c *Collector) Collect(ctx context.Context) (SystemState, error) {
"grid_w", state.GridPowerW, "grid_w", state.GridPowerW,
"soc", state.BatterySOC, "soc", state.BatterySOC,
"pv_w", state.PVProductionW, "pv_w", state.PVProductionW,
"pv_today_kwh", fmt.Sprintf("%.1f", state.PVYieldTodayKWh),
"compressor_w", state.CompressorPowerW, "compressor_w", state.CompressorPowerW,
"ambient_c", state.AmbientTempC,
"l1_w", state.PhaseL1PowerW, "l1_w", state.PhaseL1PowerW,
"l2_w", state.PhaseL2PowerW, "l2_w", state.PhaseL2PowerW,
"l3_w", state.PhaseL3PowerW, "l3_w", state.PhaseL3PowerW,
"ww_top_c", state.WWTopTempC,
) )
return state, nil return state, nil

View File

@@ -2,7 +2,6 @@ package config
import ( import (
"fmt" "fmt"
"net"
"os" "os"
"time" "time"
@@ -11,50 +10,17 @@ import (
// Config is the top-level EMS configuration. // Config is the top-level EMS configuration.
type Config struct { type Config struct {
Prometheus PrometheusConfig `yaml:"prometheus"` Prometheus PrometheusConfig `yaml:"prometheus"`
Shelly ShellyConfig `yaml:"shelly"` Shelly ShellyConfig `yaml:"shelly"`
Viessmann ViessmannConfig `yaml:"viessmann"` Viessmann ViessmannConfig `yaml:"viessmann"`
SOC SOCThresholds `yaml:"soc_thresholds"` SOC SOCThresholds `yaml:"soc_thresholds"`
Hysteresis HysteresisConfig `yaml:"hysteresis"` Hysteresis HysteresisConfig `yaml:"hysteresis"`
Thresholds PowerThresholds `yaml:"thresholds"` Thresholds PowerThresholds `yaml:"thresholds"`
Consumers ConsumersConfig `yaml:"consumers"` Consumers ConsumersConfig `yaml:"consumers"`
Strategic StrategicConfig `yaml:"strategic"` Strategic StrategicConfig `yaml:"strategic"`
Season SeasonConfig `yaml:"season"` Season SeasonConfig `yaml:"season"`
Forecast ForecastConfig `yaml:"forecast"` Forecast ForecastConfig `yaml:"forecast"`
Battery BatteryConfig `yaml:"battery"` EMS EMSConfig `yaml:"ems"`
CarCharging CarChargingConfig `yaml:"car_charging"`
Cars map[string]CarProfile `yaml:"cars"`
EMS EMSConfig `yaml:"ems"`
}
// BatteryConfig holds physical battery properties.
type BatteryConfig struct {
CapacityKWh float64 `yaml:"capacity_kwh"` // usable battery capacity in kWh
}
// CarChargingConfig holds parameters for the proactive car charging strategy.
type CarChargingConfig struct {
MinSOC int `yaml:"min_soc"` // minimum SOC% to start car charging (e.g. 25)
SOCFloor int `yaml:"soc_floor"` // never drain battery below this % (e.g. 5)
SOCHighBypassPct int `yaml:"soc_high_bypass_pct"` // activate without forecast check when SOC ≥ this % (0 → default 90%)
EODSOCTarget int `yaml:"eod_soc_target"` // target SOC% to reach by sunset (e.g. 90)
EODTime string `yaml:"eod_time"` // soft-stop check starts at this time (e.g. "16:00")
NoCarRetryMin int `yaml:"no_car_retry_min"` // minutes before retrying after no-car detection
PVThresholdAW float64 `yaml:"pv_threshold_a_w"` // min PV production to start WallboxA (W)
PVThresholdBW float64 `yaml:"pv_threshold_b_w"` // min PV production to start WallboxB (W)
GridDeltaThreshW float64 `yaml:"grid_delta_thresh_w"` // min grid power shift after WallboxB activation = car detected (W)
}
func (c *CarChargingConfig) EODTimeParsed(ref time.Time) time.Time {
var h, m int
fmt.Sscanf(c.EODTime, "%d:%d", &h, &m)
return time.Date(ref.Year(), ref.Month(), ref.Day(), h, m, 0, 0, ref.Location())
}
// 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.
@@ -136,12 +102,9 @@ type PowerThresholds struct {
// ConsumersConfig holds per-consumer behavior thresholds. // ConsumersConfig holds per-consumer behavior thresholds.
type ConsumersConfig struct { type ConsumersConfig struct {
CompressorIdleW int `yaml:"compressor_idle_w"` // below this = heat pump compressor idle (W) CompressorIdleW int `yaml:"compressor_idle_w"` // below this = heat pump compressor idle (W)
WallboxMinChargeW int `yaml:"wallbox_min_charge_w"` // below this = car not charging (W) WallboxMinChargeW int `yaml:"wallbox_min_charge_w"` // below this = car not charging (W)
IdleCycles int `yaml:"idle_cycles"` // consecutive idle cycles before early release IdleCycles int `yaml:"idle_cycles"` // consecutive idle cycles before early release
SGReadyStartupMin int `yaml:"sg_ready_startup_min"` // grace period after SG-Ready activation before idle detection starts (min, 0 = disabled)
WallboxAAcceptedImportW float64 `yaml:"wallbox_a_accepted_import_w"` // tolerate this much grid import while WallboxA is running (0 = disabled)
WallboxBAcceptedImportW float64 `yaml:"wallbox_b_accepted_import_w"` // tolerate this much grid import while WallboxB is running (0 = disabled)
} }
// StrategicConfig holds PV forecast based strategic settings. // StrategicConfig holds PV forecast based strategic settings.
@@ -150,11 +113,9 @@ type StrategicConfig struct {
ForecastMidKWh float64 `yaml:"forecast_mid_kwh"` ForecastMidKWh float64 `yaml:"forecast_mid_kwh"`
WWBoostHighC float64 `yaml:"ww_boost_high_c"` WWBoostHighC float64 `yaml:"ww_boost_high_c"`
WWBoostMidC float64 `yaml:"ww_boost_mid_c"` WWBoostMidC float64 `yaml:"ww_boost_mid_c"`
WWBaseC float64 `yaml:"ww_base_c"` // normal WW setpoint (°C) WWBaseC float64 `yaml:"ww_base_c"` // normal WW setpoint (°C)
WWMaxSetpointC float64 `yaml:"ww_max_setpoint_c"` // absolute maximum WW setpoint (°C), e.g. 60 WWWindowStart string `yaml:"ww_window_start"` // e.g. "12:30"
WWHysteresisC float64 `yaml:"ww_hysteresis_c"` // Viessmann switchOn = setpoint - hysteresis (°C), e.g. 5 WWWindowEnd string `yaml:"ww_window_end"` // e.g. "18:00"
WWWindowStart string `yaml:"ww_window_start"` // e.g. "12:30"
WWWindowEnd string `yaml:"ww_window_end"` // e.g. "18:00"
ScheduleOn string `yaml:"schedule_on"` ScheduleOn string `yaml:"schedule_on"`
ScheduleOff string `yaml:"schedule_off"` ScheduleOff string `yaml:"schedule_off"`
} }
@@ -192,19 +153,12 @@ func (f *ForecastConfig) FetchIntervalParsed() time.Duration {
// EMSConfig holds operational settings for the EMS daemon. // EMSConfig holds operational settings for the EMS daemon.
type EMSConfig struct { type EMSConfig struct {
PollInterval string `yaml:"poll_interval"` PollInterval string `yaml:"poll_interval"`
ListenAddr string `yaml:"listen_addr"` // web UI address (authenticated) ListenAddr string `yaml:"listen_addr"`
MetricsAddr string `yaml:"metrics_addr"` // Prometheus /metrics address (no auth, internal only)
LogLevel string `yaml:"log_level"` LogLevel string `yaml:"log_level"`
StateFile string `yaml:"state_file"` StateFile string `yaml:"state_file"`
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
HTTPUsername string `yaml:"http_username"` // login username (empty = no auth)
HTTPPassword string `yaml:"http_password"` // login password
} }
func (e *EMSConfig) PollIntervalParsed() time.Duration { func (e *EMSConfig) PollIntervalParsed() time.Duration {
@@ -230,13 +184,6 @@ func (e *EMSConfig) OverrideTimeoutParsed() time.Duration {
// Load reads and parses the YAML config file at the given path. // Load reads and parses the YAML config file at the given path.
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
// Warn if config file is readable by group or others (contains credentials).
if info, err := os.Stat(path); err == nil {
if info.Mode().Perm()&0o077 != 0 {
fmt.Fprintf(os.Stderr, "WARNING: config file %s has permissions %o — should be 0600 (contains credentials)\n", path, info.Mode().Perm())
}
}
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("reading config file: %w", err) return nil, fmt.Errorf("reading config file: %w", err)
@@ -267,21 +214,5 @@ func (c *Config) validate() error {
if c.EMS.ListenAddr == "" { if c.EMS.ListenAddr == "" {
return fmt.Errorf("ems.listen_addr is required") return fmt.Errorf("ems.listen_addr is required")
} }
// Validate Shelly IPs to prevent SSRF via config manipulation.
for name, dev := range map[string]ShellyDevice{
"sg_ready": c.Shelly.SGReady,
"wallbox_a": c.Shelly.WallboxA,
"wallbox_b": c.Shelly.WallboxB,
} {
if dev.IP != "" && net.ParseIP(dev.IP) == nil {
return fmt.Errorf("shelly.%s.ip %q is not a valid IP address", name, dev.IP)
}
}
if c.EMS.HTTPUsername != "" && c.EMS.HTTPPassword == "" {
return fmt.Errorf("ems.http_username is set but ems.http_password is empty")
}
if c.EMS.HTTPPassword != "" && c.EMS.HTTPUsername == "" {
return fmt.Errorf("ems.http_password is set but ems.http_username is empty")
}
return nil return nil
} }

View File

@@ -51,14 +51,11 @@ type DeviceStatus struct {
// ConsumerState tracks the runtime state of a single consumer. // ConsumerState tracks the runtime state of a single consumer.
type ConsumerState struct { type ConsumerState struct {
Active bool Active bool
ActivatedAt time.Time // when it was last turned on ActivatedAt time.Time // when it was last turned on
ManualOverride bool ManualOverride bool
OverrideUntil time.Time OverrideUntil time.Time
LowPowerCycles int // consecutive cycles with power below minimum threshold LowPowerCycles int // consecutive cycles with power below minimum threshold
ProactiveCharging bool // true if activated by proactive car-charging logic (bypasses import shutdown)
ProbeStartGridW float64 // grid power snapshot at WallboxB activation (for no-car detection)
NoCarRetryUntil time.Time // don't retry WallboxB proactive charging until this time
} }
// OverrideInfo is returned to callers that need to display or record override state. // OverrideInfo is returned to callers that need to display or record override state.
@@ -103,11 +100,9 @@ func NewEngine(cfg *config.Config, logger *slog.Logger) *Engine {
} }
// Decide evaluates the current system state and returns a list of actions. // Decide evaluates the current system state and returns a list of actions.
// // wwBoostC is the WW temperature boost in °C derived from the PV forecast
// - wwBoostC: WW temperature boost in °C from PV forecast (0 = no boost warranted) // (0 = no forecast / forecast too low to warrant boosting).
// - forecastKWh: today's forecast total in kWh (0 if forecasting disabled) func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC float64) []Action {
// - sunsetTime: estimated time of sunset (used for EOD soft stop); zero = disabled
func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC, forecastKWh float64, sunsetTime time.Time) []Action {
var actions []Action var actions []Action
soc := state.BatterySOC soc := state.BatterySOC
@@ -119,11 +114,10 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC, fo
e.logger.Debug("decision input", e.logger.Debug("decision input",
"grid_w", gridW, "grid_w", gridW,
"soc", soc, "soc", soc,
"pv_w", state.PVProductionW,
"forecast_kwh", forecastKWh,
"heating_period", heatingPeriod, "heating_period", heatingPeriod,
"ww_window", wwWindow, "ww_window", wwWindow,
"ww_boost_c", wwBoostC, "ww_boost_c", wwBoostC,
"allowed", allowed,
) )
// --- SOC emergency brake --- // --- SOC emergency brake ---
@@ -145,21 +139,14 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC, fo
}) })
} }
// --- EOD soft stop: after configured time, stop car charging if remaining PV insufficient --- // --- Shutdown logic (reverse priority order) ---
actions = append(actions, e.checkEODSoftStop(state, now, sunsetTime)...)
// --- WallboxB no-car probe: check grid delta after activation ---
actions = append(actions, e.checkWallboxBProbe(state, now)...)
// --- Shutdown logic (reverse priority order, import hysteresis) ---
// Proactive wallboxes are skipped — EOD/probe/idle-cycles handle their stops.
if gridW > e.cfg.Thresholds.ImportOffW { if gridW > e.cfg.Thresholds.ImportOffW {
if e.hyst.ImportSinceAbove.IsZero() { if e.hyst.ImportSinceAbove.IsZero() {
e.hyst.ImportSinceAbove = now e.hyst.ImportSinceAbove = now
} }
importDuration := now.Sub(e.hyst.ImportSinceAbove) importDuration := now.Sub(e.hyst.ImportSinceAbove)
if importDuration >= e.cfg.Hysteresis.ImportOffDurationParsed() { if importDuration >= e.cfg.Hysteresis.ImportOffDurationParsed() {
if a := e.shutdownLastConsumer(now, gridW); a != nil { if a := e.shutdownLastConsumer(now); a != nil {
actions = append(actions, *a) actions = append(actions, *a)
e.hyst.ImportSinceAbove = time.Time{} e.hyst.ImportSinceAbove = time.Time{}
} }
@@ -169,20 +156,8 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC, fo
} }
// --- Compressor idle: release SG-Ready early if heat pump stopped --- // --- Compressor idle: release SG-Ready early if heat pump stopped ---
// A startup grace period is applied after activation: the heat pump compressor
// takes several minutes to start after receiving the SG-Ready signal, so idle
// detection is suppressed until the grace period has elapsed.
if cs := e.consumers[ConsumerSGReady]; cs.Active { if cs := e.consumers[ConsumerSGReady]; cs.Active {
startupMin := e.cfg.Consumers.SGReadyStartupMin if state.CompressorPowerW < float64(e.cfg.Consumers.CompressorIdleW) {
inGrace := startupMin > 0 && !cs.ActivatedAt.IsZero() && now.Sub(cs.ActivatedAt) < time.Duration(startupMin)*time.Minute
if inGrace {
e.logger.Debug("SG-Ready: startup grace period, skipping idle check",
"activated_at", cs.ActivatedAt.Format("15:04"),
"grace_min", startupMin,
"elapsed_min", int(now.Sub(cs.ActivatedAt).Minutes()),
)
} else if state.CompressorPowerW < float64(e.cfg.Consumers.CompressorIdleW) {
cs.LowPowerCycles++ cs.LowPowerCycles++
e.logger.Debug("SG-Ready: compressor idle cycle", e.logger.Debug("SG-Ready: compressor idle cycle",
"compressor_w", state.CompressorPowerW, "compressor_w", state.CompressorPowerW,
@@ -216,17 +191,9 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC, fo
e.logger.Info("wallbox released: car not charging", e.logger.Info("wallbox released: car not charging",
"consumer", wb, "consumer", wb,
"low_power_cycles", cs.LowPowerCycles, "low_power_cycles", cs.LowPowerCycles,
"proactive", cs.ProactiveCharging,
) )
cs.Active = false cs.Active = false
cs.ProactiveCharging = false
cs.LowPowerCycles = 0 cs.LowPowerCycles = 0
// For WallboxA: set retry timeout so proactive logic doesn't immediately re-activate
if wb == ConsumerWallboxA && e.cfg.CarCharging.NoCarRetryMin > 0 {
e.consumers[ConsumerWallboxA].NoCarRetryUntil = now.Add(
time.Duration(e.cfg.CarCharging.NoCarRetryMin) * time.Minute,
)
}
actions = append(actions, Action{ actions = append(actions, Action{
Consumer: wb, Consumer: wb,
TurnOn: false, TurnOn: false,
@@ -236,267 +203,51 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC, fo
} }
// --- Turn-on logic (priority order) --- // --- Turn-on logic (priority order) ---
if gridW <= 0 {
// P1: SG-Ready (heating period only)
if heatingPeriod {
actions = append(actions, e.evaluateTurnOn(
ConsumerSGReady, gridW, e.cfg.Thresholds.SGReadyExportW,
allowed, now,
)...)
}
// P1: Proactive car charging — forecast-driven, no export threshold required. // P2: WW boost (time window + forecast required)
// Mutual exclusion (A vs B) is enforced inside evaluateCarCharging. if wwWindow && wwBoostC > 0 {
actions = append(actions, e.evaluateCarCharging(state, forecastKWh, now)...) actions = append(actions, e.evaluateWWTurnOn(gridW, wwBoostC, allowed, now)...)
}
// P2: WW boost — time window + forecast, no export threshold, no car charging. // P3: Wallbox A (2kW, single-phase) — only if Wallbox B is not active.
// Only runs if no wallbox is active (car charging takes priority). // Uses per-phase export check if available, otherwise falls back to total.
if wwWindow && wwBoostC > 0 && // Re-reads Active state directly so a same-cycle activation of WallboxB blocks WallboxA.
!e.consumers[ConsumerWallboxA].Active && !e.consumers[ConsumerWallboxB].Active { if !e.consumers[ConsumerWallboxB].Active {
actions = append(actions, e.evaluateWWTurnOn(state, wwBoostC, allowed, now)...) phaseGridW := gridW // fallback: total grid power
} if state.PhaseL1PowerW != 0 || state.PhaseL2PowerW != 0 || state.PhaseL3PowerW != 0 {
phaseGridW = min3(state.PhaseL1PowerW, state.PhaseL2PowerW, state.PhaseL3PowerW)
}
threshold := e.cfg.Thresholds.WallboxAExportW
if e.cfg.Thresholds.WallboxAPhaseExportW != 0 {
threshold = e.cfg.Thresholds.WallboxAPhaseExportW
}
actions = append(actions, e.evaluateTurnOn(
ConsumerWallboxA, phaseGridW, threshold,
allowed, now,
)...)
}
// P3: SG-Ready — reactive, export-threshold based, heating period only. // P4: Wallbox B (4kW, 3-phase) — only if Wallbox A is not active.
// Only runs if no wallbox is active (car charging takes priority). // Re-reads Active state so a same-cycle activation of WallboxA blocks WallboxB.
if heatingPeriod && if !e.consumers[ConsumerWallboxA].Active {
!e.consumers[ConsumerWallboxA].Active && !e.consumers[ConsumerWallboxB].Active { actions = append(actions, e.evaluateTurnOn(
actions = append(actions, e.evaluateTurnOn( ConsumerWallboxB, gridW, e.cfg.Thresholds.WallboxBExportW,
ConsumerSGReady, gridW, e.cfg.Thresholds.SGReadyExportW, allowed, now,
allowed, now, )...)
)...) }
} }
return actions return actions
} }
// evaluateCarCharging implements proactive forecast-driven car charging.
// Tries WallboxA first (has PM for car detection), then WallboxB (grid-delta probe).
// Does not require export surplus — just sufficient PV production and a good forecast.
func (e *Engine) evaluateCarCharging(state collector.SystemState, forecastKWh float64, now time.Time) []Action {
cc := e.cfg.CarCharging
// Skip if proactive charging is not configured (thresholds must be set)
if cc.PVThresholdAW == 0 && cc.PVThresholdBW == 0 {
return nil
}
// Determine activation gate: either forecast is good, or SOC is already high
// (battery full → activate regardless of forecast; any PV surplus should charge the car).
socHighBypass := float64(cc.SOCHighBypassPct)
if socHighBypass == 0 {
socHighBypass = 90 // default: bypass forecast check when battery ≥ 90%
}
forecastOK := forecastKWh > 0 && forecastKWh >= float64(e.cfg.Strategic.ForecastMidKWh)
socHigh := state.BatterySOC >= socHighBypass
if !forecastOK && !socHigh {
e.logger.Debug("proactive car charging: skipped",
"forecast_kwh", forecastKWh,
"forecast_threshold", e.cfg.Strategic.ForecastMidKWh,
"soc", state.BatterySOC,
"soc_bypass_pct", socHighBypass,
)
return nil
}
// SOC must be above the minimum for proactive charging
if state.BatterySOC < float64(cc.MinSOC) {
return nil
}
csA := e.consumers[ConsumerWallboxA]
csB := e.consumers[ConsumerWallboxB]
// --- Try WallboxA (has PM, preferred) ---
if !csA.Active && !csB.Active {
// Respect no-car retry timeout (set after idle-cycles detection)
if !csA.NoCarRetryUntil.IsZero() && now.Before(csA.NoCarRetryUntil) {
e.logger.Debug("WallboxA proactive: skipping, in no-car retry window",
"retry_until", csA.NoCarRetryUntil.Format("15:04"),
)
// Fall through to WallboxB below
} else if state.PVProductionW >= cc.PVThresholdAW {
// Respect manual override
if csA.ManualOverride && now.Before(csA.OverrideUntil) {
return nil
}
e.logger.Info("proactive: activating WallboxA",
"pv_w", state.PVProductionW,
"threshold_w", cc.PVThresholdAW,
"soc", state.BatterySOC,
"forecast_kwh", forecastKWh,
)
csA.Active = true
csA.ActivatedAt = now
csA.ProactiveCharging = true
csA.LowPowerCycles = 0
delete(e.hyst.ExportSinceAbove, ConsumerWallboxA)
trigger := fmt.Sprintf("forecast %.1fkWh", forecastKWh)
if socHigh && !forecastOK {
trigger = fmt.Sprintf("SOC %.0f%% ≥ %.0f%% (bypass)", state.BatterySOC, socHighBypass)
}
return []Action{{
Consumer: ConsumerWallboxA,
TurnOn: true,
Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, %s", state.PVProductionW, cc.PVThresholdAW, trigger),
}}
}
}
// --- Try WallboxB (no PM, uses grid-delta probe) ---
// Only if WallboxA is not active and B is not already running.
if !csA.Active && !csB.Active {
if csB.ManualOverride && now.Before(csB.OverrideUntil) {
return nil
}
if !csB.NoCarRetryUntil.IsZero() && now.Before(csB.NoCarRetryUntil) {
e.logger.Debug("WallboxB proactive: skipping, in no-car retry window",
"retry_until", csB.NoCarRetryUntil.Format("15:04"),
)
return nil
}
if state.PVProductionW >= cc.PVThresholdBW {
e.logger.Info("proactive: activating WallboxB (grid-delta probe)",
"pv_w", state.PVProductionW,
"threshold_w", cc.PVThresholdBW,
"soc", state.BatterySOC,
"forecast_kwh", forecastKWh,
)
csB.Active = true
csB.ActivatedAt = now
csB.ProactiveCharging = true
csB.ProbeStartGridW = state.GridPowerW
csB.LowPowerCycles = 0
delete(e.hyst.ExportSinceAbove, ConsumerWallboxB)
trigger := fmt.Sprintf("forecast %.1fkWh", forecastKWh)
if socHigh && !forecastOK {
trigger = fmt.Sprintf("SOC %.0f%% ≥ %.0f%% (bypass)", state.BatterySOC, socHighBypass)
}
return []Action{{
Consumer: ConsumerWallboxB,
TurnOn: true,
Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, %s", state.PVProductionW, cc.PVThresholdBW, trigger),
}}
}
}
return nil
}
// checkWallboxBProbe checks whether a car is actually charging on WallboxB after activation.
// WallboxB has no PM, so we use grid-delta: if the grid power hasn't shifted by at least
// GridDeltaThreshW within the probe window, no car is connected → deactivate and set retry.
func (e *Engine) checkWallboxBProbe(state collector.SystemState, now time.Time) []Action {
cs := e.consumers[ConsumerWallboxB]
if !cs.Active || !cs.ProactiveCharging || cs.ActivatedAt.IsZero() {
return nil
}
cc := e.cfg.CarCharging
probeDuration := e.cfg.Hysteresis.MinRuntimeWallboxParsed() / 2 // half the min runtime
if now.Sub(cs.ActivatedAt) < probeDuration {
return nil // probe window not reached yet
}
// Already probed if ProbeStartGridW is zero after first probe (reset after detection)
if cs.ProbeStartGridW == 0 {
return nil // already concluded
}
gridDelta := cs.ProbeStartGridW - state.GridPowerW // negative = more import = car charging
// A 4kW wallbox causes a grid shift of ~4000W (or large export reduction).
// Positive gridDelta means we're importing more than at probe start (expected with car charging).
// We check: grid increased by at least threshold (car drawing power).
if gridDelta >= cc.GridDeltaThreshW || -gridDelta >= cc.GridDeltaThreshW {
// Either significantly more import or less export = car detected
e.logger.Info("WallboxB probe: car detected via grid delta",
"probe_start_w", cs.ProbeStartGridW,
"current_w", state.GridPowerW,
"delta_w", gridDelta,
)
cs.ProbeStartGridW = 0 // mark probe as concluded
return nil
}
// No meaningful grid shift → no car connected
e.logger.Info("WallboxB probe: no car detected, deactivating",
"probe_start_w", cs.ProbeStartGridW,
"current_w", state.GridPowerW,
"delta_w", gridDelta,
"threshold_w", cc.GridDeltaThreshW,
)
cs.Active = false
cs.ProactiveCharging = false
cs.ProbeStartGridW = 0
if cc.NoCarRetryMin > 0 {
cs.NoCarRetryUntil = now.Add(time.Duration(cc.NoCarRetryMin) * time.Minute)
}
return []Action{{
Consumer: ConsumerWallboxB,
TurnOn: false,
Reason: fmt.Sprintf("no-car probe: grid delta %.0fW < %.0fW", gridDelta, cc.GridDeltaThreshW),
}}
}
// checkEODSoftStop implements the end-of-day battery protection.
// After CarCharging.EODTime, if the remaining estimated PV production is insufficient
// to fill the battery to the EOD target by sunset, proactive car charging is stopped.
func (e *Engine) checkEODSoftStop(state collector.SystemState, now time.Time, sunsetTime time.Time) []Action {
cc := e.cfg.CarCharging
if cc.EODTime == "" || cc.EODSOCTarget == 0 || e.cfg.Battery.CapacityKWh == 0 {
return nil
}
eodTime := cc.EODTimeParsed(now)
if now.Before(eodTime) {
return nil // too early for EOD check
}
// Determine sunset reference
if sunsetTime.IsZero() || sunsetTime.Before(now) {
return nil // no valid sunset time, skip
}
hoursToSunset := sunsetTime.Sub(now).Hours()
if hoursToSunset <= 0 {
hoursToSunset = 0
}
// Estimate remaining PV production (current watt × hours to sunset)
remainingPVkWh := (state.PVProductionW / 1000.0) * hoursToSunset
// Battery energy needed to reach target SOC
socDeficitKWh := (float64(cc.EODSOCTarget)/100.0 - state.BatterySOC/100.0) * e.cfg.Battery.CapacityKWh
if socDeficitKWh <= 0 {
return nil // already at or above target SOC
}
// House base load consumption during remaining time
houseKWh := (e.cfg.Forecast.BaseLoadW / 1000.0) * hoursToSunset
// If remaining PV can't cover the battery deficit plus house load, stop charging
if remainingPVkWh >= socDeficitKWh+houseKWh {
return nil // enough PV remaining
}
e.logger.Info("EOD soft stop: remaining PV insufficient to reach target SOC",
"remaining_pv_kwh", fmt.Sprintf("%.2f", remainingPVkWh),
"soc_deficit_kwh", fmt.Sprintf("%.2f", socDeficitKWh),
"house_kwh", fmt.Sprintf("%.2f", houseKWh),
"hours_to_sunset", fmt.Sprintf("%.1f", hoursToSunset),
"current_soc", state.BatterySOC,
"target_soc", cc.EODSOCTarget,
)
var stopActions []Action
for _, wb := range []Consumer{ConsumerWallboxB, ConsumerWallboxA} {
cs := e.consumers[wb]
if !cs.Active || !cs.ProactiveCharging {
continue
}
cs.Active = false
cs.ProactiveCharging = false
stopActions = append(stopActions, Action{
Consumer: wb,
TurnOn: false,
Reason: fmt.Sprintf("EOD soft stop: %.1fkWh PV remaining < %.1fkWh needed", remainingPVkWh, socDeficitKWh+houseKWh),
})
}
return stopActions
}
// min3 returns the minimum of three float64 values. // min3 returns the minimum of three float64 values.
func min3(a, b, c float64) float64 { func min3(a, b, c float64) float64 {
if b < a { if b < a {
@@ -579,7 +330,7 @@ func (e *Engine) evaluateTurnOn(
// All conditions met — turn on // All conditions met — turn on
e.logger.Info("turning on consumer", e.logger.Info("turning on consumer",
"consumer", consumer.String(), "consumer", consumer,
"grid_w", gridW, "grid_w", gridW,
"threshold", threshold, "threshold", threshold,
"export_duration", exportDuration, "export_duration", exportDuration,
@@ -601,8 +352,7 @@ func (e *Engine) evaluateTurnOn(
// shutdownLastConsumer turns off the lowest-priority active consumer // shutdownLastConsumer turns off the lowest-priority active consumer
// that has exceeded its minimum runtime. // that has exceeded its minimum runtime.
// gridW is the current grid power (positive = import) used to check per-consumer import tolerance. func (e *Engine) shutdownLastConsumer(now time.Time) *Action {
func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
// Reverse priority: WallboxB → WallboxA → WW → SGReady // Reverse priority: WallboxB → WallboxA → WW → SGReady
order := []Consumer{ConsumerWallboxB, ConsumerWallboxA, ConsumerWW, ConsumerSGReady} order := []Consumer{ConsumerWallboxB, ConsumerWallboxA, ConsumerWW, ConsumerSGReady}
@@ -615,42 +365,17 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
// Manually overridden to ON — don't shut down until override expires // Manually overridden to ON — don't shut down until override expires
if cs.ManualOverride && now.Before(cs.OverrideUntil) { if cs.ManualOverride && now.Before(cs.OverrideUntil) {
e.logger.Debug("skipping shutdown, consumer is manually overridden", e.logger.Debug("skipping shutdown, consumer is manually overridden",
"consumer", c.String(), "consumer", c,
"override_until", cs.OverrideUntil.Format("15:04"), "override_until", cs.OverrideUntil.Format("15:04"),
) )
continue continue
} }
// Proactive wallboxes are not shut down by import hysteresis.
// Their stops are handled by EOD soft stop, no-car probe, and idle cycles.
if cs.ProactiveCharging && (c == ConsumerWallboxA || c == ConsumerWallboxB) {
e.logger.Debug("skipping shutdown, proactive car charging active", "consumer", c.String())
continue
}
// Per-consumer accepted import tolerance: if the current import is within
// the configured tolerance for this wallbox, skip shutdown.
var acceptedImportW float64
switch c {
case ConsumerWallboxA:
acceptedImportW = e.cfg.Consumers.WallboxAAcceptedImportW
case ConsumerWallboxB:
acceptedImportW = e.cfg.Consumers.WallboxBAcceptedImportW
}
if acceptedImportW > 0 && gridW <= acceptedImportW {
e.logger.Debug("skipping shutdown, import within accepted tolerance",
"consumer", c.String(),
"grid_w", gridW,
"accepted_import_w", acceptedImportW,
)
continue
}
minRuntime := e.minRuntime(c) minRuntime := e.minRuntime(c)
runtime := now.Sub(cs.ActivatedAt) runtime := now.Sub(cs.ActivatedAt)
if runtime < minRuntime { if runtime < minRuntime {
e.logger.Debug("skipping shutdown, min runtime not reached", e.logger.Debug("skipping shutdown, min runtime not reached",
"consumer", c.String(), "consumer", c,
"runtime", runtime, "runtime", runtime,
"min_runtime", minRuntime, "min_runtime", minRuntime,
) )
@@ -658,7 +383,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
} }
e.logger.Info("shutting down consumer", e.logger.Info("shutting down consumer",
"consumer", c.String(), "consumer", c,
"runtime", runtime, "runtime", runtime,
) )
@@ -679,8 +404,6 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
// socEmergencyBrake immediately shuts off consumers whose SOC threshold // socEmergencyBrake immediately shuts off consumers whose SOC threshold
// is no longer met, ignoring minimum runtimes and manual overrides. // is no longer met, ignoring minimum runtimes and manual overrides.
// Proactive car-charging wallboxes use the SOCFloor threshold instead of the
// standard SOC gates, allowing charging down to a lower limit during solar hours.
func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action { func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
var actions []Action var actions []Action
allowed := e.allowedConsumers(soc) allowed := e.allowedConsumers(soc)
@@ -689,25 +412,12 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
if !cs.Active { if !cs.Active {
continue continue
} }
// Intentional wallbox charging (proactive or trip mode): only emergency-brake at SOCFloor.
// Trip mode uses ManualOverride=true, proactive charging uses ProactiveCharging=true —
// both deserve soc_floor protection instead of the standard block_all gate.
isIntentionalCharging := (cs.ProactiveCharging || cs.ManualOverride) &&
(c == ConsumerWallboxA || c == ConsumerWallboxB)
if isIntentionalCharging {
floor := float64(e.cfg.CarCharging.SOCFloor)
if floor > 0 && soc >= floor {
continue // still above floor, keep charging
}
}
if allowed[c] { if allowed[c] {
continue continue
} }
e.logger.Warn("SOC emergency brake", e.logger.Warn("SOC emergency brake",
"consumer", c.String(), "consumer", c,
"soc", soc, "soc", soc,
"was_override", cs.ManualOverride, "was_override", cs.ManualOverride,
) )
@@ -715,7 +425,6 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
cs.Active = false cs.Active = false
cs.ManualOverride = false // EMS takes back full control after emergency cs.ManualOverride = false // EMS takes back full control after emergency
cs.OverrideUntil = time.Time{} cs.OverrideUntil = time.Time{}
cs.ProactiveCharging = false
a := Action{ a := Action{
Consumer: c, Consumer: c,
TurnOn: false, TurnOn: false,
@@ -746,7 +455,7 @@ func (e *Engine) overrideHardStop(gridW float64) []Action {
} }
e.logger.Warn("override hard stop: import exceeds limit", e.logger.Warn("override hard stop: import exceeds limit",
"consumer", c.String(), "consumer", c,
"grid_w", gridW, "grid_w", gridW,
"limit_w", limit, "limit_w", limit,
) )
@@ -786,41 +495,13 @@ func (e *Engine) ApplyOverride(consumer Consumer, on bool, duration time.Duratio
cs.ActivatedAt = time.Time{} cs.ActivatedAt = time.Time{}
} }
e.logger.Info("manual override applied", e.logger.Info("manual override applied",
"consumer", consumer.String(), "consumer", consumer,
"on", on, "on", on,
"duration", duration, "duration", duration,
"until", cs.OverrideUntil.Format("15:04"), "until", cs.OverrideUntil.Format("15:04"),
) )
} }
// RollbackAction reverts the engine's internal state for an action that the actuator
// failed to execute. Without this, the engine believes the switch happened, diverges
// from hardware, and SyncHardwareState will misinterpret the next read-back as a
// manual override and apply a 1-hour lockout.
//
// After rollback the engine state matches hardware again, so the next cycle's
// SyncHardwareState sees no mismatch and the action is simply retried.
func (e *Engine) RollbackAction(action Action) {
cs, ok := e.consumers[action.Consumer]
if !ok {
return
}
e.logger.Warn("rolling back engine state after failed action",
"consumer", action.Consumer,
"turn_on", action.TurnOn,
)
cs.Active = !action.TurnOn
if action.TurnOn {
// Turn-on failed: undo activation side-effects
cs.ActivatedAt = time.Time{}
cs.ProactiveCharging = false
cs.ProbeStartGridW = 0
cs.LowPowerCycles = 0
}
// Turn-off failed: just restore Active=true. ActivatedAt is preserved
// (shutdown code doesn't reset it), so min-runtime stays correct.
}
// isHeatingPeriod returns true if heating is appropriate given the current month // isHeatingPeriod returns true if heating is appropriate given the current month
// and outdoor temperature. If HeatingMinAmbientC is configured (> 0), ambient // and outdoor temperature. If HeatingMinAmbientC is configured (> 0), ambient
// temperatures above that threshold suppress SG-Ready even within the heating months. // temperatures above that threshold suppress SG-Ready even within the heating months.
@@ -867,51 +548,50 @@ func (e *Engine) minRuntime(c Consumer) time.Duration {
} }
// evaluateWWTurnOn checks whether WW boost should be activated. // evaluateWWTurnOn checks whether WW boost should be activated.
// Prerequisites (time window, forecast > 0, no car charging) are already verified by the caller. // Prerequisites (time window and forecast) are already verified by the caller.
// No export threshold is required — the heat pump compressor load is covered by PV. func (e *Engine) evaluateWWTurnOn(gridW, wwBoostC float64, allowed map[Consumer]bool, now time.Time) []Action {
// Dynamic setpoint: current tank top + hysteresis + boost delta, capped at WWMaxSetpointC.
func (e *Engine) evaluateWWTurnOn(state collector.SystemState, wwBoostC float64, allowed map[Consumer]bool, now time.Time) []Action {
cs := e.consumers[ConsumerWW] cs := e.consumers[ConsumerWW]
if cs.Active { if cs.Active {
return nil return nil
} }
if cs.ManualOverride && now.Before(cs.OverrideUntil) { if cs.ManualOverride && now.Before(cs.OverrideUntil) {
delete(e.hyst.ExportSinceAbove, ConsumerWW)
return nil return nil
} }
if !allowed[ConsumerWW] { if !allowed[ConsumerWW] {
delete(e.hyst.ExportSinceAbove, ConsumerWW)
return nil
}
if gridW > e.cfg.Thresholds.WWExportW {
delete(e.hyst.ExportSinceAbove, ConsumerWW)
return nil return nil
} }
// Dynamic setpoint: set high enough above current temp to trigger heating immediately. if _, ok := e.hyst.ExportSinceAbove[ConsumerWW]; !ok {
// Viessmann switchOn threshold = setpoint - hysteresis. e.hyst.ExportSinceAbove[ConsumerWW] = now
// Target = current_top + hysteresis + boost_delta, capped at max.
hysteresis := e.cfg.Strategic.WWHysteresisC
if hysteresis == 0 {
hysteresis = 5 // safe default
} }
maxSetpoint := e.cfg.Strategic.WWMaxSetpointC exportDuration := now.Sub(e.hyst.ExportSinceAbove[ConsumerWW])
if maxSetpoint == 0 { if exportDuration < e.cfg.Hysteresis.ExportOnDurationParsed() {
maxSetpoint = 60 // safe default return nil
}
targetTemp := state.WWTopTempC + hysteresis + wwBoostC
if targetTemp > maxSetpoint {
targetTemp = maxSetpoint
} }
targetTemp := e.cfg.Strategic.WWBaseC + wwBoostC
e.logger.Info("activating WW boost", e.logger.Info("activating WW boost",
"ww_top_c", state.WWTopTempC, "grid_w", gridW,
"ww_boost_c", wwBoostC, "ww_boost_c", wwBoostC,
"target_temp_c", targetTemp, "target_temp_c", targetTemp,
"export_duration", exportDuration,
) )
cs.Active = true cs.Active = true
cs.ActivatedAt = now cs.ActivatedAt = now
delete(e.hyst.ExportSinceAbove, ConsumerWW)
return []Action{{ return []Action{{
Consumer: ConsumerWW, Consumer: ConsumerWW,
TurnOn: true, TurnOn: true,
TargetTempC: targetTemp, TargetTempC: targetTemp,
Reason: fmt.Sprintf("WW boost +%.0f°C → setpoint %.0f°C (tank %.0f°C)", wwBoostC, targetTemp, state.WWTopTempC), Reason: fmt.Sprintf("export %.0fW for %s, WW boost +%.0f°C", -gridW, exportDuration, wwBoostC),
}} }}
} }
@@ -971,10 +651,7 @@ func (e *Engine) RecoverState(states map[Consumer]DeviceStatus) {
// On match: expired overrides are cleared, resuming normal EMS control. // On match: expired overrides are cleared, resuming normal EMS control.
// Power readings (from PM-capable devices) update the low-power cycle counter for // Power readings (from PM-capable devices) update the low-power cycle counter for
// car-not-charging detection. // car-not-charging detection.
// When monitorOnly is true, hardware mismatches silently update engine state without func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Time, overrideTimeout time.Duration) {
// logging or applying override lockouts — the EMS cannot act anyway, so treating
// every missed switch as a "manual override" would produce spurious log spam.
func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Time, overrideTimeout time.Duration, monitorOnly bool) {
for c, status := range states { for c, status := range states {
cs, ok := e.consumers[c] cs, ok := e.consumers[c]
if !ok { if !ok {
@@ -982,39 +659,27 @@ func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Ti
} }
if cs.Active != status.On { if cs.Active != status.On {
if monitorOnly { // External change detected
// In monitor-only mode the EMS cannot execute switch actions, so a e.logger.Info("manual override detected — external state change",
// mismatch just means a pending action couldn't be carried out. "consumer", c,
// Silently realign engine state to hardware without locking. "engine_state", cs.Active,
cs.Active = status.On "hardware_state", status.On,
if !status.On { "override_until", now.Add(overrideTimeout).Format("15:04"),
cs.ActivatedAt = time.Time{} )
} else { cs.Active = status.On
cs.ActivatedAt = now cs.ManualOverride = true
} cs.OverrideUntil = now.Add(overrideTimeout)
cs.LowPowerCycles = 0
if !status.On {
cs.ActivatedAt = time.Time{}
} else { } else {
// External change detected — log and apply override lockout cs.ActivatedAt = now
e.logger.Info("manual override detected — external state change",
"consumer", c.String(),
"engine_state", cs.Active,
"hardware_state", status.On,
"override_until", now.Add(overrideTimeout).Format("15:04"),
)
cs.Active = status.On
cs.ManualOverride = true
cs.OverrideUntil = now.Add(overrideTimeout)
cs.LowPowerCycles = 0
if !status.On {
cs.ActivatedAt = time.Time{}
} else {
cs.ActivatedAt = now
}
} }
} else if cs.ManualOverride && now.After(cs.OverrideUntil) { } else if cs.ManualOverride && now.After(cs.OverrideUntil) {
// Override expired and state matches — resume EMS control // Override expired and state matches — resume EMS control
cs.ManualOverride = false cs.ManualOverride = false
cs.OverrideUntil = time.Time{} cs.OverrideUntil = time.Time{}
e.logger.Info("manual override expired, resuming EMS control", "consumer", c.String()) e.logger.Info("manual override expired, resuming EMS control", "consumer", c)
} }
// Track low-power cycles for car-not-charging detection (PM devices only). // Track low-power cycles for car-not-charging detection (PM devices only).
@@ -1023,7 +688,7 @@ func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Ti
if cs.Active && status.PowerW < float64(e.cfg.Consumers.WallboxMinChargeW) { if cs.Active && status.PowerW < float64(e.cfg.Consumers.WallboxMinChargeW) {
cs.LowPowerCycles++ cs.LowPowerCycles++
e.logger.Debug("wallbox low power cycle", e.logger.Debug("wallbox low power cycle",
"consumer", c.String(), "consumer", c,
"power_w", status.PowerW, "power_w", status.PowerW,
"low_power_cycles", cs.LowPowerCycles, "low_power_cycles", cs.LowPowerCycles,
) )

View File

@@ -61,7 +61,7 @@ func TestSOCBlocksAll(t *testing.T) {
BatterySOC: 40, // below 50% → all blocked BatterySOC: 40, // below 50% → all blocked
} }
actions := eng.Decide(state, now, 0, 0, time.Time{}) actions := eng.Decide(state, now, 0)
if len(actions) != 0 { if len(actions) != 0 {
t.Errorf("expected no actions with SOC 40%%, got %d actions", len(actions)) t.Errorf("expected no actions with SOC 40%%, got %d actions", len(actions))
} }
@@ -79,13 +79,13 @@ func TestSOCAllowsSGReady(t *testing.T) {
} }
// First call — starts hysteresis timer // First call — starts hysteresis timer
actions := eng.Decide(state, base, 0, 0, time.Time{}) actions := eng.Decide(state, base, 0)
if len(actions) != 0 { if len(actions) != 0 {
t.Errorf("expected no actions on first call (hysteresis), got %d", len(actions)) t.Errorf("expected no actions on first call (hysteresis), got %d", len(actions))
} }
// Second call after 5 minutes — hysteresis passed // Second call after 5 minutes — hysteresis passed
actions = eng.Decide(state, base.Add(5*time.Minute), 0, 0, time.Time{}) actions = eng.Decide(state, base.Add(5*time.Minute), 0)
if len(actions) != 1 { if len(actions) != 1 {
t.Fatalf("expected 1 action after hysteresis, got %d", len(actions)) t.Fatalf("expected 1 action after hysteresis, got %d", len(actions))
} }
@@ -107,8 +107,8 @@ func TestSOCBlocksWallboxAt60(t *testing.T) {
} }
// Pass hysteresis // Pass hysteresis
eng.Decide(state, base, 0, 0, time.Time{}) eng.Decide(state, base, 0)
actions := eng.Decide(state, base.Add(5*time.Minute), 0, 0, time.Time{}) actions := eng.Decide(state, base.Add(5*time.Minute), 0)
// Should only get SG-Ready, no wallboxes // Should only get SG-Ready, no wallboxes
for _, a := range actions { for _, a := range actions {
@@ -130,8 +130,8 @@ func TestSGReadyOnlyInHeatingPeriod(t *testing.T) {
} }
// Pass hysteresis // Pass hysteresis
eng.Decide(state, base, 0, 0, time.Time{}) eng.Decide(state, base, 0)
actions := eng.Decide(state, base.Add(5*time.Minute), 0, 0, time.Time{}) actions := eng.Decide(state, base.Add(5*time.Minute), 0)
for _, a := range actions { for _, a := range actions {
if a.Consumer == ConsumerSGReady { if a.Consumer == ConsumerSGReady {
@@ -149,14 +149,14 @@ func TestSOCEmergencyBrake(t *testing.T) {
GridPowerW: -600, GridPowerW: -600,
BatterySOC: 95, BatterySOC: 95,
} }
eng.Decide(state, base, 0, 0, time.Time{}) eng.Decide(state, base, 0)
eng.Decide(state, base.Add(5*time.Minute), 0, 0, time.Time{}) eng.Decide(state, base.Add(5*time.Minute), 0)
// Now SOC drops below threshold // Now SOC drops below threshold
state.BatterySOC = 45 state.BatterySOC = 45
state.GridPowerW = -600 // still exporting, but SOC is too low state.GridPowerW = -600 // still exporting, but SOC is too low
actions := eng.Decide(state, base.Add(10*time.Minute), 0, 0, time.Time{}) actions := eng.Decide(state, base.Add(10*time.Minute), 0)
foundBrake := false foundBrake := false
for _, a := range actions { for _, a := range actions {
@@ -190,7 +190,7 @@ func TestShutdownReverseOrder(t *testing.T) {
GridPowerW: 500, // importing GridPowerW: 500, // importing
BatterySOC: 95, BatterySOC: 95,
} }
actions := eng.Decide(state, base, 0, 0, time.Time{}) actions := eng.Decide(state, base, 0)
if len(actions) == 0 { if len(actions) == 0 {
t.Fatal("expected shutdown action") t.Fatal("expected shutdown action")
@@ -265,28 +265,18 @@ func TestWallboxMutualExclusion(t *testing.T) {
cfg := testConfig() cfg := testConfig()
cfg.Hysteresis.ExportOnDuration = "0s" cfg.Hysteresis.ExportOnDuration = "0s"
cfg.Hysteresis.ImportOffDuration = "0s" cfg.Hysteresis.ImportOffDuration = "0s"
// Configure proactive car charging
cfg.Strategic.ForecastMidKWh = 15
cfg.CarCharging = config.CarChargingConfig{
MinSOC: 35,
SOCFloor: 5,
PVThresholdAW: 1000,
PVThresholdBW: 2000,
}
eng := NewEngine(cfg, testLogger()) eng := NewEngine(cfg, testLogger())
base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC) // summer (no SG-Ready) base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC) // summer (no SG-Ready)
// Good solar day — proactive charging should activate WallboxA // Massive export — enough to meet both wallbox thresholds
state := collector.SystemState{ state := collector.SystemState{
PVProductionW: 3000, // ≥ PVThresholdA (1000W) and ≥ PVThresholdB (2000W) GridPowerW: -5000,
GridPowerW: -5000, BatterySOC: 95,
BatterySOC: 95,
} }
forecastKWh := 20.0 // above ForecastMidKWh
// First Decide: WallboxA should activate (tried first), WallboxB must be blocked (mutex) // First Decide: WallboxA should activate (P3), WallboxB must be blocked (mutex)
actions := eng.Decide(state, base, 0, forecastKWh, time.Time{}) actions := eng.Decide(state, base, 0)
var wbAOn, wbBOn bool var wbAOn, wbBOn bool
for _, a := range actions { for _, a := range actions {
@@ -305,7 +295,7 @@ func TestWallboxMutualExclusion(t *testing.T) {
} }
// Second Decide with WallboxA still active: WallboxB must still be blocked // Second Decide with WallboxA still active: WallboxB must still be blocked
actions = eng.Decide(state, base.Add(2*time.Minute), 0, forecastKWh, time.Time{}) actions = eng.Decide(state, base.Add(2*time.Minute), 0)
for _, a := range actions { for _, a := range actions {
if a.Consumer == ConsumerWallboxB && a.TurnOn { if a.Consumer == ConsumerWallboxB && a.TurnOn {
t.Error("WallboxB must not activate while WallboxA is active (second cycle)") t.Error("WallboxB must not activate while WallboxA is active (second cycle)")
@@ -315,24 +305,16 @@ func TestWallboxMutualExclusion(t *testing.T) {
func TestCarNotChargingReleasesWallbox(t *testing.T) { func TestCarNotChargingReleasesWallbox(t *testing.T) {
cfg := testConfig() cfg := testConfig()
cfg.Hysteresis.ExportOnDuration = "0s"
cfg.Consumers.IdleCycles = 3 cfg.Consumers.IdleCycles = 3
cfg.Consumers.WallboxMinChargeW = 50 cfg.Consumers.WallboxMinChargeW = 50
// Configure proactive car charging
cfg.Strategic.ForecastMidKWh = 15
cfg.CarCharging = config.CarChargingConfig{
MinSOC: 35,
SOCFloor: 5,
PVThresholdAW: 1000,
NoCarRetryMin: 30,
}
eng := NewEngine(cfg, testLogger()) eng := NewEngine(cfg, testLogger())
base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC) base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC)
forecastKWh := 20.0
// Activate WallboxA via proactive charging // Activate WallboxA
state := collector.SystemState{PVProductionW: 2000, GridPowerW: -2000, BatterySOC: 95} state := collector.SystemState{GridPowerW: -2000, BatterySOC: 95}
actions := eng.Decide(state, base, 0, forecastKWh, time.Time{}) actions := eng.Decide(state, base, 0)
if len(actions) != 1 || actions[0].Consumer != ConsumerWallboxA || !actions[0].TurnOn { if len(actions) != 1 || actions[0].Consumer != ConsumerWallboxA || !actions[0].TurnOn {
t.Fatalf("expected WallboxA to activate, got %v", actions) t.Fatalf("expected WallboxA to activate, got %v", actions)
} }
@@ -344,12 +326,11 @@ func TestCarNotChargingReleasesWallbox(t *testing.T) {
map[Consumer]DeviceStatus{ConsumerWallboxA: lowPower}, map[Consumer]DeviceStatus{ConsumerWallboxA: lowPower},
base.Add(time.Duration(i+1)*2*time.Minute), base.Add(time.Duration(i+1)*2*time.Minute),
time.Hour, time.Hour,
false,
) )
} }
// Decide should now release WallboxA // Decide should now release WallboxA
actions = eng.Decide(state, base.Add(8*time.Minute), 0, forecastKWh, time.Time{}) actions = eng.Decide(state, base.Add(8*time.Minute), 0)
found := false found := false
for _, a := range actions { for _, a := range actions {
if a.Consumer == ConsumerWallboxA && !a.TurnOn { if a.Consumer == ConsumerWallboxA && !a.TurnOn {
@@ -377,7 +358,7 @@ func TestCompressorIdleReleasesSGReady(t *testing.T) {
BatterySOC: 95, BatterySOC: 95,
CompressorPowerW: 1500, // compressor running CompressorPowerW: 1500, // compressor running
} }
actions := eng.Decide(state, base, 0, 0, time.Time{}) actions := eng.Decide(state, base, 0)
if len(actions) != 1 || actions[0].Consumer != ConsumerSGReady || !actions[0].TurnOn { if len(actions) != 1 || actions[0].Consumer != ConsumerSGReady || !actions[0].TurnOn {
t.Fatalf("expected SG-Ready to activate, got %v", actions) t.Fatalf("expected SG-Ready to activate, got %v", actions)
} }
@@ -385,7 +366,7 @@ func TestCompressorIdleReleasesSGReady(t *testing.T) {
// Compressor drops to idle — 3 consecutive cycles // Compressor drops to idle — 3 consecutive cycles
state.CompressorPowerW = 10 // below idle threshold state.CompressorPowerW = 10 // below idle threshold
for i := 1; i <= 3; i++ { for i := 1; i <= 3; i++ {
actions = eng.Decide(state, base.Add(time.Duration(i)*2*time.Minute), 0, 0, time.Time{}) actions = eng.Decide(state, base.Add(time.Duration(i)*2*time.Minute), 0)
} }
// After 3 idle cycles, SG-Ready should be released despite min-runtime not reached // After 3 idle cycles, SG-Ready should be released despite min-runtime not reached
@@ -402,47 +383,24 @@ func TestCompressorIdleReleasesSGReady(t *testing.T) {
func TestMinRuntimeRespected(t *testing.T) { func TestMinRuntimeRespected(t *testing.T) {
cfg := testConfig() cfg := testConfig()
cfg.Hysteresis.ExportOnDuration = "0s"
cfg.Hysteresis.MinRuntimeWallbox = "15m" cfg.Hysteresis.MinRuntimeWallbox = "15m"
cfg.Hysteresis.ImportOffDuration = "0s"
cfg.Strategic.ForecastMidKWh = 15
cfg.CarCharging = config.CarChargingConfig{
MinSOC: 35,
SOCFloor: 5,
PVThresholdAW: 1000,
}
eng := NewEngine(cfg, testLogger()) eng := NewEngine(cfg, testLogger())
base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC) // summer base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC) // summer
// Activate WallboxA via proactive charging // Activate Wallbox A
state := collector.SystemState{ state := collector.SystemState{
PVProductionW: 2000, GridPowerW: -2000,
GridPowerW: -2000, BatterySOC: 95,
BatterySOC: 95,
} }
eng.Decide(state, base, 0, 20.0, time.Time{}) // WallboxA activates eng.Decide(state, base, 0)
// Drop PV — now importing; proactive charging is active so import doesn't shut it down. // Try to shutdown after 5 minutes (< 15min minimum)
// But if we test a non-proactive consumer: inject WallboxA as non-proactive via RecoverState, state.GridPowerW = 500
// and verify min-runtime is still respected for import-shutdown path. eng.Decide(state, base.Add(1*time.Minute), 0) // start import timer
// Simpler: use RecoverState with SG-Ready active (min 30m), try to shut down in <30m.
eng2 := NewEngine(cfg, testLogger())
eng2.RecoverState(map[Consumer]DeviceStatus{
ConsumerSGReady: {On: true},
})
// SG-Ready ActivatedAt is zero (unknown) → treated as exceeding min-runtime, so it can be shut down.
// For a real min-runtime test, inject with SyncHardwareState to set ActivatedAt.
// Instead, manually set ActivatedAt via ApplyOverride then clear override:
cfg2 := testConfig()
cfg2.Hysteresis.MinRuntimeWallbox = "15m"
cfg2.Hysteresis.ImportOffDuration = "0s"
eng3 := NewEngine(cfg2, testLogger())
eng3.ApplyOverride(ConsumerWallboxA, true, 0) // turn on, no lock
// Reset override flag so import-shutdown applies
eng3.consumers[ConsumerWallboxA].ManualOverride = false
state2 := collector.SystemState{GridPowerW: 500, BatterySOC: 95} actions := eng.Decide(state, base.Add(8*time.Minute), 0) // import for >6min
actions := eng3.Decide(state2, base.Add(5*time.Minute), 0, 0, time.Time{}) // 5min < 15min
for _, a := range actions { for _, a := range actions {
if a.Consumer == ConsumerWallboxA && !a.TurnOn { if a.Consumer == ConsumerWallboxA && !a.TurnOn {

View File

@@ -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.OpenFile(m.filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}
return f.Close()
}
err := os.Remove(m.filePath)
if os.IsNotExist(err) {
return nil
}
return err
}

View File

@@ -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.
@@ -23,12 +21,12 @@ var consumerMeta = map[engine.Consumer]struct{ Label, Icon string }{
engine.ConsumerWallboxB: {"Wallbox B (4 kW)", "🔌"}, engine.ConsumerWallboxB: {"Wallbox B (4 kW)", "🔌"},
} }
// consumerOrder defines the display order of consumers — highest EMS priority first. // consumerOrder defines the display order of consumers.
var consumerOrder = []engine.Consumer{ var consumerOrder = []engine.Consumer{
engine.ConsumerSGReady,
engine.ConsumerWW,
engine.ConsumerWallboxA, engine.ConsumerWallboxA,
engine.ConsumerWallboxB, engine.ConsumerWallboxB,
engine.ConsumerWW,
engine.ConsumerSGReady,
} }
// consumerRecord tracks the state of a single consumer across cycles. // consumerRecord tracks the state of a single consumer across cycles.
@@ -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
@@ -57,35 +48,21 @@ type Store struct {
dryRun bool dryRun bool
wwConfigured bool wwConfigured bool
strategic config.StrategicConfig strategic config.StrategicConfig
carCharging config.CarChargingConfig
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
csrfToken string
hasAuth bool
} }
// NewStore creates a new status store. // NewStore creates a new status store.
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, carCharging config.CarChargingConfig, mode *monitor.Mode, wwBoostDisabled *monitor.Mode, tm *trip.Manager, cars []CarOption, csrfToken string, hasAuth bool) *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{}
} }
return &Store{ return &Store{
dryRun: dryRun, dryRun: dryRun,
wwConfigured: wwConfigured, wwConfigured: wwConfigured,
strategic: strategic, strategic: strategic,
carCharging: carCharging, consumers: consumers,
consumers: consumers,
monitorMode: mode,
wwBoostDisabled: wwBoostDisabled,
tripMgr: tm,
carOptions: cars,
csrfToken: csrfToken,
hasAuth: hasAuth,
} }
} }
@@ -132,22 +109,18 @@ func (s *Store) Update(state collector.SystemState, actions []engine.Action, ove
// --- Template data types --- // --- Template data types ---
type consumerView struct { type consumerView struct {
Icon string Icon string
Label string Label string
Priority string // e.g. "①" Priority string // e.g. "①"
ConsumerKey string // e.g. "wallbox_a" — used for the override form ConsumerKey string // e.g. "wallbox_a" — used for the override form
Active bool Active bool
Since time.Time Since time.Time
Reason string Reason string
SubDetail string // extra context line: tank temp for WW, compressor for SG-Ready Unconfigured bool
ReadinessHint string // shown when inactive: explains what's blocking activation CanOverride bool // true for Shelly consumers (hardware read-back available)
Unconfigured bool ManualOverride bool
CanOverride bool // true for Shelly consumers (hardware read-back available) OverrideUntil time.Time
IsWW bool // true for ConsumerWW — shows reset button instead of override LivePowerW float64 // from Shelly PM; 0 for non-PM devices
WWBoostDisabled bool // copied from pageData for template access inside range
ManualOverride bool
OverrideUntil time.Time
LivePowerW float64 // from Shelly PM; 0 for non-PM devices
} }
type phaseView struct { type phaseView struct {
@@ -164,43 +137,29 @@ type forecastView struct {
Quality string Quality string
} }
type tripView struct { type chargingAdviceView struct {
Active bool Show bool
CarName string PlugInBy string // e.g. "11:30"
WallboxLabel string // "Wallbox A" or "Wallbox B" WindowStart string // e.g. "12:00"
CurrentSOC float64 WindowEnd string // e.g. "16:00"
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
PVYieldTodayKWh float64
AmbientTempC float64 AmbientTempC float64
CompressorPowerW float64 CompressorPowerW float64
WWTopTempC float64
IsExporting bool IsExporting bool
AbsGridW float64 AbsGridW float64
HasPhaseData bool HasPhaseData bool
Phases []phaseView Phases []phaseView
Forecast forecastView Forecast forecastView
ChargingAdvice chargingAdviceView
Consumers []consumerView Consumers []consumerView
Trip tripView
CarOptions []CarOption
CSRFToken string
HasAuth bool // true when login is required — shows logout button
} }
// SyncConsumerStates updates active flags for all consumers directly from the // SyncConsumerStates updates active flags for all consumers directly from the
@@ -268,41 +227,24 @@ 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
forecastKWh := 0.0
if s.fcResult != nil {
forecastKWh = s.fcResult.TotalKWh
}
data := pageData{ data := pageData{
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,
PVYieldTodayKWh: s.state.PVYieldTodayKWh,
AmbientTempC: s.state.AmbientTempC, AmbientTempC: s.state.AmbientTempC,
CompressorPowerW: s.state.CompressorPowerW, CompressorPowerW: s.state.CompressorPowerW,
WWTopTempC: s.state.WWTopTempC,
IsExporting: s.state.GridPowerW < 0, IsExporting: s.state.GridPowerW < 0,
AbsGridW: abs(s.state.GridPowerW), AbsGridW: abs(s.state.GridPowerW),
HasPhaseData: hasPhase, HasPhaseData: hasPhase,
CSRFToken: s.csrfToken,
HasAuth: s.hasAuth,
} }
if hasPhase { if hasPhase {
for _, ph := range []struct { for _, ph := range []struct{ label string; w float64 }{
label string
w float64
}{
{"L1", l1}, {"L2", l2}, {"L3", l3}, {"L1", l1}, {"L2", l2}, {"L3", l3},
} { } {
data.Phases = append(data.Phases, phaseView{ data.Phases = append(data.Phases, phaseView{
@@ -320,6 +262,25 @@ func (s *Store) Handler() http.HandlerFunc {
Icon: s.fcResult.QualityIcon(s.strategic), Icon: s.fcResult.QualityIcon(s.strategic),
Quality: s.fcResult.Quality(s.strategic), Quality: s.fcResult.Quality(s.strategic),
} }
// Charging advice: show when surplus window is ahead and no wallbox is active
ws := s.fcResult.SurplusWindowStart
we := s.fcResult.SurplusWindowEnd
now := time.Now()
wallboxActive := s.consumers[engine.ConsumerWallboxA].active ||
s.consumers[engine.ConsumerWallboxB].active
if !ws.IsZero() && now.Before(ws) && !wallboxActive {
plugBy := ws.Add(-30 * time.Minute)
if plugBy.Before(now) {
plugBy = ws // less than 30 min to window — just show window start
}
data.ChargingAdvice = chargingAdviceView{
Show: true,
PlugInBy: plugBy.Format("15:04"),
WindowStart: ws.Format("15:04"),
WindowEnd: we.Format("15:04"),
}
}
} }
prioritySymbols := []string{"①", "②", "③", "④"} prioritySymbols := []string{"①", "②", "③", "④"}
for i, c := range consumerOrder { for i, c := range consumerOrder {
@@ -330,36 +291,17 @@ func (s *Store) Handler() http.HandlerFunc {
prio = prioritySymbols[i] prio = prioritySymbols[i]
} }
cv := consumerView{ cv := consumerView{
Icon: meta.Icon, Icon: meta.Icon,
Label: meta.Label, Label: meta.Label,
Priority: prio, Priority: prio,
ConsumerKey: c.String(), ConsumerKey: c.String(),
Active: rec.active, Active: rec.active,
Since: rec.since, Since: rec.since,
Reason: rec.reason, Reason: rec.reason,
CanOverride: c != engine.ConsumerWW, CanOverride: c != engine.ConsumerWW,
IsWW: c == engine.ConsumerWW, ManualOverride: rec.manualOverride,
WWBoostDisabled: c == engine.ConsumerWW && wwBoostDisabled, OverrideUntil: rec.overrideUntil,
ManualOverride: rec.manualOverride, LivePowerW: rec.livePowerW,
OverrideUntil: rec.overrideUntil,
LivePowerW: rec.livePowerW,
}
// SubDetail: extra context line per consumer type
switch c {
case engine.ConsumerSGReady:
if s.state.CompressorPowerW > 0 {
cv.SubDetail = "Kompressor: " + formatW(s.state.CompressorPowerW)
} else {
cv.SubDetail = "Kompressor: Standby"
}
case engine.ConsumerWW:
if s.state.WWTopTempC > 0 {
cv.SubDetail = fmt.Sprintf("Speicher: %.1f°C", s.state.WWTopTempC)
}
}
// ReadinessHint: shown when consumer is inactive and not overridden
if !rec.active && !rec.manualOverride {
cv.ReadinessHint = s.buildReadinessHint(c, s.state.BatterySOC, s.state.PVProductionW, forecastKWh)
} }
if c == engine.ConsumerWW && !s.wwConfigured { if c == engine.ConsumerWW && !s.wwConfigured {
cv.Unconfigured = true cv.Unconfigured = true
@@ -368,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)
@@ -381,109 +317,6 @@ func (s *Store) Handler() http.HandlerFunc {
} }
} }
// buildReadinessHint returns a short string explaining why a consumer is currently inactive.
// Returns empty string when the consumer is not forecast/threshold driven.
func (s *Store) buildReadinessHint(c engine.Consumer, soc, pvW, forecastKWh float64) string {
cc := s.carCharging
switch c {
case engine.ConsumerWallboxA, engine.ConsumerWallboxB:
if cc.PVThresholdAW == 0 && cc.PVThresholdBW == 0 {
return "" // proactive not configured
}
midKWh := s.strategic.ForecastMidKWh
if forecastKWh == 0 {
return "Keine Prognose verfügbar"
}
if forecastKWh < midKWh {
return fmt.Sprintf("Prognose zu gering (%.0f kWh)", forecastKWh)
}
if float64(cc.MinSOC) > 0 && soc < float64(cc.MinSOC) {
return fmt.Sprintf("SOC zu niedrig (min. %d%%)", cc.MinSOC)
}
threshold := cc.PVThresholdAW
if c == engine.ConsumerWallboxB {
threshold = cc.PVThresholdBW
}
if pvW < threshold {
return fmt.Sprintf("Warte auf PV ≥ %.0f W (aktuell %.0f W)", threshold, pvW)
}
return "Bereit — aktiviert bei nächstem Zyklus"
case engine.ConsumerWW:
if s.strategic.ForecastMidKWh > 0 && forecastKWh < s.strategic.ForecastMidKWh {
return fmt.Sprintf("Prognose zu gering (%.0f kWh)", forecastKWh)
}
}
return ""
}
// 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
@@ -533,65 +366,8 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
font-size: 0.85rem; font-size: 0.85rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.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-btn {
background: none; border: 1px solid #d1d5db; color: #9ca3af;
border-radius: 8px; padding: 0.25rem 0.75rem; font-size: 0.72rem;
cursor: pointer; flex-shrink: 0;
}
.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;
@@ -673,6 +449,33 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
margin-top: 0.1rem; margin-top: 0.1rem;
} }
/* Charging advice card */
.advice-card {
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 14px;
padding: 0.85rem 1rem;
margin-bottom: 1.25rem;
}
.advice-title {
font-size: 0.78rem;
font-weight: 700;
color: #1d4ed8;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.4rem;
}
.advice-main {
font-size: 1rem;
font-weight: 700;
color: #1e3a8a;
}
.advice-sub {
font-size: 0.78rem;
color: #3b82f6;
margin-top: 0.2rem;
}
/* Live power badge on consumers */ /* Live power badge on consumers */
.power-badge { .power-badge {
font-size: 0.72rem; font-size: 0.72rem;
@@ -739,20 +542,12 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
font-size: 0.78rem; font-size: 0.78rem;
color: #6b7280; color: #6b7280;
margin-top: 0.15rem; margin-top: 0.15rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.consumer-detail.active { color: #16a34a; font-weight: 500; } .consumer-detail.active { color: #16a34a; font-weight: 500; }
.consumer-detail.override { color: #d97706; font-weight: 500; } .consumer-detail.override { color: #d97706; font-weight: 500; }
.consumer-sub {
font-size: 0.72rem;
color: #9ca3af;
margin-top: 0.1rem;
}
.consumer-hint {
font-size: 0.72rem;
color: #6b7280;
margin-top: 0.1rem;
font-style: italic;
}
.dur-select { .dur-select {
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
@@ -784,31 +579,12 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
<header> <header>
<h1>☀️ Solar Status</h1> <h1>☀️ Solar Status</h1>
<div style="display:flex;align-items:center;gap:0.6rem"> {{if not .LastUpdate.IsZero}}
{{if not .LastUpdate.IsZero}} <span class="updated">{{.LastUpdate.Format "15:04:05"}}</span>
<span class="updated">{{.LastUpdate.Format "15:04:05"}}</span> {{end}}
{{end}}
{{if not .MonitorOnly}}
<form method="post" action="/monitor">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="monitor-toggle-btn">⏸ Monitor</button>
</form>
{{end}}
{{if .HasAuth}}
<a href="/logout" style="font-size:.8rem;color:#94a3b8;text-decoration:none;padding:.3rem .6rem;border:1px solid #e2e8f0;border-radius:.4rem">Abmelden</a>
{{end}}
</div>
</header> </header>
{{if .MonitorOnly}} {{if .DryRun}}
<div class="banner monitor">
<span>⏸ Monitor-Only — Keine Schaltvorgänge</span>
<form method="post" action="/monitor">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<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}}
@@ -848,7 +624,6 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
<div class="card"> <div class="card">
<div class="card-label">PV-Leistung</div> <div class="card-label">PV-Leistung</div>
<div class="card-value" style="color:#d97706">{{formatW .PVProductionW}}</div> <div class="card-value" style="color:#d97706">{{formatW .PVProductionW}}</div>
{{if gt .PVYieldTodayKWh 0.0}}<div class="card-sub">Heute: {{printf "%.1f" .PVYieldTodayKWh}} kWh</div>{{end}}
</div> </div>
<!-- Temperature --> <!-- Temperature -->
@@ -881,7 +656,6 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
<div class="forecast-row"> <div class="forecast-row">
<span class="forecast-icon">{{.Forecast.Icon}}</span> <span class="forecast-icon">{{.Forecast.Icon}}</span>
<span class="card-value" style="color:#d97706">{{printf "%.1f" .Forecast.KWh}} kWh</span> <span class="card-value" style="color:#d97706">{{printf "%.1f" .Forecast.KWh}} kWh</span>
{{if gt .PVYieldTodayKWh 0.0}}<span class="card-sub" style="margin-left:1rem">Ist: {{printf "%.1f" .PVYieldTodayKWh}} kWh</span>{{end}}
</div> </div>
<div class="forecast-quality">{{.Forecast.Quality}}</div> <div class="forecast-quality">{{.Forecast.Quality}}</div>
</div> </div>
@@ -889,6 +663,16 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
</div> </div>
{{if .ChargingAdvice.Show}}
<div class="advice-card">
<div class="advice-title">🔌 Ladeempfehlung</div>
<div class="advice-main">Auto einstecken bis {{.ChargingAdvice.PlugInBy}} Uhr</div>
<div class="advice-sub">
Erwartetes Überschuss-Fenster: {{.ChargingAdvice.WindowStart}} – {{.ChargingAdvice.WindowEnd}} Uhr
</div>
</div>
{{end}}
<div class="section-title">Verbraucher &mdash; Priorität ↓</div> <div class="section-title">Verbraucher &mdash; Priorität ↓</div>
{{range .Consumers}} {{range .Consumers}}
@@ -905,22 +689,19 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
{{else if .ManualOverride}} {{else if .ManualOverride}}
Manuell bis {{.OverrideUntil.Format "15:04"}} Manuell bis {{.OverrideUntil.Format "15:04"}}
{{else if .Active}} {{else if .Active}}
{{formatSince .Since}}{{if .Reason}} — {{.Reason}}{{end}} {{formatSince .Since}}
{{else if .Reason}}
{{.Reason}}
{{else}} {{else}}
Ausgeschaltet Ausgeschaltet
{{end}} {{end}}
</div> </div>
{{if .SubDetail}}<div class="consumer-sub">{{.SubDetail}}</div>{{end}}
{{if and (not .Active) (not .ManualOverride) .ReadinessHint}}
<div class="consumer-hint">{{.ReadinessHint}}</div>
{{end}}
</div> </div>
{{if and .Active .LivePowerW}} {{if and .Active .LivePowerW}}
<span class="power-badge">{{formatW .LivePowerW}}</span> <span class="power-badge">{{formatW .LivePowerW}}</span>
{{end}} {{end}}
{{if .CanOverride}} {{if .CanOverride}}
<form method="post" action="/override" style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0"> <form method="post" action="/override" style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="consumer" value="{{.ConsumerKey}}"> <input type="hidden" name="consumer" value="{{.ConsumerKey}}">
{{if .Active}} {{if .Active}}
<input type="hidden" name="state" value="off"> <input type="hidden" name="state" value="off">
@@ -938,73 +719,13 @@ 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">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<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">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
{{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}}
{{if .Trip.Active}} <!-- Compressor status (shown when SG-Ready context is relevant) -->
<div class="trip-card{{if .Trip.IsTight}} tight{{end}}"> {{if .CompressorPowerW}}
<div class="trip-header"> <div style="margin-top:0.5rem;font-size:0.75rem;color:#9ca3af;text-align:right">
<span class="trip-title">🚗 Fahrt geplant</span> Kompressor: {{formatW .CompressorPowerW}}
<form method="post" action="/trip/cancel">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="trip-cancel">Abbrechen</button>
</form>
</div>
<div class="trip-car">{{.Trip.CarName}} · {{.Trip.WallboxLabel}}</div>
<div class="trip-detail">Ladebedarf: {{printf "%.1f" .Trip.EnergyKWh}} kWh &nbsp;({{printf "%.0f" .Trip.CurrentSOC}}% → 100%) · ca. {{.Trip.DurationStr}}</div>
{{if .Trip.ChargingStarted}}
<div class="trip-timing">⚡ Lädt seit {{.Trip.ChargingSince}}</div>
{{else if .Trip.IsTight}}
<div class="trip-timing">⚠ Zeitfenster knapp — sofort einstecken!</div>
{{else}}
<div class="trip-timing">Laden startet: {{.Trip.StartsAtStr}}</div>
{{end}}
<div class="trip-deadline">Bereit bis: {{.Trip.DeadlineStr}}</div>
</div>
{{else if .CarOptions}}
<div class="trip-form-card">
<div class="section-title">🚗 Fahrtziel planen</div>
<form method="post" action="/trip" class="trip-form">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<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> </div>
{{end}} {{end}}

View File

@@ -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:]
}

395
main.go
View File

@@ -9,23 +9,18 @@ import (
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"sort"
"strconv"
"syscall" "syscall"
"time" "time"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/tb/ems/internal/actuator" "github.com/tb/ems/internal/actuator"
"github.com/tb/ems/internal/auth"
"github.com/tb/ems/internal/collector" "github.com/tb/ems/internal/collector"
"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/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"
) )
@@ -108,101 +103,31 @@ 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)
// Auth manager — loads or generates persistent session and CSRF tokens.
// Token files live alongside other persistent state in /var/lib/ems/.
tokenDir := filepath.Dir(cfg.EMS.SessionLogFile)
authMgr := auth.New(cfg.EMS.HTTPUsername, cfg.EMS.HTTPPassword, tokenDir, logger)
if authMgr.Enabled() {
logger.Info("web UI authentication enabled", "username", cfg.EMS.HTTPUsername)
} else {
logger.Warn("web UI authentication disabled — set ems.http_username and ems.http_password to enable")
}
// 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, cfg.CarCharging, monitorMode, wwBoostDisabled, tripMgr, carOptions, authMgr.CSRFToken(), authMgr.Enabled()) statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic)
// Web UI server — authenticated, serves status page and control endpoints. // Metrics HTTP server
uiMux := http.NewServeMux() mux := http.NewServeMux()
uiMux.HandleFunc("/login", authMgr.LoginHandler()) mux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
uiMux.HandleFunc("/logout", authMgr.LogoutHandler()) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
uiMux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("ok")) //nolint:errcheck w.Write([]byte("ok"))
}) })
uiMux.HandleFunc("/override", overrideHandler(act, eng, logger)) mux.HandleFunc("/override", overrideHandler(act, eng, logger))
uiMux.HandleFunc("/monitor", monitorHandler(monitorMode, logger)) mux.HandleFunc("/", statusStore.Handler())
uiMux.HandleFunc("/trip", tripSetHandler(tripMgr, cfg, logger))
uiMux.HandleFunc("/trip/cancel", tripCancelHandler(tripMgr, logger))
uiMux.HandleFunc("/ww/reset", wwResetHandler(act, eng, cfg, logger))
uiMux.HandleFunc("/ww/boost", wwBoostToggleHandler(wwBoostDisabled, logger))
uiMux.HandleFunc("/", statusStore.Handler())
var uiHandler http.Handler = authMgr.VerifyCSRF(uiMux) srv := &http.Server{
uiHandler = authMgr.RequireAuth(uiHandler)
uiSrv := &http.Server{
Addr: cfg.EMS.ListenAddr, Addr: cfg.EMS.ListenAddr,
Handler: uiHandler, Handler: mux,
}
// Metrics server — no auth, for Prometheus scraping.
// Bind to MetricsAddr; if unset, skip the separate server (metrics stay on UI port).
var metricsSrv *http.Server
if cfg.EMS.MetricsAddr != "" && cfg.EMS.MetricsAddr != cfg.EMS.ListenAddr {
metMux := http.NewServeMux()
metMux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
metMux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok")) //nolint:errcheck
})
metricsSrv = &http.Server{Addr: cfg.EMS.MetricsAddr, Handler: metMux}
} else {
// Fallback: add /metrics to the UI mux (no separate port configured)
uiMux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
} }
go func() { go func() {
logger.Info("web UI listening", "addr", cfg.EMS.ListenAddr) logger.Info("metrics server listening", "addr", cfg.EMS.ListenAddr)
if err := uiSrv.ListenAndServe(); err != http.ErrServerClosed { if err := srv.ListenAndServe(); err != http.ErrServerClosed {
logger.Error("web UI server error", "error", err) logger.Error("metrics server error", "error", err)
} }
}() }()
if metricsSrv != nil {
go func() {
logger.Info("metrics server listening", "addr", cfg.EMS.MetricsAddr)
if err := metricsSrv.ListenAndServe(); err != http.ErrServerClosed {
logger.Error("metrics server error", "error", err)
}
}()
}
// Graceful shutdown // Graceful shutdown
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@@ -218,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)
@@ -231,10 +156,7 @@ func main() {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel() defer shutdownCancel()
uiSrv.Shutdown(shutdownCtx) //nolint:errcheck srv.Shutdown(shutdownCtx)
if metricsSrv != nil {
metricsSrv.Shutdown(shutdownCtx) //nolint:errcheck
}
return return
} }
} }
@@ -252,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)
@@ -271,13 +189,11 @@ 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(), monitorMode.IsActive())
store.SyncDeviceStates(shellyStates) store.SyncDeviceStates(shellyStates)
} }
@@ -286,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 {
@@ -301,75 +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)
}
forecastKWh := 0.0
if fcResult != nil {
forecastKWh = fcResult.TotalKWh
}
// Estimate sunset as SurplusWindowEnd + 1h; fall back to zero (disables EOD check)
var sunsetTime time.Time
if fcResult != nil && !fcResult.SurplusWindowEnd.IsZero() {
sunsetTime = fcResult.SurplusWindowEnd.Add(time.Hour)
}
decisionStart := time.Now() decisionStart := time.Now()
actions := eng.Decide(state, now, wwBoostC, forecastKWh, sunsetTime) 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())
@@ -388,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,
@@ -403,192 +251,8 @@ func runCycle(
return return
} }
for i, err := range act.Execute(ctx, actions) { if err := act.Execute(ctx, actions); err != nil {
if err != nil { logger.Error("execution failed", "error", err)
eng.RollbackAction(actions[i])
}
}
}
// 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 errs := act.Execute(ctx, []engine.Action{action}); errs[0] != nil {
logger.Error("WW reset: actuator failed", "error", errs[0])
} 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)
} }
} }
@@ -655,8 +319,8 @@ func overrideHandler(act *actuator.Actuator, eng *engine.Engine, logger *slog.Lo
TurnOn: turnOn, TurnOn: turnOn,
Reason: fmt.Sprintf("manual override via web UI (%s)", duration), Reason: fmt.Sprintf("manual override via web UI (%s)", duration),
} }
if errs := act.Execute(ctx, []engine.Action{action}); errs[0] != nil { if err := act.Execute(ctx, []engine.Action{action}); err != nil {
logger.Error("web UI override failed", "consumer", consumerKey, "state", stateVal, "error", errs[0]) logger.Error("web UI override failed", "consumer", consumerKey, "state", stateVal, "error", err)
http.Error(w, "switch failed — check logs", http.StatusInternalServerError) http.Error(w, "switch failed — check logs", http.StatusInternalServerError)
return return
} }
@@ -793,4 +457,3 @@ func writeHeartbeat(stateFile string, logger *slog.Logger) {
logger.Warn("could not write heartbeat file", "error", err) logger.Warn("could not write heartbeat file", "error", err)
} }
} }

1
resume
View File

@@ -1 +0,0 @@
claude --resume b6e1143d-45c7-4162-b440-6228c86105b5