30 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
009f8b05f3 Stack WW boost buttons vertically in consumer card
The reset and boost toggle buttons were side by side, looking cramped.
Wrapped them in a flex column div so they stack neatly below each other.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:23:27 +02:00
4d4b7fb047 Fix WW boost toggle: move WWBoostDisabled into consumerView
Template range loop sets . to consumerView, not pageData, so
.WWBoostDisabled was undefined. Added the field to consumerView
and populated it from the wwBoostDisabled local in the handler.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:18:18 +02:00
afbb7bf62c Add WW boost enable/disable toggle (persistent flag file)
In summer the WW boost interferes with car charging priority.
A new ⛔/✅ toggle on the WW consumer card persists to
/etc/ems/ww-boost-off (presence = disabled, absence = enabled).

- config: ww_boost_disable_file flag file path
- main: wwBoostDisabled mode, wwBoostToggleHandler, runCycle skips
  computeWWBoost when disabled
- status: WWBoostDisabled in pageData, toggle button in WW card
  showing ⛔ Boost aus / ✅ Boost ein depending on state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:09:33 +02:00
1b57293da9 Fix compressor watts, add WW boost reset button
Compressor:
- metric changed to PromQL expression that returns actual watts:
  sensors_power_value(%) × power_value(kW) × 10
  e.g. 27% × 10 kW = 2700 W; idle = 0 W
- idle threshold raised 20→500 W (clean gap: 0W idle vs ≥2700W running)
- status page now displays real kW instead of percent

WW boost reset:
- Added 🌡️ Zurücksetzen button to WW consumer card (when configured)
- POST /ww/reset: restores WWBaseC via Viessmann API + blocks re-boost
  via engine override until midnight
- IsWW field added to consumerView for template control

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:00:42 +02:00
a537b02adb Fix compressor metric comments: sensors_power_value is % of rated power
heating_compressors_0_sensors_power_value returns 0-100% of rated
compressor power, not watts. heating_compressors_0_power_value is the
constant rated power in kW (10 kW). Actual watts = sensors% × kW × 10.

Idle threshold of 20 remains correct: values are either 0% (idle) or
≥27% (running), so 20 cleanly separates them.

Grafana panel updated to show computed actual watts using the formula.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 14:49:28 +02:00
9f34fbd393 Fix compressor idle detection: use sensors_power_value, lower threshold to 20W
heating_compressors_0_power_value is a Viessmann load level (stuck at 10),
not actual watts. The real power sensor is sensors_power_value (0W idle,
27-80W when running). With the wrong metric SG-Ready was always released
early after ~6 min regardless of min_runtime_sg_ready.

Threshold lowered 50→20W to cleanly separate idle (0W) from low-modulation
running states.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 14:39:04 +02:00
406046c3a9 Add trip mode: self-learning EV charging scheduler
User enters car SOC + departure deadline; EMS computes when to start
charging and activates the wallbox at the right time (grid import OK
for trip mode). Charging rate is learned from completed PM sessions,
improving over time. Car profiles (Mini Cooper SE 50 kWh, BMW ix2 63
kWh) are config-driven and selectable per trip.

- internal/trip/trip.go: Goal/Session types, Manager with per-cycle
  Tick() that accumulates energy, detects session completion, learns
  avg charge rate from JSONL session log
- internal/config: CarProfile + Cars map, TripGoalFile, SessionLogFile
- internal/status: trip card (active goal) + trip form (set new goal),
  CarOption list, formatDay/formatDur helpers
- main.go: tripMgr lifecycle, /trip + /trip/cancel HTTP handlers,
  runCycle integration (ShouldStartNow → ApplyOverride, auto-clear)
- configs/ems-config.yaml: cars section, trip_goal_file, session_log_file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 12:51:41 +02:00
00f8f3cdde Add monitor-only mode: suppress all actions without stopping EMS
New internal/monitor package provides a thread-safe, file-backed toggle.
Flag file presence (/etc/ems/monitor-only) = monitor-only active, survives
reboots. Deletion = resume normal operation.

Behaviour when active:
- EMS continues polling Prometheus, running the decision engine, and
  updating the status page every 2 minutes — full visibility maintained
- All actuator calls suppressed (Shelly switches + Viessmann WW writes)
- Suppressed actions logged as [MONITOR-ONLY] for audit trail
- Startup warns if flag file is already present

Web UI:
- Amber banner "⏸ Monitor-Only — Keine Schaltvorgänge" with inline
  "▶ Automatik" resume button when active
- Small unobtrusive "⏸ Monitor-Only" button at page bottom when inactive
- POST /monitor endpoint toggles state and redirects back to status page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:27:53 +02:00
0b51ce5240 Add ambient temperature gate to heating period detection
Adds heating_min_ambient_c (default 15°C) to season config. When outdoor
temperature is at or above this threshold, SG-Ready is suppressed even if
the calendar month is within the heating season. Prevents unnecessary heat
pump boost activation on warm spring/autumn days.

Logic: heating active = in_heating_month AND ambient < threshold
Zero value (unset) disables the temperature gate (calendar-only, old behaviour).

New test: TestHeatingPeriodAmbientSuppression covers warm-day suppression,
cold-day pass-through, and summer month independence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:13:05 +02:00
cc33add507 Show consumer priority order on status page
Section title changed to "Verbraucher — Priorität ↓" and each consumer
row now shows a circled number (① ② ③ ④) so it's immediately clear
which consumer has precedence for turn-on and reverse order for shutdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:08:34 +02:00
56710b4328 Fix Viessmann API hostname and harden test propagation waits
- Update apiBase: api.viessmann.com → api.viessmann-climatesolutions.com
  (Viessmann renamed their IoT API host; old domain is NXDOMAIN)
- Extend --test-viessmann context to 120s
- Add 30s propagation wait before read-back in both Stage 3 and Restore:
  cloud accepts writes immediately but gateway polls on its own schedule,
  so GET returns the last confirmed device value with ~30s lag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:05:33 +02:00
8ba86f8fc8 Add --test-viessmann flag for end-to-end API validation
Adds GetDHWTemperature() to the Viessmann client (GET on the DHW feature
endpoint) and a --test-viessmann CLI flag that runs three stages without
starting the EMS control loop:

  Stage 1: read current DHW setpoint (confirms auth + endpoint path)
  Stage 2: write same value back, read-back to confirm round-trip
  Stage 3: write +1°C, read-back to confirm, restore original

The +1°C delta is below the heat pump's 5°C hysteresis so the compressor
will not fire, but the change is visible in the Viessmann app for visual
confirmation before trusting the WW boost logic in production.

Usage: ./ems --config /etc/ems/ems-config.yaml --test-viessmann

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:59:57 +02:00
25 changed files with 2752 additions and 325 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

@@ -10,11 +10,13 @@ prometheus:
battery_soc: "ess_stateOfCharge_value" # 0-100%
pv_production: "photovoltaic_production_current_value" # watts
battery_power: "ess_power_value" # watts
compressor_power: "heating_compressors_0_power_value" # watts
compressor_power: "heating_compressors_0_sensors_power_value * heating_compressors_0_power_value * 10" # actual watts: sensors(%) × rated_kW(10) × 10
ambient_temp: "heating_sensors_temperature_outside_value" # °C
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
@@ -78,14 +82,18 @@ strategic:
# Consumer behavior — idle detection thresholds
consumers:
compressor_idle_w: 50 # heat pump compressor below this = idle (W)
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:
heating_start_month: 10 # October
heating_end_month: 4 # April
heating_min_ambient_c: 15 # above 15°C outdoor temp: no SG-Ready even within heating months
# PV forecast — forecast.solar (free, no API key required)
forecast:
@@ -104,9 +112,42 @@ 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: "/var/lib/ems/monitor-only" # presence of this file = monitor-only mode active
ww_boost_disable_file: "/var/lib/ems/ww-boost-off" # presence of this file = WW boost disabled
trip_goal_file: "/var/lib/ems/trip-goal.json" # persisted active trip goal
session_log_file: "/var/lib/ems/sessions.jsonl" # JSONL log of completed charge sessions
# Battery physical properties (for EOD soft-stop calculation)
battery:
capacity_kwh: 8.0 # usable battery capacity (kWh)
# Proactive car charging strategy (forecast-driven, no export threshold required)
car_charging:
min_soc: 25 # start car charging only if battery SOC ≥ this (%)
soc_floor: 5 # emergency brake floor — stop if SOC drops below this (%)
soc_high_bypass_pct: 90 # activate without forecast check when SOC ≥ this % (battery full → always charge)
eod_soc_target: 90 # desired battery SOC at end of day / sunset (%)
eod_time: "16:00" # start EOD soft-stop checks after this time
no_car_retry_min: 30 # minutes before retrying after no-car detection
pv_threshold_a_w: 1000 # min PV production to start WallboxA (W) — 50% of 2kW rated
pv_threshold_b_w: 2000 # min PV production to start WallboxB (W) — 50% of 4kW rated
grid_delta_thresh_w: 2000 # min grid power shift after WallboxB activation = car detected (W)
# Known car profiles for trip mode
cars:
mini:
name: "Mini Cooper SE"
battery_kwh: 50.0
bmw:
name: "BMW ix2 eDrive20"
battery_kwh: 63.0

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,17 +11,50 @@ 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"`
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.
type CarProfile struct {
Name string `yaml:"name"`
BatteryKWh float64 `yaml:"battery_kwh"`
}
// PrometheusConfig holds Prometheus connection settings.
@@ -102,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.
@@ -113,17 +150,20 @@ 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"`
}
// SeasonConfig defines the heating season by month range.
// SeasonConfig defines the heating season by month range and ambient temperature.
type SeasonConfig struct {
HeatingStartMonth int `yaml:"heating_start_month"`
HeatingEndMonth int `yaml:"heating_end_month"`
HeatingStartMonth int `yaml:"heating_start_month"`
HeatingEndMonth int `yaml:"heating_end_month"`
HeatingMinAmbientC float64 `yaml:"heating_min_ambient_c"` // above this: non-heating regardless of month (0 = disabled)
}
// ForecastConfig holds forecast.solar API parameters.
@@ -152,12 +192,19 @@ 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"`
OverrideTimeout string `yaml:"override_timeout"`
OverrideMaxImportW float64 `yaml:"override_max_import_w"` // cancel override if grid import exceeds this (0 = disabled)
MonitorOnlyFile string `yaml:"monitor_only_file"` // flag file path: presence = monitor-only mode active
WWBoostDisableFile string `yaml:"ww_boost_disable_file"` // flag file path: presence = WW boost disabled
TripGoalFile string `yaml:"trip_goal_file"` // persisted active trip goal
SessionLogFile string `yaml:"session_log_file"` // JSONL log of completed charge sessions
HTTPUsername string `yaml:"http_username"` // login username (empty = no auth)
HTTPPassword string `yaml:"http_password"` // login password
}
func (e *EMSConfig) PollIntervalParsed() time.Duration {
@@ -183,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)
@@ -213,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,24 +103,27 @@ 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
gridW := state.GridPowerW // positive = import, negative = export
heatingPeriod := e.isHeatingPeriod(now)
heatingPeriod := e.isHeatingPeriod(now, state.AmbientTempC)
wwWindow := e.isWWWindow(now)
allowed := e.allowedConsumers(soc)
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,24 +786,72 @@ 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"),
)
}
// isHeatingPeriod returns true if the current month is within the heating season.
func (e *Engine) isHeatingPeriod(now time.Time) bool {
// 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.
func (e *Engine) isHeatingPeriod(now time.Time, ambientC float64) bool {
month := int(now.Month())
start := e.cfg.Season.HeatingStartMonth
end := e.cfg.Season.HeatingEndMonth
// Handles wrap-around: e.g. October(10) to April(4)
var inMonth bool
if start > end {
return month >= start || month <= end
inMonth = month >= start || month <= end
} else {
inMonth = month >= start && month <= end
}
return month >= start && month <= end
if !inMonth {
return false
}
// Temperature override: warm day within heating months → not a heating day
threshold := e.cfg.Season.HeatingMinAmbientC
if threshold > 0 && ambientC >= threshold {
e.logger.Debug("heating period suppressed by ambient temperature",
"ambient_c", ambientC,
"threshold_c", threshold,
)
return false
}
return true
}
// minRuntime returns the minimum runtime for a consumer.
@@ -528,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),
}}
}
@@ -631,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 {
@@ -639,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).
@@ -668,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")
@@ -208,6 +208,7 @@ func TestShutdownReverseOrder(t *testing.T) {
func TestHeatingPeriodDetection(t *testing.T) {
eng := NewEngine(testConfig(), testLogger())
cold := 5.0 // well below any threshold — pure calendar test
tests := []struct {
month time.Month
@@ -230,29 +231,62 @@ func TestHeatingPeriodDetection(t *testing.T) {
for _, tt := range tests {
t.Run(tt.month.String(), func(t *testing.T) {
date := time.Date(2025, tt.month, 15, 12, 0, 0, 0, time.UTC)
if got := eng.isHeatingPeriod(date); got != tt.expected {
if got := eng.isHeatingPeriod(date, cold); got != tt.expected {
t.Errorf("month %s: got %v, want %v", tt.month, got, tt.expected)
}
})
}
}
func TestHeatingPeriodAmbientSuppression(t *testing.T) {
cfg := testConfig()
cfg.Season.HeatingMinAmbientC = 15.0
eng := NewEngine(cfg, testLogger())
// Winter month, but warm day — should be suppressed
warmWinterDay := time.Date(2025, time.January, 15, 12, 0, 0, 0, time.UTC)
if eng.isHeatingPeriod(warmWinterDay, 18.0) {
t.Error("expected heating period suppressed when ambient (18°C) >= threshold (15°C)")
}
// Winter month, cold day — should be active
if !eng.isHeatingPeriod(warmWinterDay, 8.0) {
t.Error("expected heating period active when ambient (8°C) < threshold (15°C)")
}
// Summer month, cold day — still not heating (month gate takes precedence)
summerDay := time.Date(2025, time.July, 15, 12, 0, 0, 0, time.UTC)
if eng.isHeatingPeriod(summerDay, 5.0) {
t.Error("expected heating period inactive in summer regardless of temperature")
}
}
func TestWallboxMutualExclusion(t *testing.T) {
cfg := testConfig()
cfg.Hysteresis.ExportOnDuration = "0s"
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 {
@@ -271,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)")
@@ -281,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)
}
@@ -302,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 {
@@ -334,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)
}
@@ -342,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
@@ -359,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 {

61
internal/monitor/mode.go Normal file
View File

@@ -0,0 +1,61 @@
// Package monitor provides a persistent monitor-only mode toggle.
// When active, the EMS continues polling and showing the status page
// but suppresses all actuator calls (Shelly switches, Viessmann API).
// State is stored as a flag file: presence = active, absence = normal.
package monitor
import (
"os"
"sync"
)
// Mode is a thread-safe, file-backed monitor-only flag.
type Mode struct {
mu sync.RWMutex
active bool
filePath string
}
// New creates a Mode and loads the current state from the flag file.
// If filePath is empty, the mode is in-memory only (not persistent).
func New(filePath string) *Mode {
m := &Mode{filePath: filePath}
if filePath != "" {
_, err := os.Stat(filePath)
m.active = err == nil // file exists → monitor-only
}
return m
}
// IsActive reports whether monitor-only mode is currently enabled.
func (m *Mode) IsActive() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.active
}
// Set enables or disables monitor-only mode and persists the change to disk.
func (m *Mode) Set(active bool) error {
m.mu.Lock()
defer m.mu.Unlock()
m.active = active
if m.filePath == "" {
return nil // in-memory only
}
if active {
f, err := os.OpenFile(m.filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}
return f.Close()
}
err := os.Remove(m.filePath)
if os.IsNotExist(err) {
return nil
}
return err
}

View File

@@ -11,6 +11,8 @@ import (
"github.com/tb/ems/internal/config"
"github.com/tb/ems/internal/engine"
"github.com/tb/ems/internal/forecast"
"github.com/tb/ems/internal/monitor"
"github.com/tb/ems/internal/trip"
)
// consumerMeta holds static display info for each consumer.
@@ -21,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.
@@ -39,6 +41,13 @@ type consumerRecord struct {
livePowerW float64 // from Shelly PM; 0 for non-PM devices
}
// CarOption is a selectable car for the trip mode form.
type CarOption struct {
Key string
Name string
BatteryKWh float64
}
// Store holds the latest EMS snapshot and is safe for concurrent use.
type Store struct {
mu sync.RWMutex
@@ -48,21 +57,35 @@ 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) *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{}
}
return &Store{
dryRun: dryRun,
wwConfigured: wwConfigured,
strategic: strategic,
consumers: consumers,
dryRun: dryRun,
wwConfigured: wwConfigured,
strategic: strategic,
carCharging: carCharging,
consumers: consumers,
monitorMode: mode,
wwBoostDisabled: wwBoostDisabled,
tripMgr: tm,
carOptions: cars,
csrfToken: csrfToken,
hasAuth: hasAuth,
}
}
@@ -109,17 +132,22 @@ func (s *Store) Update(state collector.SystemState, actions []engine.Action, ove
// --- Template data types ---
type consumerView struct {
Icon string
Label string
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)
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 {
@@ -136,29 +164,43 @@ 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
WallboxLabel string // "Wallbox A" or "Wallbox B"
CurrentSOC float64
EnergyKWh float64 // energy needed
DeadlineStr string // e.g. "morgen 07:00"
StartsAtStr string // e.g. "heute 22:30" / "jetzt"
DurationStr string // estimated charge time, e.g. "15h 45min"
ChargingStarted bool
ChargingSince string // e.g. "seit 2h 10min"
IsTight bool // start time already passed, deadline at risk
}
type pageData struct {
LastUpdate time.Time
ErrMsg string
DryRun bool
MonitorOnly bool
WWBoostDisabled bool
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
@@ -226,24 +268,41 @@ func (s *Store) Handler() http.HandlerFunc {
return
}
monitorOnly := s.monitorMode != nil && s.monitorMode.IsActive()
wwBoostDisabled := s.wwBoostDisabled != nil && s.wwBoostDisabled.IsActive()
now := time.Now()
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,
DryRun: s.dryRun,
MonitorOnly: monitorOnly,
WWBoostDisabled: wwBoostDisabled,
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{
@@ -261,40 +320,46 @@ 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
now := time.Now()
wallboxActive := s.consumers[engine.ConsumerWallboxA].active ||
s.consumers[engine.ConsumerWallboxB].active
if !ws.IsZero() && now.Before(ws) && !wallboxActive {
plugBy := ws.Add(-30 * time.Minute)
if plugBy.Before(now) {
plugBy = ws // less than 30 min to window — just show window start
}
data.ChargingAdvice = chargingAdviceView{
Show: true,
PlugInBy: plugBy.Format("15:04"),
WindowStart: ws.Format("15:04"),
WindowEnd: we.Format("15:04"),
}
}
}
for _, c := range consumerOrder {
prioritySymbols := []string{"①", "②", "③", "④"}
for i, c := range consumerOrder {
rec := s.consumers[c]
meta := consumerMeta[c]
prio := ""
if i < len(prioritySymbols) {
prio = prioritySymbols[i]
}
cv := consumerView{
Icon: meta.Icon,
Label: meta.Label,
ConsumerKey: c.String(),
Active: rec.active,
Since: rec.since,
Reason: rec.reason,
CanOverride: c != engine.ConsumerWW,
ManualOverride: rec.manualOverride,
OverrideUntil: rec.overrideUntil,
LivePowerW: rec.livePowerW,
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,
}
// 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
@@ -303,6 +368,12 @@ func (s *Store) Handler() http.HandlerFunc {
}
s.mu.RUnlock()
// Trip mode view (read outside store lock — tripMgr has its own lock)
data.CarOptions = s.carOptions
if s.tripMgr != nil {
data.Trip = buildTripView(s.tripMgr, now)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
http.Error(w, "template error", http.StatusInternalServerError)
@@ -310,6 +381,109 @@ func (s *Store) Handler() http.HandlerFunc {
}
}
// buildReadinessHint returns a short string explaining why a consumer is currently inactive.
// Returns empty string when the consumer is not forecast/threshold driven.
func (s *Store) buildReadinessHint(c engine.Consumer, soc, pvW, forecastKWh float64) string {
cc := s.carCharging
switch c {
case engine.ConsumerWallboxA, engine.ConsumerWallboxB:
if cc.PVThresholdAW == 0 && cc.PVThresholdBW == 0 {
return "" // proactive not configured
}
midKWh := s.strategic.ForecastMidKWh
if forecastKWh == 0 {
return "Keine Prognose verfügbar"
}
if forecastKWh < midKWh {
return fmt.Sprintf("Prognose zu gering (%.0f kWh)", forecastKWh)
}
if float64(cc.MinSOC) > 0 && soc < float64(cc.MinSOC) {
return fmt.Sprintf("SOC zu niedrig (min. %d%%)", cc.MinSOC)
}
threshold := cc.PVThresholdAW
if c == engine.ConsumerWallboxB {
threshold = cc.PVThresholdBW
}
if pvW < threshold {
return fmt.Sprintf("Warte auf PV ≥ %.0f W (aktuell %.0f W)", threshold, pvW)
}
return "Bereit — aktiviert bei nächstem Zyklus"
case engine.ConsumerWW:
if s.strategic.ForecastMidKWh > 0 && forecastKWh < s.strategic.ForecastMidKWh {
return fmt.Sprintf("Prognose zu gering (%.0f kWh)", forecastKWh)
}
}
return ""
}
// buildTripView constructs the trip card data for the current cycle.
func buildTripView(tm *trip.Manager, now time.Time) tripView {
goal := tm.ActiveGoal()
if goal == nil {
return tripView{}
}
// Use rated wallbox power as fallback (learned rate improves over sessions)
// The actual rated power isn't available here, so use battery capacity / 10 as rough estimate.
// The real rate is computed in runCycle; here we just format what's stored.
rateKW := goal.BatteryKWh / 20.0 // conservative placeholder for display only
startTime := goal.StartTime(rateKW)
duration := goal.ChargeDuration(rateKW)
wbLabel := "Wallbox A"
if goal.Wallbox == "wallbox_b" {
wbLabel = "Wallbox B"
}
tv := tripView{
Active: true,
CarName: goal.CarName,
WallboxLabel: wbLabel,
CurrentSOC: goal.CurrentSOC,
EnergyKWh: goal.EnergyNeededKWh(),
DeadlineStr: formatDay(goal.Deadline, now),
DurationStr: formatDur(duration),
IsTight: goal.IsTight(now, rateKW),
}
if !goal.ChargingStartedAt.IsZero() {
tv.ChargingStarted = true
tv.ChargingSince = formatDur(now.Sub(goal.ChargingStartedAt))
} else if goal.ShouldStartNow(now, rateKW) {
tv.StartsAtStr = "jetzt"
} else {
tv.StartsAtStr = formatDay(startTime, now)
}
return tv
}
func formatDay(t, now time.Time) string {
today := now.Format("2006-01-02")
tomorrow := now.AddDate(0, 0, 1).Format("2006-01-02")
switch t.Format("2006-01-02") {
case today:
return "heute " + t.Format("15:04")
case tomorrow:
return "morgen " + t.Format("15:04")
default:
return t.Format("02.01. 15:04")
}
}
func formatDur(d time.Duration) string {
d = d.Round(time.Minute)
h := int(d.Hours())
m := int(d.Minutes()) % 60
if h == 0 {
return fmt.Sprintf("%d min", m)
}
if m == 0 {
return fmt.Sprintf("%d h", h)
}
return fmt.Sprintf("%d h %d min", h, m)
}
func abs(v float64) float64 {
if v < 0 {
return -v
@@ -359,8 +533,65 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
font-size: 0.85rem;
margin-bottom: 1rem;
}
.banner.warn { background: #fef9c3; color: #854d0e; border: 1px solid #fde68a; }
.banner.error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
.banner.warn { background: #fef9c3; color: #854d0e; border: 1px solid #fde68a; }
.banner.error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
.banner.monitor {
background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa;
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
}
.resume-btn {
background: #fff; border: 1px solid #9a3412; color: #9a3412;
border-radius: 7px; padding: 0.25rem 0.7rem; font-size: 0.8rem;
font-weight: 700; cursor: pointer; white-space: nowrap; flex-shrink: 0;
}
.resume-btn:active { opacity: 0.7; }
.monitor-toggle-btn {
background: none; border: 1px solid #d1d5db; color: #9ca3af;
border-radius: 8px; padding: 0.25rem 0.75rem; font-size: 0.72rem;
cursor: pointer; flex-shrink: 0;
}
.monitor-toggle-btn:hover { border-color: #9ca3af; color: #6b7280; }
/* Trip mode card */
.trip-card {
background: #f0fdf4; border: 1px solid #86efac;
border-radius: 14px; padding: 0.85rem 1rem; margin-bottom: 1.25rem;
}
.trip-card.tight { background: #fff7ed; border-color: #fed7aa; }
.trip-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 0.5rem;
}
.trip-title { font-size: 0.78rem; font-weight: 700; color: #15803d; text-transform: uppercase; letter-spacing: 0.05em; }
.trip-card.tight .trip-title { color: #9a3412; }
.trip-cancel { background: none; border: 1px solid #d1d5db; color: #9ca3af; border-radius: 7px; padding: 0.2rem 0.55rem; font-size: 0.75rem; cursor: pointer; }
.trip-cancel:hover { border-color: #9ca3af; color: #6b7280; }
.trip-car { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.2rem; }
.trip-detail { font-size: 0.78rem; color: #6b7280; }
.trip-timing { font-size: 0.85rem; font-weight: 600; color: #15803d; margin-top: 0.35rem; }
.trip-card.tight .trip-timing { color: #9a3412; }
.trip-deadline { font-size: 0.78rem; color: #6b7280; margin-top: 0.1rem; }
/* Trip form */
.trip-form-card {
background: #fff; border-radius: 14px; padding: 0.85rem 1rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.07); margin-top: 1.25rem;
}
.trip-form { display: flex; flex-direction: column; gap: 0.6rem; margin-top: 0.5rem; }
.trip-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.trip-select, .trip-number, .trip-datetime {
border: 1px solid #d1d5db; border-radius: 8px; padding: 0.35rem 0.5rem;
font-size: 0.82rem; background: #f9fafb; color: #374151;
}
.trip-select { flex: 1; min-width: 0; }
.trip-number { width: 4.5rem; }
.trip-datetime { flex: 1; min-width: 0; }
.trip-label { font-size: 0.75rem; color: #6b7280; white-space: nowrap; }
.trip-submit {
background: #16a34a; color: #fff; border: none; border-radius: 8px;
padding: 0.45rem 1.2rem; font-size: 0.85rem; font-weight: 700; cursor: pointer; align-self: flex-end;
}
.trip-submit:active { opacity: 0.8; }
.grid {
display: grid;
@@ -442,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;
@@ -482,6 +686,14 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
margin-right: 0.25rem;
}
/* Priority badge */
.prio-badge {
font-size: 0.75rem;
color: #9ca3af;
margin-right: 0.35rem;
font-variant-numeric: tabular-nums;
}
/* Consumer list */
.section-title {
font-size: 0.7rem;
@@ -527,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;
@@ -564,12 +784,31 @@ 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 .DryRun}}
{{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>
{{else if .DryRun}}
<div class="banner warn">⚠️ Testmodus — keine echten Schaltvorgänge</div>
{{end}}
{{if .ErrMsg}}
@@ -609,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 -->
@@ -641,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>
@@ -648,43 +889,38 @@ 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</div>
<div class="section-title">Verbraucher &mdash; Priorität ↓</div>
{{range .Consumers}}
<div class="consumer{{if .Unconfigured}} unconfigured{{end}}">
<div class="indicator {{if .ManualOverride}}override{{else if .Active}}on{{else}}off{{end}}"></div>
<div class="consumer-icon">{{.Icon}}</div>
<div class="consumer-body">
<div class="consumer-name">{{.Label}}</div>
<div class="consumer-name">
<span class="prio-badge">{{.Priority}}</span>{{.Label}}
</div>
<div class="consumer-detail{{if and .Active (not .ManualOverride)}} active{{end}}{{if .ManualOverride}} override{{end}}">
{{if .Unconfigured}}
nicht konfiguriert
{{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">
@@ -702,13 +938,73 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
{{end}}
</form>
{{end}}
{{if .IsWW}}
<div style="display:flex;flex-direction:column;gap:0.3rem;flex-shrink:0">
{{if not .Unconfigured}}
<form method="post" action="/ww/reset">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="override-btn turn-off" style="width:100%" title="WW-Solltemperatur auf Basiswert zurücksetzen, Boost für heute sperren">🌡️ Zurücksetzen</button>
</form>
{{end}}
<form method="post" action="/ww/boost">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
{{if .WWBoostDisabled}}
<button type="submit" class="override-btn turn-on" style="width:100%" title="WW Boost durch PV-Überschuss wieder erlauben">✅ Boost ein</button>
{{else}}
<button type="submit" class="override-btn turn-off" style="width:100%" title="WW Boost dauerhaft deaktivieren (z.B. im Sommer)">⛔ Boost aus</button>
{{end}}
</form>
</div>
{{end}}
</div>
{{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}}
{{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>
<div class="trip-car">{{.Trip.CarName}} · {{.Trip.WallboxLabel}}</div>
<div class="trip-detail">Ladebedarf: {{printf "%.1f" .Trip.EnergyKWh}} kWh &nbsp;({{printf "%.0f" .Trip.CurrentSOC}}% → 100%) · ca. {{.Trip.DurationStr}}</div>
{{if .Trip.ChargingStarted}}
<div class="trip-timing">⚡ Lädt seit {{.Trip.ChargingSince}}</div>
{{else if .Trip.IsTight}}
<div class="trip-timing">⚠ Zeitfenster knapp — sofort einstecken!</div>
{{else}}
<div class="trip-timing">Laden startet: {{.Trip.StartsAtStr}}</div>
{{end}}
<div class="trip-deadline">Bereit bis: {{.Trip.DeadlineStr}}</div>
</div>
{{else if .CarOptions}}
<div class="trip-form-card">
<div class="section-title">🚗 Fahrtziel planen</div>
<form method="post" action="/trip" class="trip-form">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="trip-row">
<select name="wallbox" class="trip-select">
<option value="wallbox_a">Wallbox A</option>
<option value="wallbox_b">Wallbox B</option>
</select>
<select name="car" class="trip-select">
{{range .CarOptions}}
<option value="{{.Key}}">{{.Name}} ({{.BatteryKWh}} kWh)</option>
{{end}}
</select>
</div>
<div class="trip-row">
<span class="trip-label">Ladestand jetzt</span>
<input type="number" name="current_soc" min="1" max="99" value="50" class="trip-number"> %
</div>
<div class="trip-row">
<span class="trip-label">Bereit bis</span>
<input type="datetime-local" name="deadline" class="trip-datetime" required>
</div>
<button type="submit" class="trip-submit">Planen</button>
</form>
</div>
{{end}}

297
internal/trip/trip.go Normal file
View File

@@ -0,0 +1,297 @@
// Package trip manages trip-mode charging goals and session learning.
// A Goal records the user's intent ("car ready by HH:MM") and is persisted
// across restarts. Completed sessions are appended to a JSONL log so the
// charge rate estimate improves over time.
package trip
import (
"bufio"
"encoding/json"
"math"
"os"
"path/filepath"
"sync"
"time"
)
const (
sessionHistory = 10 // sessions used for rate averaging
minSessionsForLearning = 3 // below this: fall back to rated power
chargingBuffer = 15 * time.Minute
minSessionKWh = 0.1 // ignore sessions shorter than this
minSessionDuration = 5 * time.Minute
)
// Goal is a user-entered trip charging goal, persisted to disk.
type Goal struct {
Wallbox string `json:"wallbox"` // "wallbox_a" or "wallbox_b"
CarName string `json:"car_name"` // display label
BatteryKWh float64 `json:"battery_kwh"`
CurrentSOC float64 `json:"current_soc"` // % at time of entry
Deadline time.Time `json:"deadline"` // car must be ready by this time
CreatedAt time.Time `json:"created_at"`
ChargingStartedAt time.Time `json:"charging_started_at,omitempty"` // set when trip mode activates the wallbox
}
// EnergyNeededKWh returns the energy required to charge from CurrentSOC to 100%.
func (g *Goal) EnergyNeededKWh() float64 {
return (100.0 - g.CurrentSOC) / 100.0 * g.BatteryKWh
}
// ChargeDuration estimates the time required at the given charge rate.
func (g *Goal) ChargeDuration(rateKW float64) time.Duration {
hours := g.EnergyNeededKWh() / rateKW
return time.Duration(hours * float64(time.Hour))
}
// StartTime returns when charging must begin to meet the deadline (including buffer).
func (g *Goal) StartTime(rateKW float64) time.Time {
return g.Deadline.Add(-(g.ChargeDuration(rateKW) + chargingBuffer))
}
// ShouldStartNow returns true if charging should begin immediately.
func (g *Goal) ShouldStartNow(now time.Time, rateKW float64) bool {
return !now.Before(g.StartTime(rateKW))
}
// IsTight returns true if the start time has already passed (deadline at risk).
func (g *Goal) IsTight(now time.Time, rateKW float64) bool {
return g.StartTime(rateKW).Before(now) && g.ChargingStartedAt.IsZero()
}
// Session records a completed charging session for rate learning.
type Session struct {
Timestamp time.Time `json:"ts"`
Wallbox string `json:"wallbox"`
DurationMin float64 `json:"duration_min"`
KWhDelivered float64 `json:"kwh_delivered"`
AvgKW float64 `json:"avg_kw"`
}
// Manager coordinates trip goals, session energy accumulation, and rate learning.
// All methods are safe for concurrent use (HTTP handler vs control loop).
type Manager struct {
mu sync.Mutex
goal *Goal
goalFile string
sessionFile string
// per-wallbox energy accumulation for the in-progress charging session
accEnergy map[string]float64
accStart map[string]time.Time
// previous active state per wallbox — used to detect active→inactive transitions
prevActive map[string]bool
}
// New creates a Manager and loads any persisted goal from disk.
func New(goalFile, sessionFile string) *Manager {
m := &Manager{
goalFile: goalFile,
sessionFile: sessionFile,
accEnergy: make(map[string]float64),
accStart: make(map[string]time.Time),
prevActive: make(map[string]bool),
}
m.loadGoal()
return m
}
func (m *Manager) loadGoal() {
if m.goalFile == "" {
return
}
data, err := os.ReadFile(m.goalFile)
if err != nil {
return // no file = no active goal
}
var g Goal
if json.Unmarshal(data, &g) == nil {
m.goal = &g
}
}
// ActiveGoal returns a snapshot of the current goal, or nil if none is set.
func (m *Manager) ActiveGoal() *Goal {
m.mu.Lock()
defer m.mu.Unlock()
if m.goal == nil {
return nil
}
g := *m.goal
return &g
}
// SetGoal persists and activates a new trip goal.
func (m *Manager) SetGoal(g Goal) error {
m.mu.Lock()
defer m.mu.Unlock()
m.goal = &g
return m.saveGoalLocked()
}
// ClearGoal removes the active trip goal from memory and disk.
func (m *Manager) ClearGoal() error {
m.mu.Lock()
defer m.mu.Unlock()
m.goal = nil
if m.goalFile == "" {
return nil
}
err := os.Remove(m.goalFile)
if os.IsNotExist(err) {
return nil
}
return err
}
func (m *Manager) saveGoalLocked() error {
if m.goalFile == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(m.goalFile), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(m.goal, "", " ")
if err != nil {
return err
}
return os.WriteFile(m.goalFile, data, 0644)
}
// MarkChargingStarted records when trip-mode charging began in the persisted goal.
func (m *Manager) MarkChargingStarted(now time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.goal == nil || !m.goal.ChargingStartedAt.IsZero() {
return nil // already marked, or no goal
}
m.goal.ChargingStartedAt = now
return m.saveGoalLocked()
}
// Tick must be called once per cycle. It:
// 1. Accumulates energy from active PM readings into the running session total.
// 2. Detects active→inactive wallbox transitions and finalises those sessions.
//
// Returns the list of wallboxes whose sessions just completed this cycle.
// The caller uses this to auto-clear a trip goal when its wallbox finishes.
func (m *Manager) Tick(
activeStates map[string]bool,
powerReadingsW map[string]float64,
intervalMin float64,
) []string {
completed := m.tickLocked(activeStates, powerReadingsW, intervalMin)
for _, wb := range completed {
m.finalizeSession(wb)
}
return completed
}
func (m *Manager) tickLocked(activeStates map[string]bool, powerW map[string]float64, intervalMin float64) []string {
m.mu.Lock()
defer m.mu.Unlock()
for wb, active := range activeStates {
if active {
if _, ok := m.accStart[wb]; !ok {
m.accStart[wb] = time.Now()
m.accEnergy[wb] = 0
}
if pw := powerW[wb]; pw > 0 {
m.accEnergy[wb] += pw / 1000.0 * (intervalMin / 60.0)
}
}
}
var completed []string
for wb, active := range activeStates {
if m.prevActive[wb] && !active {
completed = append(completed, wb)
}
m.prevActive[wb] = active
}
return completed
}
// finalizeSession writes a completed session record to the log file.
// Called without m.mu held.
func (m *Manager) finalizeSession(wallbox string) {
m.mu.Lock()
start, ok := m.accStart[wallbox]
kwh := m.accEnergy[wallbox]
delete(m.accStart, wallbox)
delete(m.accEnergy, wallbox)
m.mu.Unlock()
if !ok {
return
}
duration := time.Since(start)
if kwh < minSessionKWh || duration < minSessionDuration {
return // too short / too little energy — ignore
}
s := Session{
Timestamp: time.Now(),
Wallbox: wallbox,
DurationMin: math.Round(duration.Minutes()*10) / 10,
KWhDelivered: math.Round(kwh*100) / 100,
AvgKW: math.Round(kwh/duration.Hours()*100) / 100,
}
_ = m.appendSession(s)
}
func (m *Manager) appendSession(s Session) error {
if m.sessionFile == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(m.sessionFile), 0755); err != nil {
return err
}
f, err := os.OpenFile(m.sessionFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
return json.NewEncoder(f).Encode(s)
}
// LearnedRateKW returns the average charging rate (kW) from recent sessions
// for the given wallbox. Falls back to fallbackKW if fewer than
// minSessionsForLearning exist.
func (m *Manager) LearnedRateKW(wallbox string, fallbackKW float64) float64 {
sessions := m.recentSessions(wallbox, sessionHistory)
if len(sessions) < minSessionsForLearning {
return fallbackKW
}
var total float64
for _, s := range sessions {
total += s.AvgKW
}
return total / float64(len(sessions))
}
func (m *Manager) recentSessions(wallbox string, n int) []Session {
if m.sessionFile == "" {
return nil
}
f, err := os.Open(m.sessionFile)
if err != nil {
return nil
}
defer f.Close()
var matched []Session
sc := bufio.NewScanner(f)
for sc.Scan() {
var s Session
if json.Unmarshal(sc.Bytes(), &s) == nil && s.Wallbox == wallbox {
matched = append(matched, s)
}
}
if len(matched) <= n {
return matched
}
return matched[len(matched)-n:]
}

View File

@@ -17,7 +17,7 @@ import (
const (
tokenEndpoint = "https://iam.viessmann-climatesolutions.com/idp/v3/token"
apiBase = "https://api.viessmann.com/iot/v2"
apiBase = "https://api.viessmann-climatesolutions.com/iot/v2"
)
// tokenFile mirrors the JSON structure stored on disk.
@@ -137,6 +137,52 @@ func (c *Client) featureURL(feature string) string {
)
}
// dhwFeatureResponse is the minimal structure returned by GET on the DHW temperature feature.
type dhwFeatureResponse struct {
Data struct {
Properties struct {
Value struct {
Value float64 `json:"value"`
} `json:"value"`
} `json:"properties"`
} `json:"data"`
}
// GetDHWTemperature reads the current domestic hot water target temperature from the Viessmann API.
func (c *Client) GetDHWTemperature(ctx context.Context) (float64, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureToken(ctx); err != nil {
return 0, fmt.Errorf("ensuring token: %w", err)
}
featureURL := c.featureURL("heating.dhw.temperature.main")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, featureURL, nil)
if err != nil {
return 0, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token.AccessToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, fmt.Errorf("API call: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("Viessmann API returned %d", resp.StatusCode)
}
var result dhwFeatureResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return 0, fmt.Errorf("decoding response: %w", err)
}
return result.Data.Properties.Value.Value, nil
}
// SetDHWTemperature sets the domestic hot water target temperature via the Viessmann API.
func (c *Client) SetDHWTemperature(ctx context.Context, tempC float64) error {
c.mu.Lock()

520
main.go
View File

@@ -9,24 +9,30 @@ import (
"os"
"os/signal"
"path/filepath"
"sort"
"strconv"
"syscall"
"time"
"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"
"github.com/tb/ems/internal/forecast"
"github.com/tb/ems/internal/metrics"
"github.com/tb/ems/internal/monitor"
"github.com/tb/ems/internal/status"
"github.com/tb/ems/internal/trip"
"github.com/tb/ems/internal/viessmann"
)
func main() {
configPath := flag.String("config", "configs/ems-config.yaml", "Path to config file")
dryRun := flag.Bool("dry-run", false, "Run without executing actions (log only)")
testViessmann := flag.Bool("test-viessmann", false, "Test Viessmann API: read setpoint, round-trip write, +1°C delta, then restore")
flag.Parse()
// Load configuration
@@ -50,6 +56,27 @@ func main() {
Level: logLevel,
}))
// Viessmann client — needed for both normal operation and --test-viessmann
var vc *viessmann.Client
if cfg.Viessmann.InstallationID != "" && cfg.Viessmann.ClientID != "" {
var err error
vc, err = viessmann.NewClient(cfg.Viessmann, logger)
if err != nil {
logger.Warn("Viessmann client init failed, WW boost disabled", "error", err)
} else {
logger.Info("Viessmann client initialized")
}
}
// --test-viessmann: run the API test and exit — do not start the control loop
if *testViessmann {
if vc == nil {
fmt.Fprintln(os.Stderr, "ERROR: Viessmann credentials not configured or token failed to load")
os.Exit(1)
}
os.Exit(runViessmannTest(vc))
}
logger.Info("starting EMS",
"config", *configPath,
"dry_run", *dryRun,
@@ -62,18 +89,6 @@ func main() {
eng := engine.NewEngine(cfg, logger)
fc := forecast.NewClient(cfg.Forecast, cfg.Strategic, logger)
// Viessmann client — optional, only if credentials are configured
var vc *viessmann.Client
if cfg.Viessmann.InstallationID != "" && cfg.Viessmann.ClientID != "" {
var err error
vc, err = viessmann.NewClient(cfg.Viessmann, logger)
if err != nil {
logger.Warn("Viessmann client init failed, WW boost disabled", "error", err)
} else {
logger.Info("Viessmann client initialized")
}
}
act := actuator.NewActuator(cfg, vc, logger)
// Startup: attempt state recovery from Shelly read-back
@@ -93,31 +108,101 @@ func main() {
reg := prometheus.NewRegistry()
m := metrics.NewMetrics(reg)
// Monitor-only mode (persistent flag file)
monitorMode := monitor.New(cfg.EMS.MonitorOnlyFile)
if monitorMode.IsActive() {
logger.Warn("starting in monitor-only mode — actuator calls suppressed",
"flag_file", cfg.EMS.MonitorOnlyFile,
)
}
// WW boost disable flag (persistent; presence of file = boost disabled)
wwBoostDisabled := monitor.New(cfg.EMS.WWBoostDisableFile)
if wwBoostDisabled.IsActive() {
logger.Info("WW boost disabled via flag file", "flag_file", cfg.EMS.WWBoostDisableFile)
}
// Trip mode manager (goal persistence + session learning)
tripMgr := trip.New(cfg.EMS.TripGoalFile, cfg.EMS.SessionLogFile)
if g := tripMgr.ActiveGoal(); g != nil {
logger.Info("trip goal loaded from disk",
"wallbox", g.Wallbox,
"car", g.CarName,
"deadline", g.Deadline.Format("02.01. 15:04"),
)
}
// Build sorted car option list for the status page form
carOptions := buildCarOptions(cfg.Cars)
// Auth manager — loads or generates persistent session and CSRF tokens.
// Token files live alongside other persistent state in /var/lib/ems/.
tokenDir := filepath.Dir(cfg.EMS.SessionLogFile)
authMgr := auth.New(cfg.EMS.HTTPUsername, cfg.EMS.HTTPPassword, tokenDir, logger)
if authMgr.Enabled() {
logger.Info("web UI authentication enabled", "username", cfg.EMS.HTTPUsername)
} else {
logger.Warn("web UI authentication disabled — set ems.http_username and ems.http_password to enable")
}
// Status store (shared between HTTP handler and control loop)
wwConfigured := cfg.Viessmann.InstallationID != ""
statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic)
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("/", 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())
@@ -133,12 +218,12 @@ func main() {
logger.Info("EMS control loop started")
// Run once immediately
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun)
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode, wwBoostDisabled, tripMgr)
for {
select {
case <-ticker.C:
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun)
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode, wwBoostDisabled, tripMgr)
case sig := <-sigCh:
logger.Info("received signal, shutting down", "signal", sig)
@@ -146,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
}
}
@@ -164,8 +252,12 @@ func runCycle(
stateFile string,
logger *slog.Logger,
dryRun bool,
monitorMode *monitor.Mode,
wwBoostDisabled *monitor.Mode,
tripMgr *trip.Manager,
) {
now := time.Now()
pollMin := cfg.EMS.PollIntervalParsed().Minutes()
// Step 1: Collect current state from Prometheus
state, err := coll.Collect(ctx)
@@ -179,11 +271,13 @@ func runCycle(
writeHeartbeat(stateFile, logger)
// Step 1b: Read Shelly states and sync to engine (detects manual overrides).
// Partial results are fine — unreachable devices are logged inside ReadAllStates.
if shellyStates, err := act.ReadAllStates(ctx); err != nil {
// Keep shellyStates accessible for trip mode energy accumulation.
var shellyStates map[engine.Consumer]engine.DeviceStatus
if states, err := act.ReadAllStates(ctx); err != nil {
logger.Warn("all Shelly devices unreachable, skipping override detection", "error", err)
} else {
eng.SyncHardwareState(shellyStates, now, cfg.EMS.OverrideTimeoutParsed())
shellyStates = states
eng.SyncHardwareState(shellyStates, now, cfg.EMS.OverrideTimeoutParsed(), monitorMode.IsActive())
store.SyncDeviceStates(shellyStates)
}
@@ -192,8 +286,8 @@ func runCycle(
m.BatterySOC.Set(state.BatterySOC)
m.PVProductionW.Set(state.PVProductionW)
// Track energy flow (rough estimation based on 2-min intervals)
intervalHours := 2.0 / 60.0
// Track energy flow (rough estimation based on poll interval)
intervalHours := pollMin / 60.0
if state.GridPowerW > 0 {
m.GridImportKWh.Add(state.GridPowerW / 1000.0 * intervalHours)
} else {
@@ -207,11 +301,75 @@ func runCycle(
}
// Step 3: Run decision engine
wwBoostC := computeWWBoost(fcResult, cfg)
wwBoostC := 0.0
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
consumerStates := eng.ConsumerStates()
wallboxActive := map[string]bool{
engine.ConsumerWallboxA.String(): consumerStates[engine.ConsumerWallboxA],
engine.ConsumerWallboxB.String(): consumerStates[engine.ConsumerWallboxB],
}
wallboxPowerW := map[string]float64{}
if shellyStates != nil {
if s, ok := shellyStates[engine.ConsumerWallboxA]; ok {
wallboxPowerW[engine.ConsumerWallboxA.String()] = s.PowerW
}
if s, ok := shellyStates[engine.ConsumerWallboxB]; ok {
wallboxPowerW[engine.ConsumerWallboxB.String()] = s.PowerW
}
}
completed := tripMgr.Tick(wallboxActive, wallboxPowerW, pollMin)
// If a trip goal's wallbox just went idle → car is full, clear the goal
if goal := tripMgr.ActiveGoal(); goal != nil {
for _, wb := range completed {
if wb == goal.Wallbox {
logger.Info("trip mode: charging complete, clearing goal",
"wallbox", wb, "car", goal.CarName)
_ = tripMgr.ClearGoal()
}
}
}
// If trip goal's start time has arrived and wallbox is not yet active, force it on
if goal := tripMgr.ActiveGoal(); goal != nil {
consumer := tripConsumer(goal.Wallbox)
ratedKW := tripRatedKW(cfg, consumer)
learnedKW := tripMgr.LearnedRateKW(goal.Wallbox, ratedKW)
if goal.ShouldStartNow(now, learnedKW) && !consumerStates[consumer] {
actions = append(actions, engine.Action{
Consumer: consumer,
TurnOn: true,
Reason: fmt.Sprintf("trip mode: %s, bereit bis %s",
goal.CarName, goal.Deadline.Format("15:04")),
})
overrideDur := time.Until(goal.Deadline) + time.Hour
eng.ApplyOverride(consumer, true, overrideDur)
_ = tripMgr.MarkChargingStarted(now)
logger.Info("trip mode: activating wallbox",
"wallbox", goal.Wallbox,
"car", goal.CarName,
"learned_kw", learnedKW,
"deadline", goal.Deadline.Format("15:04"),
)
}
}
// Update consumer state metrics
m.UpdateConsumerStates(eng.ConsumerStates())
@@ -230,9 +388,13 @@ func runCycle(
// Step 4: Execute actions
m.RecordActions(actions)
if dryRun {
if dryRun || monitorMode.IsActive() {
mode := "DRY RUN"
if monitorMode.IsActive() {
mode = "MONITOR-ONLY"
}
for _, a := range actions {
logger.Info("[DRY RUN] would execute",
logger.Info(fmt.Sprintf("[%s] would execute", mode),
"consumer", a.Consumer,
"turn_on", a.TurnOn,
"reason", a.Reason,
@@ -241,8 +403,192 @@ 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])
}
}
}
// tripConsumer maps a wallbox config key to an engine Consumer.
func tripConsumer(wallbox string) engine.Consumer {
if wallbox == "wallbox_b" {
return engine.ConsumerWallboxB
}
return engine.ConsumerWallboxA
}
// tripRatedKW returns the configured rated power for the given consumer in kW.
func tripRatedKW(cfg *config.Config, c engine.Consumer) float64 {
if c == engine.ConsumerWallboxB {
return float64(cfg.Shelly.WallboxB.PowerW) / 1000.0
}
return float64(cfg.Shelly.WallboxA.PowerW) / 1000.0
}
// buildCarOptions returns a sorted list of car options for the status page form.
func buildCarOptions(cars map[string]config.CarProfile) []status.CarOption {
keys := make([]string, 0, len(cars))
for k := range cars {
keys = append(keys, k)
}
sort.Strings(keys)
opts := make([]status.CarOption, 0, len(keys))
for _, k := range keys {
c := cars[k]
opts = append(opts, status.CarOption{Key: k, Name: c.Name, BatteryKWh: c.BatteryKWh})
}
return opts
}
// tripSetHandler handles the trip mode form submission.
func tripSetHandler(tm *trip.Manager, cfg *config.Config, logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
carKey := r.FormValue("car")
wallbox := r.FormValue("wallbox")
socStr := r.FormValue("current_soc")
deadlineStr := r.FormValue("deadline") // "2006-01-02T15:04"
car, ok := cfg.Cars[carKey]
if !ok {
http.Error(w, "unknown car", http.StatusBadRequest)
return
}
if wallbox != "wallbox_a" && wallbox != "wallbox_b" {
http.Error(w, "unknown wallbox", http.StatusBadRequest)
return
}
soc, err := strconv.ParseFloat(socStr, 64)
if err != nil || soc < 0 || soc >= 100 {
http.Error(w, "invalid SOC (must be 0–99)", http.StatusBadRequest)
return
}
deadline, err := time.ParseInLocation("2006-01-02T15:04", deadlineStr, time.Local)
if err != nil {
http.Error(w, "invalid deadline format", http.StatusBadRequest)
return
}
if deadline.Before(time.Now()) {
http.Error(w, "deadline is in the past", http.StatusBadRequest)
return
}
goal := trip.Goal{
Wallbox: wallbox,
CarName: car.Name,
BatteryKWh: car.BatteryKWh,
CurrentSOC: soc,
Deadline: deadline,
CreatedAt: time.Now(),
}
if err := tm.SetGoal(goal); err != nil {
logger.Error("failed to save trip goal", "error", err)
http.Error(w, "could not save goal — check logs", http.StatusInternalServerError)
return
}
logger.Info("trip goal set",
"wallbox", wallbox,
"car", car.Name,
"soc", soc,
"deadline", deadline.Format("02.01. 15:04"),
)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
// tripCancelHandler clears the active trip goal.
func tripCancelHandler(tm *trip.Manager, logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if err := tm.ClearGoal(); err != nil {
logger.Error("failed to clear trip goal", "error", err)
}
logger.Info("trip goal cancelled by user")
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
// wwBoostToggleHandler toggles the WW boost disable flag on POST.
func wwBoostToggleHandler(disabled *monitor.Mode, logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
newState := !disabled.IsActive()
if err := disabled.Set(newState); err != nil {
logger.Error("WW boost toggle failed", "error", err)
} else {
if newState {
logger.Info("WW boost disabled via web UI")
} else {
logger.Info("WW boost enabled via web UI")
}
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
// wwResetHandler resets the WW temperature to the configured base setpoint and
// blocks further boosts for the rest of the day via an engine override.
func wwResetHandler(act *actuator.Actuator, eng *engine.Engine, cfg *config.Config, logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// Block re-boost until midnight.
now := time.Now()
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
lockDur := time.Until(midnight)
eng.ApplyOverride(engine.ConsumerWW, false, lockDur)
// Immediately restore base WW temperature via Viessmann API.
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
action := engine.Action{
Consumer: engine.ConsumerWW,
TurnOn: false,
TargetTempC: cfg.Strategic.WWBaseC,
Reason: "manual WW reset via web UI",
}
if errs := act.Execute(ctx, []engine.Action{action}); errs[0] != nil {
logger.Error("WW reset: actuator failed", "error", errs[0])
} else {
logger.Info("WW boost reset", "base_c", cfg.Strategic.WWBaseC, "locked_until", midnight.Format("15:04"))
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
// monitorHandler toggles monitor-only mode on POST and redirects to the status page.
func monitorHandler(mode *monitor.Mode, logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
newState := !mode.IsActive()
if err := mode.Set(newState); err != nil {
logger.Error("failed to set monitor-only mode", "active", newState, "error", err)
http.Error(w, "could not update monitor mode — check logs", http.StatusInternalServerError)
return
}
logger.Info("monitor-only mode changed", "active", newState)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
@@ -309,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
}
@@ -343,6 +689,97 @@ func computeWWBoost(fc *forecast.Result, cfg *config.Config) float64 {
}
}
// runViessmannTest exercises the Viessmann API read/write path without starting
// the EMS control loop. It performs three stages:
// 1. Read current DHW setpoint (confirms auth + endpoint)
// 2. Write the same value back (confirms write path with zero net change)
// 3. Write setpoint +1°C, read back to confirm, then restore original
//
// Returns 0 on success, 1 on any failure.
func runViessmannTest(vc *viessmann.Client) int {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
pass := func(format string, args ...any) { fmt.Printf(" PASS "+format+"\n", args...) }
fail := func(format string, args ...any) { fmt.Fprintf(os.Stderr, " FAIL "+format+"\n", args...) }
step := func(format string, args ...any) { fmt.Printf("\n[test] "+format+"\n", args...) }
fmt.Println("=== Viessmann API test ===")
// Stage 1: read current setpoint
step("Stage 1 — read current DHW setpoint")
original, err := vc.GetDHWTemperature(ctx)
if err != nil {
fail("GetDHWTemperature: %v", err)
return 1
}
pass("current setpoint = %.1f°C", original)
// Stage 2: write same value back (round-trip, no observable effect)
step("Stage 2 — write same value back (%.1f°C → %.1f°C, no net change)", original, original)
if err := vc.SetDHWTemperature(ctx, original); err != nil {
fail("SetDHWTemperature(%.1f): %v", original, err)
return 1
}
readback, err := vc.GetDHWTemperature(ctx)
if err != nil {
fail("read-back after round-trip: %v", err)
return 1
}
if readback != original {
fail("round-trip mismatch: wrote %.1f, read back %.1f", original, readback)
return 1
}
pass("round-trip confirmed (%.1f°C)", readback)
// Stage 3: +1°C delta — heat pump won't notice (5°C hysteresis), but API change is visible.
// The Viessmann cloud queues the write to the gateway; the GET endpoint returns the last
// *confirmed* device value, so we wait up to 30s for the change to propagate.
delta := original + 1
step("Stage 3 — +1°C delta test (%.1f°C → %.1f°C)", original, delta)
if err := vc.SetDHWTemperature(ctx, delta); err != nil {
fail("SetDHWTemperature(%.1f): %v", delta, err)
_ = vc.SetDHWTemperature(ctx, original)
return 1
}
fmt.Println(" ...waiting 30s for cloud→gateway propagation...")
time.Sleep(30 * time.Second)
readback, err = vc.GetDHWTemperature(ctx)
if err != nil {
fail("read-back after +1°C: %v", err)
_ = vc.SetDHWTemperature(ctx, original)
return 1
}
if readback != delta {
fail("+1°C mismatch: wrote %.1f, read back %.1f after 30s — gateway may be offline or propagation takes longer", delta, readback)
_ = vc.SetDHWTemperature(ctx, original)
return 1
}
pass("+1°C confirmed (%.1f°C) — check Viessmann app now if you want visual confirmation", readback)
// Restore original
step("Restore — writing original setpoint back (%.1f°C)", original)
if err := vc.SetDHWTemperature(ctx, original); err != nil {
fail("restore SetDHWTemperature(%.1f): %v", original, err)
return 1
}
fmt.Println(" ...waiting 30s for cloud→gateway propagation...")
time.Sleep(30 * time.Second)
readback, err = vc.GetDHWTemperature(ctx)
if err != nil {
fail("read-back after restore: %v", err)
return 1
}
if readback != original {
fail("restore mismatch: wrote %.1f, read back %.1f", original, readback)
return 1
}
pass("restored to %.1f°C", readback)
fmt.Println("\n=== ALL STAGES PASSED — Viessmann API read/write confirmed ===")
return 0
}
// writeHeartbeat updates the heartbeat file timestamp each cycle.
func writeHeartbeat(stateFile string, logger *slog.Logger) {
if stateFile == "" {
@@ -356,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