18 Commits

Author SHA1 Message Date
b8878c403c Latest Fixes
All checks were successful
CI/CD / test (push) Successful in 58s
CI/CD / build-push (push) Successful in 33s
CI/CD / deploy (push) Successful in 20s
2026-04-17 19:44:55 +02:00
f7d9b49555 health check Update and Fixes
All checks were successful
CI/CD / test (push) Successful in 52s
CI/CD / build-push (push) Successful in 31s
CI/CD / deploy (push) Successful in 20s
2026-04-12 14:21:26 +02:00
c4f0e8be01 Pipeline test update X-1
Some checks failed
CI/CD / test (push) Successful in 54s
CI/CD / build-push (push) Successful in 31s
CI/CD / deploy (push) Failing after 2m42s
2026-04-12 13:54:23 +02:00
7f00b1b0eb Pipeline test update X3
Some checks failed
CI/CD / test (push) Successful in 57s
CI/CD / build-push (push) Successful in 50s
CI/CD / deploy (push) Failing after 8s
2026-04-12 13:39:53 +02:00
e2acfac357 Pipeline test update X2
Some checks failed
CI/CD / test (push) Successful in 54s
CI/CD / build-push (push) Successful in 31s
CI/CD / deploy (push) Failing after 6s
2026-04-12 12:13:23 +02:00
42a48f342e Pipeline test update X
Some checks failed
CI/CD / test (push) Successful in 51s
CI/CD / build-push (push) Successful in 35s
CI/CD / deploy (push) Failing after 15s
2026-04-12 11:23:34 +02:00
84b98cd913 Pipeline test update NG
Some checks failed
CI/CD / test (push) Successful in 52s
CI/CD / build-push (push) Failing after 5s
CI/CD / deploy (push) Has been skipped
2026-04-12 11:15:31 +02:00
fc402f5749 Pipeline test update 3
All checks were successful
CI/CD / test (push) Successful in 51s
CI/CD / deploy (push) Has been skipped
2026-04-12 10:43:28 +02:00
94f0c93b89 Pipeline test update 2
Some checks failed
CI/CD / test (push) Failing after 16s
CI/CD / deploy (push) Has been skipped
2026-04-12 10:41:07 +02:00
b1982999bf Pipeline test update
Some checks failed
CI/CD / test (push) Failing after 29s
CI/CD / deploy (push) Has been skipped
2026-04-12 10:37:39 +02:00
64840914ca CICD Branch Change to house/lutz
Some checks failed
CI/CD / test (push) Has been cancelled
CI/CD / deploy (push) Has been cancelled
2026-04-12 10:16:18 +02:00
5354e34055 Latest Updates done, before integrating 2026-04-12 10:13:53 +02:00
db46fcf0c6 Add SG-Ready startup grace period and daily PV yield tracking
SG-Ready startup grace period (sg_ready_startup_min: 10):
Heat pump compressor takes ~9 minutes to start after receiving the
SG-Ready signal. Previously, idle detection fired after only 3×2min=6min,
turning off SG-Ready before the compressor had time to respond. The grace
period suppresses idle detection for the configured duration after activation.
0 = disabled (tests and unconfigured deployments are unaffected).

Daily PV yield (photovoltaic_production_cumulated_currentDay):
Reads actual kWh produced today from Prometheus (Wh → kWh, ÷1000).
Logged as pv_today_kwh each cycle. Shown on status page in two places:
- PV card sub-line: "Heute: 31.9 kWh"
- Forecast card alongside forecast: "Ist: 31.9 kWh"
Immediately makes forecast vs reality visible (today: forecast 15.9,
actual 31.9 kWh — explains why SOC bypass triggered).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 21:00:57 +02:00
2728755738 Fix proactive charging, logging, and status page ordering
1. SOC high bypass: when SOC >= soc_high_bypass_pct (default 90%), activate
   car charging without requiring a good forecast. Solves today's missed
   charging window where 4kW was exported for 6h with a full battery because
   the intraday forecast dropped from 15.4 to 13.3 kWh.

2. Log ambient_c in every cycle: makes it diagnosable why isHeatingPeriod
   suppresses SG-Ready on warm days (heating_min_ambient_c: 15°C gate).

3. Consumer names in logs: replace slog integer Consumer values with
   .String() so logs show "wallbox_a" instead of "2".

4. Status page order: reorder consumers by EMS priority (WallboxA →
   WallboxB → WW → SG-Ready) instead of the old reversed order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 20:47:56 +02:00
f45d23df97 Fix Shelly unreachable: roll back engine state on failed actions
Without this, a failed Execute() left engine state diverged from hardware.
SyncHardwareState would then misread the mismatch as a manual override and
apply a 1-hour lockout — causing either a stuck-on or stuck-off loop.

Now Execute() returns per-action []error. The control loop calls
Engine.RollbackAction() for each failed action, keeping engine state in
sync with hardware so the next cycle simply retries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 20:44:08 +02:00
2dd53d1543 Improve status page ergonomics
- WW card shows current tank temperature (Speicher: 48.7°C)
- SG-Ready card shows inline compressor power (Kompressor: 1.5 kW / Standby)
- Consumer reason text now wraps instead of being silently truncated
- Add readiness hints on inactive wallboxes: explains what's blocking
  activation (SOC too low / forecast insufficient / waiting for PV ≥ Xw)
- Add WW readiness hint when forecast is below threshold
- Move Monitor-Only toggle button to header (next to timestamp) for
  symmetric placement with the resume button in the monitor banner
- Remove charging advice card (outdated for proactive charging logic)
- Remove compressor power footnote from page bottom (now inline in card)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 21:30:01 +02:00
971e22c4be Implement proactive forecast-driven car charging strategy
- Replace reactive export-threshold wallbox activation with proactive
  logic: WallboxA/B activate when forecast ≥ mid AND PV ≥ threshold
  AND SOC ≥ 35%, without requiring grid export surplus
- Add WallboxB no-car detection via grid-delta probe (no PM available):
  after probe window, if grid shift < GridDeltaThreshW → no car, retry
  after configured timeout
- Add EOD soft stop: after 16:00, stop proactive car charging if
  remaining PV estimate can't cover battery deficit to 90% by sunset
- WW boost no longer requires export threshold; dynamic setpoint uses
  tank top temp + hysteresis + boost delta, capped at 60°C
- Proactive wallboxes bypass import-hysteresis shutdown; SOC emergency
  brake uses SOCFloor (5%) instead of standard AllConsumers gate
- Add WWTopTempC to SystemState (ww_top_temp metric from DHW cylinder)
- Add BatteryConfig (capacity_kwh), CarChargingConfig to config
- Add WWMaxSetpointC, WWHysteresisC to StrategicConfig
- Update Decide() signature: forecastKWh + sunsetTime parameters
- Update all tests; add proactive charging test cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 21:23:04 +02:00
fc535d395c Add per-consumer accepted import tolerance for wallboxes
With a 4.6kW inverter and 4kW WallboxB + ~450W house load, any PV dip
causes brief grid import and would trigger shutdown/restart cycling.

New config: wallbox_a_accepted_import_w / wallbox_b_accepted_import_w
WallboxB set to 600W: engine skips shutdown if import <= 600W, keeping
the car charging through short cloud shadows. WallboxA disabled (2kW
leaves sufficient inverter headroom without needing tolerance).

Logic: shutdownLastConsumer checks per-consumer tolerance before
applying min-runtime and initiating turn-off.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:34:18 +02:00
23 changed files with 1608 additions and 312 deletions

133
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,133 @@
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"

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
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"]

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.9.3

View File

@@ -15,6 +15,8 @@ prometheus:
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_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:
@@ -34,7 +36,7 @@ shelly:
# Viessmann API (write access for WW temperature)
viessmann:
token_file: "/etc/ems/viessmann-token.json" # OAuth2 refresh token
token_file: "/var/lib/ems/viessmann-token.json" # OAuth2 refresh token
client_id: "5d1235548dfaa01ce37db8f865d2df4d"
installation_id: "1253124"
gateway_serial: "7637415006796210"
@@ -69,6 +71,8 @@ strategic:
forecast_high_kwh: 25
forecast_mid_kwh: 15
ww_base_c: 48 # normal WW setpoint (°C)
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_mid_c: 3 # +3°C on medium-forecast days (>15 kWh)
ww_window_start: "12:30" # WW boost only allowed from 12:30
@@ -81,6 +85,9 @@ consumers:
compressor_idle_w: 500 # heat pump compressor below this = idle (W); running starts at ~2700W, so 500W cleanly separates idle (0W) from running
wallbox_min_charge_w: 50 # wallbox below this = car not charging (W)
idle_cycles: 3 # consecutive idle cycles before early release (~6 min at 2-min poll)
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:
@@ -105,17 +112,37 @@ forecast:
# EMS operational settings
ems:
poll_interval: "2m"
listen_addr: ":9099" # Prometheus metrics endpoint
listen_addr: ":9099" # web UI (login-protected; put behind Synology HTTPS proxy for internet access)
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"
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
recovery_timeout: "1h" # ignore Shelly state if EMS was down longer
override_timeout: "1h" # default lockout when EMS detects external Shelly change
override_max_import_w: 800 # cancel override immediately if importing more than this (0 = disabled)
monitor_only_file: "/etc/ems/monitor-only" # presence of this file = monitor-only mode active
ww_boost_disable_file: "/etc/ems/ww-boost-off" # presence of this file = WW boost disabled
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:

59
docker-compose.yml Normal file
View File

@@ -0,0 +1,59 @@
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 Normal file
View File

@@ -0,0 +1,12 @@
# 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

94
infra/ca/enroll-iphone.md Normal file
View File

@@ -0,0 +1,94 @@
# 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
```

40
infra/ca/gen-ca.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/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"

47
infra/ca/gen-server-cert.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/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."

57
infra/ca/issue-client-cert.sh Executable file
View File

@@ -0,0 +1,57 @@
#!/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."

37
infra/traefik/dynamic.yml Normal file
View File

@@ -0,0 +1,37 @@
# 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

24
infra/traefik/traefik.yml Normal file
View File

@@ -0,0 +1,24 @@
# 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,26 +38,30 @@ func NewActuator(cfg *config.Config, vc *viessmann.Client, logger *slog.Logger)
}
}
// Execute performs a list of switching actions.
func (a *Actuator) Execute(ctx context.Context, actions []engine.Action) error {
for _, action := range actions {
// Execute performs a list of switching actions and returns one error per action
// (nil on success). Failed actions do not prevent subsequent actions from running.
// The caller should roll back engine state for any failed action using
// 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 {
a.logger.Error("action failed",
"consumer", action.Consumer,
"turn_on", action.TurnOn,
"error", err,
)
// Continue with other actions even if one fails
errs[i] = err
continue
}
a.logger.Info("action executed",
"consumer", action.Consumer,
"turn_on", action.TurnOn,
"reason", action.Reason,
)
}
return nil
return errs
}
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))
err := act.Execute(context.Background(), []engine.Action{
errs := act.Execute(context.Background(), []engine.Action{
{Consumer: engine.ConsumerSGReady, TurnOn: true, Reason: "test"},
})
if err != nil {
t.Fatalf("Execute failed: %v", err)
if errs[0] != nil {
t.Fatalf("Execute failed: %v", errs[0])
}
if !sg.state {
@@ -112,11 +112,11 @@ func TestExecuteTurnOffWallboxA(t *testing.T) {
act := testActuator(t, ipFrom(dummySrv), ipFrom(wbASrv), ipFrom(dummySrv))
err := act.Execute(context.Background(), []engine.Action{
errs := act.Execute(context.Background(), []engine.Action{
{Consumer: engine.ConsumerWallboxA, TurnOn: false, Reason: "import"},
})
if err != nil {
t.Fatalf("Execute failed: %v", err)
if errs[0] != nil {
t.Fatalf("Execute failed: %v", errs[0])
}
if wbA.state {
@@ -164,13 +164,14 @@ func TestExecuteMultipleActions(t *testing.T) {
act := testActuator(t, ipFrom(sgSrv), ipFrom(wbASrv), ipFrom(wbBSrv))
err := act.Execute(context.Background(), []engine.Action{
for i, err := range act.Execute(context.Background(), []engine.Action{
{Consumer: engine.ConsumerSGReady, TurnOn: true},
{Consumer: engine.ConsumerWallboxA, TurnOn: true},
{Consumer: engine.ConsumerWallboxB, TurnOn: true},
})
if err != nil {
t.Fatalf("Execute failed: %v", err)
}) {
if err != nil {
t.Fatalf("Execute action %d failed: %v", i, err)
}
}
if !sg.state || !wbA.state || !wbB.state {

221
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,221 @@
// 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,6 +26,8 @@ type SystemState struct {
PhaseL1PowerW float64 // per-phase grid power L1 (positive=import, negative=export)
PhaseL2PowerW float64 // per-phase grid power L2
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.
@@ -91,6 +93,8 @@ func (c *Collector) Collect(ctx context.Context) (SystemState, error) {
{"phase_l1_power", &state.PhaseL1PowerW, 1},
{"phase_l2_power", &state.PhaseL2PowerW, 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 {
@@ -116,10 +120,13 @@ func (c *Collector) Collect(ctx context.Context) (SystemState, error) {
"grid_w", state.GridPowerW,
"soc", state.BatterySOC,
"pv_w", state.PVProductionW,
"pv_today_kwh", fmt.Sprintf("%.1f", state.PVYieldTodayKWh),
"compressor_w", state.CompressorPowerW,
"ambient_c", state.AmbientTempC,
"l1_w", state.PhaseL1PowerW,
"l2_w", state.PhaseL2PowerW,
"l3_w", state.PhaseL3PowerW,
"ww_top_c", state.WWTopTempC,
)
return state, nil

View File

@@ -2,6 +2,7 @@ package config
import (
"fmt"
"net"
"os"
"time"
@@ -10,18 +11,44 @@ import (
// Config is the top-level EMS configuration.
type Config struct {
Prometheus PrometheusConfig `yaml:"prometheus"`
Shelly ShellyConfig `yaml:"shelly"`
Viessmann ViessmannConfig `yaml:"viessmann"`
SOC SOCThresholds `yaml:"soc_thresholds"`
Hysteresis HysteresisConfig `yaml:"hysteresis"`
Thresholds PowerThresholds `yaml:"thresholds"`
Consumers ConsumersConfig `yaml:"consumers"`
Strategic StrategicConfig `yaml:"strategic"`
Season SeasonConfig `yaml:"season"`
Forecast ForecastConfig `yaml:"forecast"`
Cars map[string]CarProfile `yaml:"cars"`
EMS EMSConfig `yaml:"ems"`
Prometheus PrometheusConfig `yaml:"prometheus"`
Shelly ShellyConfig `yaml:"shelly"`
Viessmann ViessmannConfig `yaml:"viessmann"`
SOC SOCThresholds `yaml:"soc_thresholds"`
Hysteresis HysteresisConfig `yaml:"hysteresis"`
Thresholds PowerThresholds `yaml:"thresholds"`
Consumers ConsumersConfig `yaml:"consumers"`
Strategic StrategicConfig `yaml:"strategic"`
Season SeasonConfig `yaml:"season"`
Forecast ForecastConfig `yaml:"forecast"`
Battery BatteryConfig `yaml:"battery"`
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.
@@ -109,9 +136,12 @@ type PowerThresholds struct {
// ConsumersConfig holds per-consumer behavior thresholds.
type ConsumersConfig struct {
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)
IdleCycles int `yaml:"idle_cycles"` // consecutive idle cycles before early release
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)
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.
@@ -120,9 +150,11 @@ type StrategicConfig struct {
ForecastMidKWh float64 `yaml:"forecast_mid_kwh"`
WWBoostHighC float64 `yaml:"ww_boost_high_c"`
WWBoostMidC float64 `yaml:"ww_boost_mid_c"`
WWBaseC float64 `yaml:"ww_base_c"` // normal WW setpoint (°C)
WWWindowStart string `yaml:"ww_window_start"` // e.g. "12:30"
WWWindowEnd string `yaml:"ww_window_end"` // e.g. "18:00"
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
WWHysteresisC float64 `yaml:"ww_hysteresis_c"` // Viessmann switchOn = setpoint - hysteresis (°C), e.g. 5
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"`
ScheduleOff string `yaml:"schedule_off"`
}
@@ -160,7 +192,8 @@ func (f *ForecastConfig) FetchIntervalParsed() time.Duration {
// EMSConfig holds operational settings for the EMS daemon.
type EMSConfig struct {
PollInterval string `yaml:"poll_interval"`
ListenAddr string `yaml:"listen_addr"`
ListenAddr string `yaml:"listen_addr"` // web UI address (authenticated)
MetricsAddr string `yaml:"metrics_addr"` // Prometheus /metrics address (no auth, internal only)
LogLevel string `yaml:"log_level"`
StateFile string `yaml:"state_file"`
RecoveryTimeout string `yaml:"recovery_timeout"`
@@ -170,6 +203,8 @@ type EMSConfig struct {
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 {
@@ -195,6 +230,13 @@ func (e *EMSConfig) OverrideTimeoutParsed() time.Duration {
// Load reads and parses the YAML config file at the given path.
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)
if err != nil {
return nil, fmt.Errorf("reading config file: %w", err)
@@ -225,5 +267,21 @@ func (c *Config) validate() error {
if c.EMS.ListenAddr == "" {
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
}

View File

@@ -51,11 +51,14 @@ type DeviceStatus struct {
// ConsumerState tracks the runtime state of a single consumer.
type ConsumerState struct {
Active bool
ActivatedAt time.Time // when it was last turned on
ManualOverride bool
OverrideUntil time.Time
LowPowerCycles int // consecutive cycles with power below minimum threshold
Active bool
ActivatedAt time.Time // when it was last turned on
ManualOverride bool
OverrideUntil time.Time
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.
@@ -100,9 +103,11 @@ func NewEngine(cfg *config.Config, logger *slog.Logger) *Engine {
}
// 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
// (0 = no forecast / forecast too low to warrant boosting).
func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC float64) []Action {
//
// - wwBoostC: WW temperature boost in °C from PV forecast (0 = no boost warranted)
// - forecastKWh: today's forecast total in kWh (0 if forecasting disabled)
// - 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
soc := state.BatterySOC
@@ -114,10 +119,11 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo
e.logger.Debug("decision input",
"grid_w", gridW,
"soc", soc,
"pv_w", state.PVProductionW,
"forecast_kwh", forecastKWh,
"heating_period", heatingPeriod,
"ww_window", wwWindow,
"ww_boost_c", wwBoostC,
"allowed", allowed,
)
// --- SOC emergency brake ---
@@ -139,14 +145,21 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo
})
}
// --- Shutdown logic (reverse priority order) ---
// --- EOD soft stop: after configured time, stop car charging if remaining PV insufficient ---
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 e.hyst.ImportSinceAbove.IsZero() {
e.hyst.ImportSinceAbove = now
}
importDuration := now.Sub(e.hyst.ImportSinceAbove)
if importDuration >= e.cfg.Hysteresis.ImportOffDurationParsed() {
if a := e.shutdownLastConsumer(now); a != nil {
if a := e.shutdownLastConsumer(now, gridW); a != nil {
actions = append(actions, *a)
e.hyst.ImportSinceAbove = time.Time{}
}
@@ -156,8 +169,20 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo
}
// --- 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 state.CompressorPowerW < float64(e.cfg.Consumers.CompressorIdleW) {
startupMin := e.cfg.Consumers.SGReadyStartupMin
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++
e.logger.Debug("SG-Ready: compressor idle cycle",
"compressor_w", state.CompressorPowerW,
@@ -191,9 +216,17 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo
e.logger.Info("wallbox released: car not charging",
"consumer", wb,
"low_power_cycles", cs.LowPowerCycles,
"proactive", cs.ProactiveCharging,
)
cs.Active = false
cs.ProactiveCharging = false
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{
Consumer: wb,
TurnOn: false,
@@ -203,51 +236,267 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo
}
// --- 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,
)...)
}
// P2: WW boost (time window + forecast required)
if wwWindow && wwBoostC > 0 {
actions = append(actions, e.evaluateWWTurnOn(gridW, wwBoostC, allowed, now)...)
}
// P1: Proactive car charging — forecast-driven, no export threshold required.
// Mutual exclusion (A vs B) is enforced inside evaluateCarCharging.
actions = append(actions, e.evaluateCarCharging(state, forecastKWh, now)...)
// P3: Wallbox A (2kW, single-phase) — only if Wallbox B is not active.
// Uses per-phase export check if available, otherwise falls back to total.
// Re-reads Active state directly so a same-cycle activation of WallboxB blocks WallboxA.
if !e.consumers[ConsumerWallboxB].Active {
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,
)...)
}
// P2: WW boost — time window + forecast, no export threshold, no car charging.
// Only runs if no wallbox is active (car charging takes priority).
if wwWindow && wwBoostC > 0 &&
!e.consumers[ConsumerWallboxA].Active && !e.consumers[ConsumerWallboxB].Active {
actions = append(actions, e.evaluateWWTurnOn(state, wwBoostC, allowed, now)...)
}
// P4: Wallbox B (4kW, 3-phase) — only if Wallbox A is not active.
// Re-reads Active state so a same-cycle activation of WallboxA blocks WallboxB.
if !e.consumers[ConsumerWallboxA].Active {
actions = append(actions, e.evaluateTurnOn(
ConsumerWallboxB, gridW, e.cfg.Thresholds.WallboxBExportW,
allowed, now,
)...)
}
// P3: SG-Ready — reactive, export-threshold based, heating period only.
// Only runs if no wallbox is active (car charging takes priority).
if heatingPeriod &&
!e.consumers[ConsumerWallboxA].Active && !e.consumers[ConsumerWallboxB].Active {
actions = append(actions, e.evaluateTurnOn(
ConsumerSGReady, gridW, e.cfg.Thresholds.SGReadyExportW,
allowed, now,
)...)
}
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.
func min3(a, b, c float64) float64 {
if b < a {
@@ -330,7 +579,7 @@ func (e *Engine) evaluateTurnOn(
// All conditions met — turn on
e.logger.Info("turning on consumer",
"consumer", consumer,
"consumer", consumer.String(),
"grid_w", gridW,
"threshold", threshold,
"export_duration", exportDuration,
@@ -352,7 +601,8 @@ func (e *Engine) evaluateTurnOn(
// shutdownLastConsumer turns off the lowest-priority active consumer
// that has exceeded its minimum runtime.
func (e *Engine) shutdownLastConsumer(now time.Time) *Action {
// gridW is the current grid power (positive = import) used to check per-consumer import tolerance.
func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
// Reverse priority: WallboxB → WallboxA → WW → SGReady
order := []Consumer{ConsumerWallboxB, ConsumerWallboxA, ConsumerWW, ConsumerSGReady}
@@ -365,17 +615,42 @@ func (e *Engine) shutdownLastConsumer(now time.Time) *Action {
// Manually overridden to ON — don't shut down until override expires
if cs.ManualOverride && now.Before(cs.OverrideUntil) {
e.logger.Debug("skipping shutdown, consumer is manually overridden",
"consumer", c,
"consumer", c.String(),
"override_until", cs.OverrideUntil.Format("15:04"),
)
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)
runtime := now.Sub(cs.ActivatedAt)
if runtime < minRuntime {
e.logger.Debug("skipping shutdown, min runtime not reached",
"consumer", c,
"consumer", c.String(),
"runtime", runtime,
"min_runtime", minRuntime,
)
@@ -383,7 +658,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time) *Action {
}
e.logger.Info("shutting down consumer",
"consumer", c,
"consumer", c.String(),
"runtime", runtime,
)
@@ -404,6 +679,8 @@ func (e *Engine) shutdownLastConsumer(now time.Time) *Action {
// socEmergencyBrake immediately shuts off consumers whose SOC threshold
// 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 {
var actions []Action
allowed := e.allowedConsumers(soc)
@@ -412,12 +689,25 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
if !cs.Active {
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] {
continue
}
e.logger.Warn("SOC emergency brake",
"consumer", c,
"consumer", c.String(),
"soc", soc,
"was_override", cs.ManualOverride,
)
@@ -425,6 +715,7 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
cs.Active = false
cs.ManualOverride = false // EMS takes back full control after emergency
cs.OverrideUntil = time.Time{}
cs.ProactiveCharging = false
a := Action{
Consumer: c,
TurnOn: false,
@@ -455,7 +746,7 @@ func (e *Engine) overrideHardStop(gridW float64) []Action {
}
e.logger.Warn("override hard stop: import exceeds limit",
"consumer", c,
"consumer", c.String(),
"grid_w", gridW,
"limit_w", limit,
)
@@ -495,13 +786,41 @@ func (e *Engine) ApplyOverride(consumer Consumer, on bool, duration time.Duratio
cs.ActivatedAt = time.Time{}
}
e.logger.Info("manual override applied",
"consumer", consumer,
"consumer", consumer.String(),
"on", on,
"duration", duration,
"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
// and outdoor temperature. If HeatingMinAmbientC is configured (> 0), ambient
// temperatures above that threshold suppress SG-Ready even within the heating months.
@@ -548,50 +867,51 @@ func (e *Engine) minRuntime(c Consumer) time.Duration {
}
// evaluateWWTurnOn checks whether WW boost should be activated.
// Prerequisites (time window and forecast) are already verified by the caller.
func (e *Engine) evaluateWWTurnOn(gridW, wwBoostC float64, allowed map[Consumer]bool, now time.Time) []Action {
// Prerequisites (time window, forecast > 0, no car charging) are already verified by the caller.
// No export threshold is required — the heat pump compressor load is covered by PV.
// 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]
if cs.Active {
return nil
}
if cs.ManualOverride && now.Before(cs.OverrideUntil) {
delete(e.hyst.ExportSinceAbove, ConsumerWW)
return nil
}
if !allowed[ConsumerWW] {
delete(e.hyst.ExportSinceAbove, ConsumerWW)
return nil
}
if gridW > e.cfg.Thresholds.WWExportW {
delete(e.hyst.ExportSinceAbove, ConsumerWW)
return nil
}
if _, ok := e.hyst.ExportSinceAbove[ConsumerWW]; !ok {
e.hyst.ExportSinceAbove[ConsumerWW] = now
// Dynamic setpoint: set high enough above current temp to trigger heating immediately.
// Viessmann switchOn threshold = setpoint - hysteresis.
// Target = current_top + hysteresis + boost_delta, capped at max.
hysteresis := e.cfg.Strategic.WWHysteresisC
if hysteresis == 0 {
hysteresis = 5 // safe default
}
exportDuration := now.Sub(e.hyst.ExportSinceAbove[ConsumerWW])
if exportDuration < e.cfg.Hysteresis.ExportOnDurationParsed() {
return nil
maxSetpoint := e.cfg.Strategic.WWMaxSetpointC
if maxSetpoint == 0 {
maxSetpoint = 60 // safe default
}
targetTemp := state.WWTopTempC + hysteresis + wwBoostC
if targetTemp > maxSetpoint {
targetTemp = maxSetpoint
}
targetTemp := e.cfg.Strategic.WWBaseC + wwBoostC
e.logger.Info("activating WW boost",
"grid_w", gridW,
"ww_top_c", state.WWTopTempC,
"ww_boost_c", wwBoostC,
"target_temp_c", targetTemp,
"export_duration", exportDuration,
)
cs.Active = true
cs.ActivatedAt = now
delete(e.hyst.ExportSinceAbove, ConsumerWW)
return []Action{{
Consumer: ConsumerWW,
TurnOn: true,
TargetTempC: targetTemp,
Reason: fmt.Sprintf("export %.0fW for %s, WW boost +%.0f°C", -gridW, exportDuration, wwBoostC),
Reason: fmt.Sprintf("WW boost +%.0f°C → setpoint %.0f°C (tank %.0f°C)", wwBoostC, targetTemp, state.WWTopTempC),
}}
}
@@ -651,7 +971,10 @@ func (e *Engine) RecoverState(states map[Consumer]DeviceStatus) {
// On match: expired overrides are cleared, resuming normal EMS control.
// Power readings (from PM-capable devices) update the low-power cycle counter for
// car-not-charging detection.
func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Time, overrideTimeout time.Duration) {
// When monitorOnly is true, hardware mismatches silently update engine state without
// 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 {
cs, ok := e.consumers[c]
if !ok {
@@ -659,27 +982,39 @@ func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Ti
}
if cs.Active != status.On {
// External change detected
e.logger.Info("manual override detected — external state change",
"consumer", c,
"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{}
if monitorOnly {
// In monitor-only mode the EMS cannot execute switch actions, so a
// mismatch just means a pending action couldn't be carried out.
// Silently realign engine state to hardware without locking.
cs.Active = status.On
if !status.On {
cs.ActivatedAt = time.Time{}
} else {
cs.ActivatedAt = now
}
} else {
cs.ActivatedAt = now
// External change detected — log and apply override lockout
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) {
// Override expired and state matches — resume EMS control
cs.ManualOverride = false
cs.OverrideUntil = time.Time{}
e.logger.Info("manual override expired, resuming EMS control", "consumer", c)
e.logger.Info("manual override expired, resuming EMS control", "consumer", c.String())
}
// Track low-power cycles for car-not-charging detection (PM devices only).
@@ -688,7 +1023,7 @@ func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Ti
if cs.Active && status.PowerW < float64(e.cfg.Consumers.WallboxMinChargeW) {
cs.LowPowerCycles++
e.logger.Debug("wallbox low power cycle",
"consumer", c,
"consumer", c.String(),
"power_w", status.PowerW,
"low_power_cycles", cs.LowPowerCycles,
)

View File

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

View File

@@ -46,7 +46,7 @@ func (m *Mode) Set(active bool) error {
}
if active {
f, err := os.Create(m.filePath)
f, err := os.OpenFile(m.filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}

View File

@@ -23,12 +23,12 @@ var consumerMeta = map[engine.Consumer]struct{ Label, Icon string }{
engine.ConsumerWallboxB: {"Wallbox B (4 kW)", "🔌"},
}
// consumerOrder defines the display order of consumers.
// consumerOrder defines the display order of consumers — highest EMS priority first.
var consumerOrder = []engine.Consumer{
engine.ConsumerSGReady,
engine.ConsumerWW,
engine.ConsumerWallboxA,
engine.ConsumerWallboxB,
engine.ConsumerWW,
engine.ConsumerSGReady,
}
// consumerRecord tracks the state of a single consumer across cycles.
@@ -57,16 +57,19 @@ type Store struct {
dryRun bool
wwConfigured bool
strategic config.StrategicConfig
carCharging config.CarChargingConfig
consumers map[engine.Consumer]*consumerRecord
fcResult *forecast.Result
monitorMode *monitor.Mode
wwBoostDisabled *monitor.Mode
tripMgr *trip.Manager
carOptions []CarOption
csrfToken string
hasAuth bool
}
// NewStore creates a new status store.
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, mode *monitor.Mode, wwBoostDisabled *monitor.Mode, tm *trip.Manager, cars []CarOption) *Store {
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, carCharging config.CarChargingConfig, mode *monitor.Mode, wwBoostDisabled *monitor.Mode, tm *trip.Manager, cars []CarOption, csrfToken string, hasAuth bool) *Store {
consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder))
for _, c := range consumerOrder {
consumers[c] = &consumerRecord{}
@@ -75,11 +78,14 @@ func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig,
dryRun: dryRun,
wwConfigured: wwConfigured,
strategic: strategic,
carCharging: carCharging,
consumers: consumers,
monitorMode: mode,
wwBoostDisabled: wwBoostDisabled,
tripMgr: tm,
carOptions: cars,
csrfToken: csrfToken,
hasAuth: hasAuth,
}
}
@@ -126,20 +132,22 @@ func (s *Store) Update(state collector.SystemState, actions []engine.Action, ove
// --- Template data types ---
type consumerView struct {
Icon string
Label string
Priority string // e.g. "①"
ConsumerKey string // e.g. "wallbox_a" — used for the override form
Active bool
Since time.Time
Reason string
Unconfigured bool
CanOverride bool // true for Shelly consumers (hardware read-back available)
IsWW bool // true for ConsumerWW — shows reset button instead of override
WWBoostDisabled bool // copied from pageData for template access inside range
ManualOverride bool
OverrideUntil time.Time
LivePowerW float64 // from Shelly PM; 0 for non-PM devices
Icon string
Label string
Priority string // e.g. "①"
ConsumerKey string // e.g. "wallbox_a" — used for the override form
Active bool
Since time.Time
Reason string
SubDetail string // extra context line: tank temp for WW, compressor for SG-Ready
ReadinessHint string // shown when inactive: explains what's blocking activation
Unconfigured bool
CanOverride bool // true for Shelly consumers (hardware read-back available)
IsWW bool // true for ConsumerWW — shows reset button instead of override
WWBoostDisabled bool // copied from pageData for template access inside range
ManualOverride bool
OverrideUntil time.Time
LivePowerW float64 // from Shelly PM; 0 for non-PM devices
}
type phaseView struct {
@@ -156,13 +164,6 @@ type forecastView struct {
Quality string
}
type chargingAdviceView struct {
Show bool
PlugInBy string // e.g. "11:30"
WindowStart string // e.g. "12:00"
WindowEnd string // e.g. "16:00"
}
type tripView struct {
Active bool
CarName string
@@ -186,17 +187,20 @@ type pageData struct {
BatterySOC float64
GridPowerW float64
PVProductionW float64
PVYieldTodayKWh float64
AmbientTempC float64
CompressorPowerW float64
WWTopTempC float64
IsExporting bool
AbsGridW float64
HasPhaseData bool
Phases []phaseView
Forecast forecastView
ChargingAdvice chargingAdviceView
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
@@ -271,6 +275,10 @@ func (s *Store) Handler() http.HandlerFunc {
s.mu.RLock()
l1, l2, l3 := s.state.PhaseL1PowerW, s.state.PhaseL2PowerW, s.state.PhaseL3PowerW
hasPhase := l1 != 0 || l2 != 0 || l3 != 0
forecastKWh := 0.0
if s.fcResult != nil {
forecastKWh = s.fcResult.TotalKWh
}
data := pageData{
LastUpdate: s.lastUpdate,
ErrMsg: s.errMsg,
@@ -280,14 +288,21 @@ func (s *Store) Handler() http.HandlerFunc {
BatterySOC: s.state.BatterySOC,
GridPowerW: s.state.GridPowerW,
PVProductionW: s.state.PVProductionW,
PVYieldTodayKWh: s.state.PVYieldTodayKWh,
AmbientTempC: s.state.AmbientTempC,
CompressorPowerW: s.state.CompressorPowerW,
WWTopTempC: s.state.WWTopTempC,
IsExporting: s.state.GridPowerW < 0,
AbsGridW: abs(s.state.GridPowerW),
HasPhaseData: hasPhase,
CSRFToken: s.csrfToken,
HasAuth: s.hasAuth,
}
if hasPhase {
for _, ph := range []struct{ label string; w float64 }{
for _, ph := range []struct {
label string
w float64
}{
{"L1", l1}, {"L2", l2}, {"L3", l3},
} {
data.Phases = append(data.Phases, phaseView{
@@ -305,24 +320,6 @@ func (s *Store) Handler() http.HandlerFunc {
Icon: s.fcResult.QualityIcon(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
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{"①", "②", "③", "④"}
for i, c := range consumerOrder {
@@ -333,19 +330,36 @@ func (s *Store) Handler() http.HandlerFunc {
prio = prioritySymbols[i]
}
cv := consumerView{
Icon: meta.Icon,
Label: meta.Label,
Priority: prio,
ConsumerKey: c.String(),
Active: rec.active,
Since: rec.since,
Reason: rec.reason,
Icon: meta.Icon,
Label: meta.Label,
Priority: prio,
ConsumerKey: c.String(),
Active: rec.active,
Since: rec.since,
Reason: rec.reason,
CanOverride: c != engine.ConsumerWW,
IsWW: c == engine.ConsumerWW,
WWBoostDisabled: c == engine.ConsumerWW && wwBoostDisabled,
ManualOverride: rec.manualOverride,
OverrideUntil: rec.overrideUntil,
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 {
cv.Unconfigured = true
@@ -367,6 +381,41 @@ 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()
@@ -496,13 +545,10 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
font-weight: 700; cursor: pointer; white-space: nowrap; flex-shrink: 0;
}
.resume-btn:active { opacity: 0.7; }
.monitor-toggle {
text-align: center; margin-top: 1.5rem; padding-bottom: 0.5rem;
}
.monitor-toggle-btn {
background: none; border: 1px solid #d1d5db; color: #9ca3af;
border-radius: 8px; padding: 0.35rem 1rem; font-size: 0.75rem;
cursor: pointer;
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; }
@@ -627,33 +673,6 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
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 */
.power-badge {
font-size: 0.72rem;
@@ -720,12 +739,20 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
font-size: 0.78rem;
color: #6b7280;
margin-top: 0.15rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.consumer-detail.active { color: #16a34a; 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 {
border: 1px solid #d1d5db;
@@ -757,15 +784,27 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
<header>
<h1>☀️ Solar Status</h1>
{{if not .LastUpdate.IsZero}}
<span class="updated">{{.LastUpdate.Format "15:04:05"}}</span>
{{end}}
<div style="display:flex;align-items:center;gap:0.6rem">
{{if not .LastUpdate.IsZero}}
<span class="updated">{{.LastUpdate.Format "15:04:05"}}</span>
{{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>
{{if .MonitorOnly}}
<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>
@@ -809,6 +848,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
<div class="card">
<div class="card-label">PV-Leistung</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>
<!-- Temperature -->
@@ -841,6 +881,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
<div class="forecast-row">
<span class="forecast-icon">{{.Forecast.Icon}}</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 class="forecast-quality">{{.Forecast.Quality}}</div>
</div>
@@ -848,16 +889,6 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
</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>
{{range .Consumers}}
@@ -874,19 +905,22 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
{{else if .ManualOverride}}
Manuell bis {{.OverrideUntil.Format "15:04"}}
{{else if .Active}}
{{formatSince .Since}}
{{else if .Reason}}
{{.Reason}}
{{formatSince .Since}}{{if .Reason}} — {{.Reason}}{{end}}
{{else}}
Ausgeschaltet
{{end}}
</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>
{{if and .Active .LivePowerW}}
<span class="power-badge">{{formatW .LivePowerW}}</span>
{{end}}
{{if .CanOverride}}
<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}}">
{{if .Active}}
<input type="hidden" name="state" value="off">
@@ -908,10 +942,12 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
<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}}
@@ -923,26 +959,12 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
</div>
{{end}}
<!-- Compressor status (shown when SG-Ready context is relevant) -->
{{if .CompressorPowerW}}
<div style="margin-top:0.5rem;font-size:0.75rem;color:#9ca3af;text-align:right">
Kompressor: {{formatW .CompressorPowerW}}
</div>
{{end}}
{{if not .MonitorOnly}}
<div class="monitor-toggle">
<form method="post" action="/monitor">
<button type="submit" class="monitor-toggle-btn">⏸ Monitor-Only</button>
</form>
</div>
{{end}}
{{if .Trip.Active}}
<div class="trip-card{{if .Trip.IsTight}} tight{{end}}">
<div class="trip-header">
<span class="trip-title">🚗 Fahrt geplant</span>
<form method="post" action="/trip/cancel">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="trip-cancel">Abbrechen</button>
</form>
</div>
@@ -961,6 +983,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
<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>

108
main.go
View File

@@ -17,6 +17,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/tb/ems/internal/actuator"
"github.com/tb/ems/internal/auth"
"github.com/tb/ems/internal/collector"
"github.com/tb/ems/internal/config"
"github.com/tb/ems/internal/engine"
@@ -134,36 +135,74 @@ func main() {
// 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)
wwConfigured := cfg.Viessmann.InstallationID != ""
statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, monitorMode, wwBoostDisabled, tripMgr, carOptions)
statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, cfg.CarCharging, monitorMode, wwBoostDisabled, tripMgr, carOptions, authMgr.CSRFToken(), authMgr.Enabled())
// Metrics HTTP server
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
// Web UI server — authenticated, serves status page and control endpoints.
uiMux := http.NewServeMux()
uiMux.HandleFunc("/login", authMgr.LoginHandler())
uiMux.HandleFunc("/logout", authMgr.LogoutHandler())
uiMux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
w.Write([]byte("ok")) //nolint:errcheck
})
mux.HandleFunc("/override", overrideHandler(act, eng, logger))
mux.HandleFunc("/monitor", monitorHandler(monitorMode, logger))
mux.HandleFunc("/trip", tripSetHandler(tripMgr, cfg, logger))
mux.HandleFunc("/trip/cancel", tripCancelHandler(tripMgr, logger))
mux.HandleFunc("/ww/reset", wwResetHandler(act, eng, cfg, logger))
mux.HandleFunc("/ww/boost", wwBoostToggleHandler(wwBoostDisabled, logger))
mux.HandleFunc("/", statusStore.Handler())
uiMux.HandleFunc("/override", overrideHandler(act, eng, logger))
uiMux.HandleFunc("/monitor", monitorHandler(monitorMode, logger))
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())
srv := &http.Server{
var uiHandler http.Handler = authMgr.VerifyCSRF(uiMux)
uiHandler = authMgr.RequireAuth(uiHandler)
uiSrv := &http.Server{
Addr: cfg.EMS.ListenAddr,
Handler: mux,
Handler: uiHandler,
}
// 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() {
logger.Info("metrics server listening", "addr", cfg.EMS.ListenAddr)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
logger.Error("metrics server error", "error", err)
logger.Info("web UI listening", "addr", cfg.EMS.ListenAddr)
if err := uiSrv.ListenAndServe(); err != http.ErrServerClosed {
logger.Error("web UI 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
ctx, cancel := context.WithCancel(context.Background())
@@ -192,7 +231,10 @@ func main() {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
srv.Shutdown(shutdownCtx)
uiSrv.Shutdown(shutdownCtx) //nolint:errcheck
if metricsSrv != nil {
metricsSrv.Shutdown(shutdownCtx) //nolint:errcheck
}
return
}
}
@@ -235,7 +277,7 @@ func runCycle(
logger.Warn("all Shelly devices unreachable, skipping override detection", "error", err)
} else {
shellyStates = states
eng.SyncHardwareState(shellyStates, now, cfg.EMS.OverrideTimeoutParsed())
eng.SyncHardwareState(shellyStates, now, cfg.EMS.OverrideTimeoutParsed(), monitorMode.IsActive())
store.SyncDeviceStates(shellyStates)
}
@@ -263,8 +305,17 @@ func runCycle(
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()
actions := eng.Decide(state, now, wwBoostC)
actions := eng.Decide(state, now, wwBoostC, forecastKWh, sunsetTime)
m.DecisionDuration.Observe(time.Since(decisionStart).Seconds())
// Step 3b: Trip mode — session energy tracking + wallbox activation
@@ -352,8 +403,10 @@ func runCycle(
return
}
if err := act.Execute(ctx, actions); err != nil {
logger.Error("execution failed", "error", err)
for i, err := range act.Execute(ctx, actions) {
if err != nil {
eng.RollbackAction(actions[i])
}
}
}
@@ -511,8 +564,8 @@ func wwResetHandler(act *actuator.Actuator, eng *engine.Engine, cfg *config.Conf
TargetTempC: cfg.Strategic.WWBaseC,
Reason: "manual WW reset via web UI",
}
if err := act.Execute(ctx, []engine.Action{action}); err != nil {
logger.Error("WW reset: actuator failed", "error", err)
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"))
}
@@ -602,8 +655,8 @@ func overrideHandler(act *actuator.Actuator, eng *engine.Engine, logger *slog.Lo
TurnOn: turnOn,
Reason: fmt.Sprintf("manual override via web UI (%s)", duration),
}
if err := act.Execute(ctx, []engine.Action{action}); err != nil {
logger.Error("web UI override failed", "consumer", consumerKey, "state", stateVal, "error", err)
if errs := act.Execute(ctx, []engine.Action{action}); errs[0] != nil {
logger.Error("web UI override failed", "consumer", consumerKey, "state", stateVal, "error", errs[0])
http.Error(w, "switch failed — check logs", http.StatusInternalServerError)
return
}
@@ -740,3 +793,4 @@ func writeHeartbeat(stateFile string, logger *slog.Logger) {
logger.Warn("could not write heartbeat file", "error", err)
}
}

1
resume Normal file
View File

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