Compare commits
2 Commits
house/lutz
...
house/son
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cd481a5ea | |||
| ca0f9ed1c3 |
@@ -1,133 +0,0 @@
|
|||||||
name: CI/CD
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Registry on the local Gitea instance (HTTP — add to Docker insecure-registries if needed)
|
|
||||||
env:
|
|
||||||
REGISTRY: 192.168.0.234:8765
|
|
||||||
# Deployment directory on the host — compose file lives here so relative
|
|
||||||
# volume paths (./infra/traefik/...) resolve correctly on the host.
|
|
||||||
DEPLOY_DIR: /opt/ems
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
|
|
||||||
# ── Test ──────────────────────────────────────────────────────────────────
|
|
||||||
# Runs on every push and every PR.
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Go 1.23
|
|
||||||
run: |
|
|
||||||
wget -qO go.tar.gz https://go.dev/dl/go1.23.4.linux-arm64.tar.gz
|
|
||||||
sudo tar -C /usr/local -xzf go.tar.gz
|
|
||||||
rm go.tar.gz
|
|
||||||
echo "/usr/local/go/bin" >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: go test ./...
|
|
||||||
|
|
||||||
- name: Build binary (compile check)
|
|
||||||
run: CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /dev/null .
|
|
||||||
|
|
||||||
# ── Build & Push ──────────────────────────────────────────────────────────
|
|
||||||
# Builds the Docker image and pushes it to the Gitea container registry.
|
|
||||||
# Runs on house/lutz and main; skipped on other branches and PRs.
|
|
||||||
#
|
|
||||||
# Required secret:
|
|
||||||
# GITEA_TOKEN — personal access token with write:packages permission
|
|
||||||
build-push:
|
|
||||||
needs: test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.ref == 'refs/heads/house/lutz' || github.ref == 'refs/heads/main'
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Log in to Gitea registry
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.MY_GITEA_CI_TOKEN }}" | \
|
|
||||||
docker login ${{ env.REGISTRY }} -u ${{ gitea.actor }} --password-stdin
|
|
||||||
|
|
||||||
- name: Build and push image
|
|
||||||
run: |
|
|
||||||
IMAGE=${{ env.REGISTRY }}/${{ gitea.repository_owner }}/ems
|
|
||||||
docker build \
|
|
||||||
-t ${IMAGE}:${{ gitea.sha }} \
|
|
||||||
-t ${IMAGE}:latest \
|
|
||||||
.
|
|
||||||
docker push ${IMAGE}:${{ gitea.sha }}
|
|
||||||
docker push ${IMAGE}:latest
|
|
||||||
echo "IMAGE=${IMAGE}:${{ gitea.sha }}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
# ── Deploy ────────────────────────────────────────────────────────────────
|
|
||||||
# Syncs compose + infra files to DEPLOY_DIR on the host, then restarts
|
|
||||||
# only the EMS container. Traefik keeps running untouched.
|
|
||||||
#
|
|
||||||
# Uses a docker:cli helper container so that:
|
|
||||||
# - relative paths in docker-compose.yml resolve from DEPLOY_DIR on the host
|
|
||||||
# - Docker socket gives access to the host daemon from within the job container
|
|
||||||
deploy:
|
|
||||||
needs: build-push
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.ref == 'refs/heads/house/lutz' || github.ref == 'refs/heads/main'
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Log in to Gitea registry
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.MY_GITEA_CI_TOKEN }}" | \
|
|
||||||
docker login ${{ env.REGISTRY }} -u ${{ gitea.actor }} --password-stdin
|
|
||||||
|
|
||||||
- name: Sync files and restart EMS
|
|
||||||
run: |
|
|
||||||
IMAGE=${{ env.REGISTRY }}/${{ gitea.repository_owner }}/ems:${{ gitea.sha }}
|
|
||||||
SHA=${{ gitea.sha }}
|
|
||||||
REPO_URL=http://x:${{ secrets.MY_GITEA_CI_TOKEN }}@192.168.0.234:8765/${{ gitea.repository }}.git
|
|
||||||
|
|
||||||
# The helper container mounts /opt/ems from the HOST (absolute path — works).
|
|
||||||
# It clones/updates the repo there so relative paths in docker-compose.yml
|
|
||||||
# (./infra/traefik/...) resolve correctly when Docker Compose runs.
|
|
||||||
docker run --rm \
|
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
||||||
-v ${{ env.DEPLOY_DIR }}:${{ env.DEPLOY_DIR }} \
|
|
||||||
-e EMS_IMAGE=${IMAGE} \
|
|
||||||
docker:cli sh -c "
|
|
||||||
set -e
|
|
||||||
apk add -q git
|
|
||||||
|
|
||||||
git config --global --add safe.directory ${{ env.DEPLOY_DIR }}
|
|
||||||
|
|
||||||
if [ -d ${{ env.DEPLOY_DIR }}/.git ]; then
|
|
||||||
git -C ${{ env.DEPLOY_DIR }} fetch --quiet
|
|
||||||
else
|
|
||||||
git clone --quiet ${REPO_URL} ${{ env.DEPLOY_DIR }}
|
|
||||||
fi
|
|
||||||
git -C ${{ env.DEPLOY_DIR }} checkout --quiet ${SHA}
|
|
||||||
|
|
||||||
cd ${{ env.DEPLOY_DIR }}
|
|
||||||
docker compose pull ems
|
|
||||||
docker compose up -d ems
|
|
||||||
"
|
|
||||||
|
|
||||||
- name: Verify health
|
|
||||||
run: |
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
# 1. EMS alive — direct check via internal Docker network
|
|
||||||
echo -n "EMS: "
|
|
||||||
docker run --rm --network ems_ems-internal alpine \
|
|
||||||
wget -qO- http://ems:9099/health && echo " — OK"
|
|
||||||
|
|
||||||
# 2. Traefik alive — ping endpoint (no mTLS required)
|
|
||||||
echo -n "Traefik: "
|
|
||||||
docker run --rm --network host alpine \
|
|
||||||
wget -qO- http://127.0.0.1:8082/ping && echo " — OK"
|
|
||||||
|
|
||||||
# 3. TCP reachability of the mTLS entrypoint from LAN IP
|
|
||||||
echo -n "Port 9443: "
|
|
||||||
docker run --rm --network host alpine sh -c \
|
|
||||||
"nc -zw5 192.168.0.152 9443 && echo reachable || echo UNREACHABLE"
|
|
||||||
19
Dockerfile
19
Dockerfile
@@ -1,19 +0,0 @@
|
|||||||
FROM golang:1.23-alpine AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY . .
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o ems .
|
|
||||||
|
|
||||||
FROM alpine:3.19
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=builder /app/ems .
|
|
||||||
|
|
||||||
# Web UI port (put behind Synology HTTPS reverse proxy)
|
|
||||||
EXPOSE 9099
|
|
||||||
# Prometheus metrics port (keep internal — do not expose via reverse proxy)
|
|
||||||
EXPOSE 9100
|
|
||||||
|
|
||||||
ENTRYPOINT ["/app/ems"]
|
|
||||||
CMD ["-config", "/etc/ems/ems-config.yaml"]
|
|
||||||
@@ -1,52 +1,52 @@
|
|||||||
# EMS Configuration
|
# EMS Configuration — house/son
|
||||||
|
# Fill in all values marked with <...> before deploying.
|
||||||
# All thresholds and timing parameters are configurable here.
|
# All thresholds and timing parameters are configurable here.
|
||||||
|
|
||||||
prometheus:
|
prometheus:
|
||||||
url: "http://192.168.0.23:9090"
|
url: "http://<prometheus-ip>:9090"
|
||||||
|
|
||||||
# Metric names from the custom Viessmann exporter
|
# Metric names from the custom Viessmann exporter (same as house/lutz)
|
||||||
metrics:
|
metrics:
|
||||||
grid_power_exchange: "pcc_transfer_power_exchange_value" # positive = import, negative = export
|
grid_power_exchange: "pcc_transfer_power_exchange_value" # positive = import, negative = export
|
||||||
battery_soc: "ess_stateOfCharge_value" # 0-100%
|
battery_soc: "ess_stateOfCharge_value" # 0-100%
|
||||||
pv_production: "photovoltaic_production_current_value" # watts
|
pv_production: "photovoltaic_production_current_value" # watts
|
||||||
battery_power: "ess_power_value" # watts
|
battery_power: "ess_power_value" # watts
|
||||||
compressor_power: "heating_compressors_0_sensors_power_value * heating_compressors_0_power_value * 10" # actual watts: sensors(%) × rated_kW(10) × 10
|
compressor_power: "heating_compressors_0_power_value" # watts
|
||||||
ambient_temp: "heating_sensors_temperature_outside_value" # °C
|
ambient_temp: "heating_sensors_temperature_outside_value" # °C
|
||||||
phase_l1_power: "pcc_ac_active_power_phaseOne" # per-phase grid power L1 (W)
|
phase_l1_power: "pcc_ac_active_power_phaseOne" # per-phase grid power L1 (W)
|
||||||
phase_l2_power: "pcc_ac_active_power_phaseTwo" # per-phase grid power L2 (W)
|
phase_l2_power: "pcc_ac_active_power_phaseTwo" # per-phase grid power L2 (W)
|
||||||
phase_l3_power: "pcc_ac_active_power_phaseThree" # per-phase grid power L3 (W)
|
phase_l3_power: "pcc_ac_active_power_phaseThree" # per-phase grid power L3 (W)
|
||||||
ww_top_temp: "heating_dhw_sensors_temperature_dhwCylinder_top_value"
|
|
||||||
pv_yield_today: "photovoltaic_production_cumulated_currentDay" # Wh, converted to kWh by collector (÷1000) # DHW cylinder top temperature (°C)
|
|
||||||
|
|
||||||
# Shelly actuators
|
# Shelly actuators
|
||||||
shelly:
|
shelly:
|
||||||
sg_ready:
|
sg_ready:
|
||||||
ip: "192.168.42.90"
|
ip: "<shelly-sg-ready-ip>"
|
||||||
gen: 1 # Gen1: /relay/0?turn=on|off
|
gen: 1 # Gen1: /relay/0?turn=on|off
|
||||||
wallbox_a:
|
wallbox_a:
|
||||||
ip: "192.168.42.185"
|
ip: "<shelly-wallbox-a-ip>"
|
||||||
gen: 2 # Gen2: /rpc/Switch.Set
|
gen: 2 # Gen2: /rpc/Switch.Set
|
||||||
power_w: 2000
|
power_w: 2000 # rated power of the wallbox (W)
|
||||||
password: "" # set Shelly admin password if auth is enabled
|
password: "" # set Shelly admin password if auth is enabled
|
||||||
wallbox_b:
|
wallbox_b:
|
||||||
ip: "192.168.42.51"
|
ip: "<shelly-wallbox-b-ip>"
|
||||||
gen: 2 # Gen2: /rpc/Switch.Set
|
gen: 2 # Gen2: /rpc/Switch.Set
|
||||||
power_w: 4000
|
power_w: 4000 # rated power of the wallbox (W)
|
||||||
password: "" # set Shelly admin password if auth is enabled
|
password: "" # set Shelly admin password if auth is enabled
|
||||||
|
|
||||||
# Viessmann API (write access for WW temperature)
|
# Viessmann API (write access for WW temperature)
|
||||||
|
# Leave installation_id empty to disable WW boost (EMS runs without it)
|
||||||
viessmann:
|
viessmann:
|
||||||
token_file: "/var/lib/ems/viessmann-token.json" # OAuth2 refresh token
|
token_file: "/etc/ems/viessmann-token.json" # OAuth2 refresh token
|
||||||
client_id: "5d1235548dfaa01ce37db8f865d2df4d"
|
client_id: "<viessmann-oauth2-client-id>"
|
||||||
installation_id: "1253124"
|
installation_id: "" # leave empty to disable WW boost
|
||||||
gateway_serial: "7637415006796210"
|
gateway_serial: "<gateway-serial>"
|
||||||
device_id: "0"
|
device_id: "0"
|
||||||
|
|
||||||
# SOC thresholds — which consumers are allowed at which battery level
|
# SOC thresholds — which consumers are allowed at which battery level
|
||||||
soc_thresholds:
|
soc_thresholds:
|
||||||
block_all: 50 # below 50%: no consumers
|
block_all: 50 # below 50%: no consumers
|
||||||
sg_ready_only: 70 # 50-70%: only SG-Ready (heating period)
|
sg_ready_only: 70 # 50-70%: only SG-Ready (heating period)
|
||||||
plus_wallbox_a: 90 # 70-90%: + Wallbox A (2kW)
|
plus_wallbox_a: 90 # 70-90%: + Wallbox A
|
||||||
all_consumers: 90 # above 90%: all consumers
|
all_consumers: 90 # above 90%: all consumers
|
||||||
|
|
||||||
# Hysteresis settings
|
# Hysteresis settings
|
||||||
@@ -70,9 +70,7 @@ thresholds:
|
|||||||
strategic:
|
strategic:
|
||||||
forecast_high_kwh: 25
|
forecast_high_kwh: 25
|
||||||
forecast_mid_kwh: 15
|
forecast_mid_kwh: 15
|
||||||
ww_base_c: 48 # normal WW setpoint (°C)
|
ww_base_c: 48 # normal WW setpoint (°C) — adjust to your heat pump
|
||||||
ww_max_setpoint_c: 60 # absolute maximum WW setpoint (°C)
|
|
||||||
ww_hysteresis_c: 5 # Viessmann switchOn = setpoint - hysteresis (°C)
|
|
||||||
ww_boost_high_c: 5 # +5°C on high-forecast days (>25 kWh)
|
ww_boost_high_c: 5 # +5°C on high-forecast days (>25 kWh)
|
||||||
ww_boost_mid_c: 3 # +3°C on medium-forecast days (>15 kWh)
|
ww_boost_mid_c: 3 # +3°C on medium-forecast days (>15 kWh)
|
||||||
ww_window_start: "12:30" # WW boost only allowed from 12:30
|
ww_window_start: "12:30" # WW boost only allowed from 12:30
|
||||||
@@ -82,12 +80,9 @@ strategic:
|
|||||||
|
|
||||||
# Consumer behavior — idle detection thresholds
|
# Consumer behavior — idle detection thresholds
|
||||||
consumers:
|
consumers:
|
||||||
compressor_idle_w: 500 # heat pump compressor below this = idle (W); running starts at ~2700W, so 500W cleanly separates idle (0W) from running
|
compressor_idle_w: 50 # heat pump compressor below this = idle (W)
|
||||||
wallbox_min_charge_w: 50 # wallbox below this = car not charging (W)
|
wallbox_min_charge_w: 50 # wallbox below this = car not charging (W)
|
||||||
idle_cycles: 3 # consecutive idle cycles before early release (~6 min at 2-min poll)
|
idle_cycles: 3 # consecutive idle cycles before early release (~6 min at 2-min poll)
|
||||||
sg_ready_startup_min: 10 # grace period after SG-Ready activation before compressor idle check starts (min)
|
|
||||||
wallbox_a_accepted_import_w: 0 # tolerate this much grid import while WallboxA is running (0 = disabled; WallboxA is 2kW, inverter headroom is sufficient)
|
|
||||||
wallbox_b_accepted_import_w: 600 # tolerate up to 600W import while WallboxB runs (4kW wallbox + 450W house > 4.6kW inverter max on PV dips)
|
|
||||||
|
|
||||||
# Season detection
|
# Season detection
|
||||||
season:
|
season:
|
||||||
@@ -98,56 +93,23 @@ season:
|
|||||||
# PV forecast — forecast.solar (free, no API key required)
|
# PV forecast — forecast.solar (free, no API key required)
|
||||||
forecast:
|
forecast:
|
||||||
enabled: true
|
enabled: true
|
||||||
lat: 48.7839
|
lat: <latitude> # decimal degrees, e.g. 48.7839
|
||||||
lon: 9.3889
|
lon: <longitude> # decimal degrees, e.g. 9.3889
|
||||||
declination: 20 # panel tilt in degrees
|
declination: <tilt> # panel tilt in degrees from horizontal (0=flat, 90=vertical)
|
||||||
azimuth: 5 # degrees from south (south=0, west=90, east=-90)
|
azimuth: <azimuth> # degrees from south (south=0, west=90, east=-90)
|
||||||
kwp: 7.0 # installed peak power
|
kwp: <kwp> # installed peak power in kWp, e.g. 8.5
|
||||||
fetch_interval: "4h" # re-fetch during the day (free tier: 12 req/day → 3 fetches)
|
fetch_interval: "4h" # re-fetch during the day (free tier: 12 req/day → 3 fetches)
|
||||||
fetch_window_start: "07:00"
|
fetch_window_start: "07:00"
|
||||||
fetch_window_end: "19:00"
|
fetch_window_end: "19:00"
|
||||||
base_load_w: 450 # steady-state house consumption (W) — spikes from kettle/dishwasher are hourly averages and negligible
|
base_load_w: 400 # steady-state house consumption (W) — measure and adjust
|
||||||
min_surplus_w: 1800 # min export surplus to trigger wallbox — matches wallbox_a threshold
|
min_surplus_w: 1800 # min export surplus to trigger wallbox charging (W)
|
||||||
|
|
||||||
# EMS operational settings
|
# EMS operational settings
|
||||||
ems:
|
ems:
|
||||||
poll_interval: "2m"
|
poll_interval: "2m"
|
||||||
listen_addr: ":9099" # web UI (login-protected; put behind Synology HTTPS proxy for internet access)
|
listen_addr: ":9099" # Prometheus metrics endpoint
|
||||||
metrics_addr: ":9101" # Prometheus /metrics (no auth — keep internal, do not expose to internet)
|
|
||||||
# Note: :9100 is typically taken by prometheus-node-exporter
|
|
||||||
log_level: "info"
|
log_level: "info"
|
||||||
http_username: "ems" # login username (empty = no auth required)
|
|
||||||
http_password: "changeme" # login password — change this; config must be chmod 600
|
|
||||||
state_file: "/run/ems/heartbeat" # written each cycle for state recovery
|
state_file: "/run/ems/heartbeat" # written each cycle for state recovery
|
||||||
recovery_timeout: "1h" # ignore Shelly state if EMS was down longer
|
recovery_timeout: "1h" # ignore Shelly state if EMS was down longer
|
||||||
override_timeout: "1h" # default lockout when EMS detects external Shelly change
|
override_timeout: "1h" # default lockout when EMS detects external Shelly change
|
||||||
override_max_import_w: 800 # cancel override immediately if importing more than this (0 = disabled)
|
override_max_import_w: 800 # cancel override immediately if importing more than this (0 = disabled)
|
||||||
monitor_only_file: "/var/lib/ems/monitor-only" # presence of this file = monitor-only mode active
|
|
||||||
ww_boost_disable_file: "/var/lib/ems/ww-boost-off" # presence of this file = WW boost disabled
|
|
||||||
trip_goal_file: "/var/lib/ems/trip-goal.json" # persisted active trip goal
|
|
||||||
session_log_file: "/var/lib/ems/sessions.jsonl" # JSONL log of completed charge sessions
|
|
||||||
|
|
||||||
# Battery physical properties (for EOD soft-stop calculation)
|
|
||||||
battery:
|
|
||||||
capacity_kwh: 8.0 # usable battery capacity (kWh)
|
|
||||||
|
|
||||||
# Proactive car charging strategy (forecast-driven, no export threshold required)
|
|
||||||
car_charging:
|
|
||||||
min_soc: 25 # start car charging only if battery SOC ≥ this (%)
|
|
||||||
soc_floor: 5 # emergency brake floor — stop if SOC drops below this (%)
|
|
||||||
soc_high_bypass_pct: 90 # activate without forecast check when SOC ≥ this % (battery full → always charge)
|
|
||||||
eod_soc_target: 90 # desired battery SOC at end of day / sunset (%)
|
|
||||||
eod_time: "16:00" # start EOD soft-stop checks after this time
|
|
||||||
no_car_retry_min: 30 # minutes before retrying after no-car detection
|
|
||||||
pv_threshold_a_w: 1000 # min PV production to start WallboxA (W) — 50% of 2kW rated
|
|
||||||
pv_threshold_b_w: 2000 # min PV production to start WallboxB (W) — 50% of 4kW rated
|
|
||||||
grid_delta_thresh_w: 2000 # min grid power shift after WallboxB activation = car detected (W)
|
|
||||||
|
|
||||||
# Known car profiles for trip mode
|
|
||||||
cars:
|
|
||||||
mini:
|
|
||||||
name: "Mini Cooper SE"
|
|
||||||
battery_kwh: 50.0
|
|
||||||
bmw:
|
|
||||||
name: "BMW ix2 eDrive20"
|
|
||||||
battery_kwh: 63.0
|
|
||||||
|
|||||||
15
deploy/son/.env
Normal file
15
deploy/son/.env
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# ── Son's EMS stack — edit these values before first start ──────────────────
|
||||||
|
|
||||||
|
# Static IP of this Synology on the LAN (no trailing slash)
|
||||||
|
SYNOLOGY_IP=192.168.1.100
|
||||||
|
|
||||||
|
# Fronius DataManager IP (the box with the display / web interface)
|
||||||
|
FRONIUS_IP=192.168.1.XXX
|
||||||
|
|
||||||
|
# Elli Charger Connect 2 — OCPP station IDs
|
||||||
|
# Find these in the Elli app under Settings → Charging Station → Station ID
|
||||||
|
ELLI_A_STATION_ID=ELLI-XXXXXXXXXX-1
|
||||||
|
ELLI_B_STATION_ID=ELLI-XXXXXXXXXX-2
|
||||||
|
|
||||||
|
# Timezone
|
||||||
|
TZ=Europe/Berlin
|
||||||
101
deploy/son/README.md
Normal file
101
deploy/son/README.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# EMS Stack — Einrichtung auf der Synology
|
||||||
|
|
||||||
|
## Was läuft hier?
|
||||||
|
|
||||||
|
| Dienst | Adresse | Funktion |
|
||||||
|
|---|---|---|
|
||||||
|
| evcc | `http://SYNOLOGY-IP:7070` | Ladesteuerung, PV-Überschuss, Haupt-UI |
|
||||||
|
| Grafana | `http://SYNOLOGY-IP:3000` | Historische Auswertungen |
|
||||||
|
| Prometheus | `http://SYNOLOGY-IP:9090` | Metrik-Speicher (intern) |
|
||||||
|
|
||||||
|
**Passwort Grafana:** `ems2024` (Benutzer: `admin`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Einmalige Einrichtung
|
||||||
|
|
||||||
|
### 1. Synology vorbereiten
|
||||||
|
|
||||||
|
Im Synology **Package Center**: **Container Manager** installieren (kostenlos).
|
||||||
|
|
||||||
|
### 2. Dateien auf die Synology kopieren
|
||||||
|
|
||||||
|
Den gesamten Ordner `deploy/son/` per FileStation oder SCP auf die Synology kopieren,
|
||||||
|
z.B. nach `/volume1/docker/ems/`.
|
||||||
|
|
||||||
|
### 3. `.env` anpassen
|
||||||
|
|
||||||
|
Die Datei `.env` im Texteditor öffnen und ausfüllen:
|
||||||
|
|
||||||
|
```
|
||||||
|
SYNOLOGY_IP=192.168.1.100 ← IP-Adresse der Synology
|
||||||
|
FRONIUS_IP=192.168.1.XXX ← IP-Adresse des Fronius DataManagers
|
||||||
|
ELLI_A_STATION_ID=ELLI-... ← Station-ID aus der Elli-App (Wallbox A)
|
||||||
|
ELLI_B_STATION_ID=ELLI-... ← Station-ID aus der Elli-App (Wallbox B)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Station-ID in der Elli-App:** Einstellungen → Ladestation → Station-ID
|
||||||
|
|
||||||
|
### 4. Elli Wallboxes konfigurieren
|
||||||
|
|
||||||
|
In der **Elli-App** für jede Wallbox:
|
||||||
|
- Einstellungen → Backend / OCPP
|
||||||
|
- Backend-URL: `ws://SYNOLOGY-IP:8887/ocpp`
|
||||||
|
- Station-ID: wie in `.env` eingetragen
|
||||||
|
|
||||||
|
### 5. Stack starten
|
||||||
|
|
||||||
|
Im Synology **Container Manager** → **Projekt** → **Erstellen**:
|
||||||
|
- Projektname: `ems`
|
||||||
|
- Pfad: Ordner mit der `docker-compose.yaml`
|
||||||
|
- Starten
|
||||||
|
|
||||||
|
Oder per SSH:
|
||||||
|
```bash
|
||||||
|
cd /volume1/docker/ems
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Prüfen ob alles läuft
|
||||||
|
|
||||||
|
- evcc öffnen: `http://SYNOLOGY-IP:7070`
|
||||||
|
- Unter **Geräte** sollten Fronius (PV + Batterie + Netz) und beide Wallboxen grün erscheinen
|
||||||
|
- Grafana: `http://SYNOLOGY-IP:3000` → Dashboard **evcc — Laden & PV**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wenn ein Fronius-Wechselrichter eine eigene IP hat
|
||||||
|
|
||||||
|
Falls doch zwei getrennte DataManager (zwei IP-Adressen):
|
||||||
|
In `evcc.yaml` den Abschnitt `meters` → `pv` auf zwei Einträge aufteilen:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: pv1
|
||||||
|
type: template
|
||||||
|
template: fronius-solarapi-v1
|
||||||
|
host: 192.168.1.XXX # Wechselrichter 1 (11 kW)
|
||||||
|
usage: pv
|
||||||
|
|
||||||
|
- name: pv2
|
||||||
|
type: template
|
||||||
|
template: fronius-solarapi-v1
|
||||||
|
host: 192.168.1.YYY # Wechselrichter 2 (22 kW)
|
||||||
|
usage: pv
|
||||||
|
```
|
||||||
|
|
||||||
|
Und in `site.meters.pv` beide eintragen:
|
||||||
|
```yaml
|
||||||
|
pv:
|
||||||
|
- pv1
|
||||||
|
- pv2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /volume1/docker/ems
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
64
deploy/son/docker-compose.yaml
Normal file
64
deploy/son/docker-compose.yaml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
# ── evcc — EV charging controller ─────────────────────────────────────────
|
||||||
|
evcc:
|
||||||
|
image: evcc/evcc:latest
|
||||||
|
container_name: evcc
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "7070:7070" # evcc web UI
|
||||||
|
- "8887:8887" # OCPP server (Elli wallboxes connect here)
|
||||||
|
volumes:
|
||||||
|
- ./evcc.yaml:/etc/evcc.yaml:ro
|
||||||
|
- evcc-data:/root/.evcc
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ}
|
||||||
|
networks:
|
||||||
|
- ems-net
|
||||||
|
|
||||||
|
# ── Prometheus — metrics storage ───────────────────────────────────────────
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:latest
|
||||||
|
container_name: prometheus
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
volumes:
|
||||||
|
- ./prometheus/prometheus.yaml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- prometheus-data:/prometheus
|
||||||
|
command:
|
||||||
|
- --config.file=/etc/prometheus/prometheus.yml
|
||||||
|
- --storage.tsdb.retention.time=90d
|
||||||
|
- --storage.tsdb.path=/prometheus
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ}
|
||||||
|
networks:
|
||||||
|
- ems-net
|
||||||
|
|
||||||
|
# ── Grafana — dashboards ───────────────────────────────────────────────────
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana-oss:latest
|
||||||
|
container_name: grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ}
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=ems2024
|
||||||
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
|
- GF_ANALYTICS_REPORTING_ENABLED=false
|
||||||
|
- GF_SERVER_ROOT_URL=http://${SYNOLOGY_IP}:3000
|
||||||
|
networks:
|
||||||
|
- ems-net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
evcc-data:
|
||||||
|
prometheus-data:
|
||||||
|
grafana-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ems-net:
|
||||||
|
driver: bridge
|
||||||
94
deploy/son/evcc.yaml
Normal file
94
deploy/son/evcc.yaml
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# evcc configuration — son's installation
|
||||||
|
# Edit the IPs/IDs below to match your hardware.
|
||||||
|
# All values with XXX must be filled in before starting.
|
||||||
|
|
||||||
|
network:
|
||||||
|
schema: http
|
||||||
|
host: 0.0.0.0 # listen on all interfaces inside container
|
||||||
|
port: 7070
|
||||||
|
|
||||||
|
log: warn
|
||||||
|
levels:
|
||||||
|
core: info
|
||||||
|
charger: info
|
||||||
|
|
||||||
|
# ── Interval ─────────────────────────────────────────────────────────────────
|
||||||
|
interval: 30s # control loop interval
|
||||||
|
|
||||||
|
# ── Grid meter (Fronius Smart Meter via DataManager) ─────────────────────────
|
||||||
|
meters:
|
||||||
|
- name: grid
|
||||||
|
type: template
|
||||||
|
template: fronius-solarapi-v1
|
||||||
|
host: ${FRONIUS_IP} # Fronius DataManager IP
|
||||||
|
usage: grid
|
||||||
|
|
||||||
|
- name: pv
|
||||||
|
type: template
|
||||||
|
template: fronius-solarapi-v1
|
||||||
|
host: ${FRONIUS_IP} # same DataManager — covers both inverters aggregated
|
||||||
|
usage: pv
|
||||||
|
|
||||||
|
- name: battery
|
||||||
|
type: template
|
||||||
|
template: fronius-solarapi-v1
|
||||||
|
host: ${FRONIUS_IP} # BYD battery connected to Fronius
|
||||||
|
usage: battery
|
||||||
|
|
||||||
|
# ── Site ─────────────────────────────────────────────────────────────────────
|
||||||
|
site:
|
||||||
|
title: "Haus"
|
||||||
|
meters:
|
||||||
|
grid: grid
|
||||||
|
pv:
|
||||||
|
- pv
|
||||||
|
battery:
|
||||||
|
- battery
|
||||||
|
|
||||||
|
# ── Chargers (Elli Charger Connect 2 via OCPP) ───────────────────────────────
|
||||||
|
# The Elli app: Settings → Charging Station → Backend URL
|
||||||
|
# Set to: ws://<SYNOLOGY_IP>:8887/ocpp
|
||||||
|
# The station ID is shown in the same screen.
|
||||||
|
chargers:
|
||||||
|
- name: wallbox_a
|
||||||
|
type: template
|
||||||
|
template: ocpp
|
||||||
|
stationid: ${ELLI_A_STATION_ID}
|
||||||
|
connecttimeout: 5m
|
||||||
|
|
||||||
|
- name: wallbox_b
|
||||||
|
type: template
|
||||||
|
template: ocpp
|
||||||
|
stationid: ${ELLI_B_STATION_ID}
|
||||||
|
connecttimeout: 5m
|
||||||
|
|
||||||
|
# ── Load points ──────────────────────────────────────────────────────────────
|
||||||
|
loadpoints:
|
||||||
|
- title: "Wallbox A (11 kW)"
|
||||||
|
charger: wallbox_a
|
||||||
|
mode: pv # default: charge on PV surplus only
|
||||||
|
mincurrent: 6 # A — minimum OCPP current (legal minimum = 6A)
|
||||||
|
maxcurrent: 16 # A — 16A × 3 phases × 230V ≈ 11 kW
|
||||||
|
phases: 3
|
||||||
|
|
||||||
|
- title: "Wallbox B (8 kW)"
|
||||||
|
charger: wallbox_b
|
||||||
|
mode: pv
|
||||||
|
mincurrent: 6
|
||||||
|
maxcurrent: 12 # A — 12A × 3 phases × 230V ≈ 8.3 kW
|
||||||
|
phases: 3
|
||||||
|
|
||||||
|
# ── Tariff (optional — enables cost display) ─────────────────────────────────
|
||||||
|
# Uncomment and adjust if you want cost/CO2 tracking
|
||||||
|
# tariffs:
|
||||||
|
# currency: EUR
|
||||||
|
# grid:
|
||||||
|
# type: fixed
|
||||||
|
# price: 0.30 # €/kWh
|
||||||
|
|
||||||
|
# ── OCPP server ───────────────────────────────────────────────────────────────
|
||||||
|
ocpp:
|
||||||
|
port: 8887
|
||||||
|
|
||||||
|
# ── Prometheus metrics ────────────────────────────────────────────────────────
|
||||||
|
# evcc exposes /metrics on the main port (7070)
|
||||||
10
deploy/son/grafana/provisioning/dashboards/dashboard.yaml
Normal file
10
deploy/son/grafana/provisioning/dashboards/dashboard.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: evcc
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
updateIntervalSeconds: 60
|
||||||
|
options:
|
||||||
|
path: /etc/grafana/provisioning/dashboards
|
||||||
|
foldersFromFilesStructure: false
|
||||||
1
deploy/son/grafana/provisioning/dashboards/evcc.json
Normal file
1
deploy/son/grafana/provisioning/dashboards/evcc.json
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: false
|
||||||
9
deploy/son/prometheus/prometheus.yaml
Normal file
9
deploy/son/prometheus/prometheus.yaml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
global:
|
||||||
|
scrape_interval: 30s
|
||||||
|
evaluation_interval: 30s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: evcc
|
||||||
|
static_configs:
|
||||||
|
- targets: ['evcc:7070']
|
||||||
|
metrics_path: /metrics
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
networks:
|
|
||||||
ems-internal:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
services:
|
|
||||||
|
|
||||||
# ── Traefik ─────────────────────────────────────────────────────────────────
|
|
||||||
# TLS termination + mTLS on port 9443.
|
|
||||||
# Perimeter firewall forwards external TCP 9443 → Synology:9443.
|
|
||||||
# Only connections presenting a client cert signed by /certs/ca.crt are forwarded.
|
|
||||||
traefik:
|
|
||||||
image: traefik:v3.1
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "9443:9443" # HTTPS + mTLS — open on perimeter firewall
|
|
||||||
- "127.0.0.1:8082:8082" # Traefik ping — LAN only, never expose externally
|
|
||||||
volumes:
|
|
||||||
- ./infra/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
|
||||||
- ./infra/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
|
|
||||||
# Certs: ca.crt, server.crt, server.key (generated by infra/ca/ scripts)
|
|
||||||
- /etc/ems/certs:/certs:ro
|
|
||||||
networks:
|
|
||||||
- ems-internal
|
|
||||||
depends_on:
|
|
||||||
- ems
|
|
||||||
|
|
||||||
# ── EMS ─────────────────────────────────────────────────────────────────────
|
|
||||||
# Web UI is NOT exposed externally — Traefik proxies to :9099 internally.
|
|
||||||
# Prometheus metrics on :9101 stay LAN-accessible for the existing scrape job.
|
|
||||||
#
|
|
||||||
# Image source:
|
|
||||||
# CI deploy: EMS_IMAGE=192.168.0.234:8765/owner/ems:sha docker compose up -d ems
|
|
||||||
# Local dev: docker compose up --build
|
|
||||||
ems:
|
|
||||||
image: ${EMS_IMAGE:-ems:latest}
|
|
||||||
build: .
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
# Metrics — Prometheus scrape from LAN (192.168.0.23:9090)
|
|
||||||
# Do NOT route through Traefik (no auth on metrics endpoint by design).
|
|
||||||
- "9101:9101"
|
|
||||||
volumes:
|
|
||||||
# Config (read-only) — must be chmod 600
|
|
||||||
- /etc/ems:/etc/ems:ro
|
|
||||||
# Persistent state: trip goals, sessions, auth tokens
|
|
||||||
- /var/lib/ems:/var/lib/ems
|
|
||||||
# Heartbeat file
|
|
||||||
- /run/ems:/run/ems
|
|
||||||
networks:
|
|
||||||
- ems-internal
|
|
||||||
environment:
|
|
||||||
- TZ=Europe/Berlin
|
|
||||||
|
|
||||||
# Health check using the unauthenticated /health endpoint
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:9099/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
12
infra/ca/.gitignore
vendored
12
infra/ca/.gitignore
vendored
@@ -1,12 +0,0 @@
|
|||||||
# Private keys and device certs — NEVER commit
|
|
||||||
*.key
|
|
||||||
*.p12
|
|
||||||
*.csr
|
|
||||||
*.srl
|
|
||||||
|
|
||||||
# ca.crt and server.crt are public — safe to commit if desired,
|
|
||||||
# but kept out by default so the repo doesn't become a cert store.
|
|
||||||
# Uncomment the next two lines to allow committing public certs:
|
|
||||||
# !ca.crt
|
|
||||||
# !server.crt
|
|
||||||
*.crt
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
# iPhone Enrollment Guide
|
|
||||||
|
|
||||||
Two steps: first install the CA as a trusted root, then install your personal client cert.
|
|
||||||
Both must be done before Safari can reach the EMS.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 1 — Install the CA cert (trust anchor)
|
|
||||||
|
|
||||||
Do this once. It makes your iPhone trust the EMS server cert and accept the client cert.
|
|
||||||
|
|
||||||
### On your Mac / Linux machine (in the `infra/ca/` directory):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Serve ca.crt temporarily on the LAN
|
|
||||||
python3 -m http.server 8080
|
|
||||||
```
|
|
||||||
|
|
||||||
### On the iPhone (Safari — not Chrome):
|
|
||||||
|
|
||||||
1. Open **Safari** and navigate to `http://192.168.x.x:8080/ca.crt`
|
|
||||||
(replace with your Mac's LAN IP — check with `ifconfig | grep 192.168`)
|
|
||||||
2. Safari shows: *"This website is trying to download a configuration profile. Do you want to allow this?"* → **Allow**
|
|
||||||
3. Open **Settings** → you will see a banner: **Profile Downloaded** → tap it → **Install**
|
|
||||||
4. Enter your iPhone passcode if prompted → **Install** (top right) → **Install** again to confirm
|
|
||||||
5. Go to **Settings → General → About → Certificate Trust Settings**
|
|
||||||
6. Under *"Enable Full Trust For Root Certificates"*, toggle **EMS Private CA** → **Continue**
|
|
||||||
|
|
||||||
The CA is now trusted. You can stop the Python server.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 2 — Install the client cert
|
|
||||||
|
|
||||||
Do this once per device. Generate the cert first if you haven't:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd infra/ca
|
|
||||||
./issue-client-cert.sh lutz-iphone
|
|
||||||
```
|
|
||||||
|
|
||||||
Then transfer `lutz-iphone.p12` to the iPhone. The easiest ways:
|
|
||||||
|
|
||||||
**AirDrop (recommended):**
|
|
||||||
1. On Mac: right-click `lutz-iphone.p12` → Share → AirDrop → select your iPhone
|
|
||||||
2. On iPhone: tap Accept
|
|
||||||
3. Tap the received file → **Settings** opens automatically
|
|
||||||
4. **Settings → Profile Downloaded** → **Install** → enter PKCS12 password → **Install**
|
|
||||||
|
|
||||||
**Alternatively via Files / Mail / Notes:**
|
|
||||||
- Share the `.p12` file to yourself via any app, then tap it to trigger profile installation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 3 — Test
|
|
||||||
|
|
||||||
1. Open **Safari** on the iPhone
|
|
||||||
2. Navigate to `https://ems.famfi.dyndns.org:9443`
|
|
||||||
3. Safari will prompt: *"ems.famfi.dyndns.org" wants to use "lutz-iphone EMS"* → **Continue**
|
|
||||||
4. The EMS login page should appear — no certificate warning
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Revoking a device
|
|
||||||
|
|
||||||
There is no CRL/OCSP for this private CA (not needed for a home setup).
|
|
||||||
To revoke a device:
|
|
||||||
1. On the iPhone: **Settings → General → VPN & Device Management** → select the EMS profile → **Remove**
|
|
||||||
2. Generate a new CA (`gen-ca.sh`) and re-enroll all remaining devices, OR
|
|
||||||
regenerate only the server cert and client certs — a revoked client cert is still technically valid
|
|
||||||
until the Traefik config is updated to exclude it by CN.
|
|
||||||
|
|
||||||
For a home setup with 1-2 devices, deleting the profile from the device is sufficient protection.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cert renewal (annually)
|
|
||||||
|
|
||||||
Server and client certs are valid for 825 days (~2.25 years). When they approach expiry:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd infra/ca
|
|
||||||
./gen-server-cert.sh # new server cert
|
|
||||||
scp server.crt server.key user@synology:/etc/ems/certs/
|
|
||||||
ssh user@synology "cd /opt/ems && docker compose restart traefik"
|
|
||||||
|
|
||||||
./issue-client-cert.sh lutz-iphone # new client cert — repeat enrollment Step 2
|
|
||||||
```
|
|
||||||
|
|
||||||
The CA itself is valid for 10 years. Its expiry date:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openssl x509 -noout -dates -in infra/ca/ca.crt
|
|
||||||
```
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# gen-ca.sh — Generate the EMS root CA (one-time operation).
|
|
||||||
#
|
|
||||||
# Outputs: ca.key (KEEP SECRET) and ca.crt (distribute to all devices).
|
|
||||||
# ca.key must be backed up encrypted and kept off the Synology.
|
|
||||||
# Losing ca.key means all devices must re-enroll after generating a new CA.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
|
|
||||||
if [[ -f ca.key ]]; then
|
|
||||||
echo "ERROR: ca.key already exists."
|
|
||||||
echo "Delete it explicitly if you intend to replace the CA."
|
|
||||||
echo "WARNING: a new CA invalidates ALL existing server and client certs — every device must re-enroll."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Generating EMS root CA (RSA-4096, 10 years)..."
|
|
||||||
|
|
||||||
openssl req -x509 -newkey rsa:4096 -sha256 \
|
|
||||||
-days 3650 \
|
|
||||||
-keyout ca.key \
|
|
||||||
-out ca.crt \
|
|
||||||
-nodes \
|
|
||||||
-subj "/CN=EMS Private CA/O=FamFi/C=DE" \
|
|
||||||
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
|
|
||||||
-addext "keyUsage=critical,keyCertSign,cRLSign" \
|
|
||||||
-addext "subjectKeyIdentifier=hash"
|
|
||||||
|
|
||||||
chmod 600 ca.key
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Done."
|
|
||||||
echo " ca.crt — distribute to devices (install as trusted root)"
|
|
||||||
echo " ca.key — KEEP SECRET: store in encrypted backup, remove from Synology after cert issuance"
|
|
||||||
echo ""
|
|
||||||
echo "Next steps:"
|
|
||||||
echo " ./gen-server-cert.sh # server cert for Traefik"
|
|
||||||
echo " ./issue-client-cert.sh <name> # one per device"
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# gen-server-cert.sh — Generate the Traefik server cert for ems.famfi.dyndns.org.
|
|
||||||
#
|
|
||||||
# Signed by the private CA. Once ca.crt is trusted on a device,
|
|
||||||
# this cert is accepted without warnings.
|
|
||||||
# Renew annually (before 825-day expiry) by re-running this script.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
|
|
||||||
DOMAIN="${1:-ems.famfi.dyndns.org}"
|
|
||||||
|
|
||||||
if [[ ! -f ca.key ]]; then
|
|
||||||
echo "ERROR: ca.key not found. Run gen-ca.sh first."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Generating server cert for ${DOMAIN}..."
|
|
||||||
|
|
||||||
openssl req -newkey rsa:2048 -nodes \
|
|
||||||
-keyout server.key \
|
|
||||||
-out server.csr \
|
|
||||||
-subj "/CN=${DOMAIN}/O=FamFi/C=DE"
|
|
||||||
|
|
||||||
openssl x509 -req \
|
|
||||||
-in server.csr \
|
|
||||||
-CA ca.crt \
|
|
||||||
-CAkey ca.key \
|
|
||||||
-CAcreateserial \
|
|
||||||
-out server.crt \
|
|
||||||
-days 825 \
|
|
||||||
-sha256 \
|
|
||||||
-extfile <(printf "basicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:%s\nsubjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid" "$DOMAIN")
|
|
||||||
|
|
||||||
chmod 600 server.key
|
|
||||||
rm -f server.csr
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Done: server.crt + server.key for ${DOMAIN}"
|
|
||||||
echo ""
|
|
||||||
echo "Deploy certs to Synology (run from infra/ca/):"
|
|
||||||
echo " ssh user@synology 'mkdir -p /etc/ems/certs'"
|
|
||||||
echo " scp ca.crt server.crt server.key user@synology:/etc/ems/certs/"
|
|
||||||
echo " ssh user@synology 'chmod 600 /etc/ems/certs/server.key'"
|
|
||||||
echo ""
|
|
||||||
echo "Traefik will pick up the new cert on next container restart."
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# issue-client-cert.sh — Issue a client cert for one device.
|
|
||||||
#
|
|
||||||
# Usage: ./issue-client-cert.sh <device-name>
|
|
||||||
# Example: ./issue-client-cert.sh lutz-iphone
|
|
||||||
#
|
|
||||||
# Outputs <device-name>.p12 — install on the device after enrolling ca.crt.
|
|
||||||
# See enroll-iphone.md for the full iPhone enrollment flow.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
|
|
||||||
NAME="${1:?Usage: $0 <device-name> e.g.: $0 lutz-iphone}"
|
|
||||||
|
|
||||||
if [[ ! -f ca.key ]]; then
|
|
||||||
echo "ERROR: ca.key not found. Run gen-ca.sh first."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -f "${NAME}.p12" ]]; then
|
|
||||||
echo "WARNING: ${NAME}.p12 already exists. Overwriting."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Issuing client cert for: ${NAME}"
|
|
||||||
echo "You will be prompted for a PKCS12 export password."
|
|
||||||
echo "Use a strong password — you will need it during iPhone installation."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
openssl req -newkey rsa:2048 -nodes \
|
|
||||||
-keyout "${NAME}.key" \
|
|
||||||
-out "${NAME}.csr" \
|
|
||||||
-subj "/CN=${NAME}/O=FamFi/C=DE"
|
|
||||||
|
|
||||||
openssl x509 -req \
|
|
||||||
-in "${NAME}.csr" \
|
|
||||||
-CA ca.crt \
|
|
||||||
-CAkey ca.key \
|
|
||||||
-CAcreateserial \
|
|
||||||
-out "${NAME}.crt" \
|
|
||||||
-days 825 \
|
|
||||||
-sha256 \
|
|
||||||
-extfile <(printf "basicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=clientAuth\nsubjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid")
|
|
||||||
|
|
||||||
openssl pkcs12 -export \
|
|
||||||
-out "${NAME}.p12" \
|
|
||||||
-inkey "${NAME}.key" \
|
|
||||||
-in "${NAME}.crt" \
|
|
||||||
-certfile ca.crt \
|
|
||||||
-name "${NAME} EMS"
|
|
||||||
|
|
||||||
chmod 600 "${NAME}.key" "${NAME}.p12"
|
|
||||||
rm -f "${NAME}.csr" "${NAME}.crt"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Done: ${NAME}.p12"
|
|
||||||
echo "AirDrop to iPhone, then follow enroll-iphone.md."
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# Traefik dynamic configuration — hot-reloaded by Traefik on change.
|
|
||||||
#
|
|
||||||
# TLS: private CA server cert + mandatory client cert (mTLS).
|
|
||||||
# Any connection without a valid client cert signed by ca.crt is rejected
|
|
||||||
# at the TLS handshake — before any HTTP reaches EMS.
|
|
||||||
|
|
||||||
tls:
|
|
||||||
certificates:
|
|
||||||
- certFile: /certs/server.crt
|
|
||||||
keyFile: /certs/server.key
|
|
||||||
|
|
||||||
options:
|
|
||||||
mtls:
|
|
||||||
clientAuth:
|
|
||||||
caFiles:
|
|
||||||
- /certs/ca.crt
|
|
||||||
clientAuthType: RequireAndVerifyClientCert
|
|
||||||
# Minimum TLS 1.2; prefer 1.3
|
|
||||||
minVersion: VersionTLS12
|
|
||||||
sniStrict: true
|
|
||||||
|
|
||||||
http:
|
|
||||||
routers:
|
|
||||||
ems:
|
|
||||||
rule: "Host(`ems.famfi.dyndns.org`)"
|
|
||||||
entryPoints:
|
|
||||||
- websecure
|
|
||||||
tls:
|
|
||||||
options: mtls
|
|
||||||
service: ems
|
|
||||||
|
|
||||||
services:
|
|
||||||
ems:
|
|
||||||
loadBalancer:
|
|
||||||
servers:
|
|
||||||
- url: "http://ems:9099"
|
|
||||||
passHostHeader: true
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Traefik static configuration
|
|
||||||
# Handles TLS termination on port 9443 with private CA mTLS.
|
|
||||||
# Dynamic routing config is in dynamic.yml (hot-reloaded on change).
|
|
||||||
|
|
||||||
entryPoints:
|
|
||||||
websecure:
|
|
||||||
address: ":9443"
|
|
||||||
ping:
|
|
||||||
address: ":8082" # internal health/ping — not exposed externally
|
|
||||||
|
|
||||||
providers:
|
|
||||||
file:
|
|
||||||
filename: /etc/traefik/dynamic.yml
|
|
||||||
watch: true # reload dynamic.yml without container restart
|
|
||||||
|
|
||||||
ping:
|
|
||||||
entryPoint: ping # GET http://localhost:8082/ping → 200 OK when Traefik is up
|
|
||||||
|
|
||||||
log:
|
|
||||||
level: INFO
|
|
||||||
|
|
||||||
accessLog:
|
|
||||||
filePath: "/dev/stdout"
|
|
||||||
format: common
|
|
||||||
@@ -38,30 +38,26 @@ func NewActuator(cfg *config.Config, vc *viessmann.Client, logger *slog.Logger)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute performs a list of switching actions and returns one error per action
|
// Execute performs a list of switching actions.
|
||||||
// (nil on success). Failed actions do not prevent subsequent actions from running.
|
func (a *Actuator) Execute(ctx context.Context, actions []engine.Action) error {
|
||||||
// The caller should roll back engine state for any failed action using
|
for _, action := range actions {
|
||||||
// Engine.RollbackAction so that the next SyncHardwareState cycle does not mistake
|
|
||||||
// the divergence for a manual override.
|
|
||||||
func (a *Actuator) Execute(ctx context.Context, actions []engine.Action) []error {
|
|
||||||
errs := make([]error, len(actions))
|
|
||||||
for i, action := range actions {
|
|
||||||
if err := a.executeOne(ctx, action); err != nil {
|
if err := a.executeOne(ctx, action); err != nil {
|
||||||
a.logger.Error("action failed",
|
a.logger.Error("action failed",
|
||||||
"consumer", action.Consumer,
|
"consumer", action.Consumer,
|
||||||
"turn_on", action.TurnOn,
|
"turn_on", action.TurnOn,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
errs[i] = err
|
// Continue with other actions even if one fails
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
a.logger.Info("action executed",
|
a.logger.Info("action executed",
|
||||||
"consumer", action.Consumer,
|
"consumer", action.Consumer,
|
||||||
"turn_on", action.TurnOn,
|
"turn_on", action.TurnOn,
|
||||||
"reason", action.Reason,
|
"reason", action.Reason,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return errs
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Actuator) executeOne(ctx context.Context, action engine.Action) error {
|
func (a *Actuator) executeOne(ctx context.Context, action engine.Action) error {
|
||||||
|
|||||||
@@ -88,11 +88,11 @@ func TestExecuteTurnOnSGReady(t *testing.T) {
|
|||||||
|
|
||||||
act := testActuator(t, ipFrom(sgSrv), ipFrom(dummySrv), ipFrom(dummySrv))
|
act := testActuator(t, ipFrom(sgSrv), ipFrom(dummySrv), ipFrom(dummySrv))
|
||||||
|
|
||||||
errs := act.Execute(context.Background(), []engine.Action{
|
err := act.Execute(context.Background(), []engine.Action{
|
||||||
{Consumer: engine.ConsumerSGReady, TurnOn: true, Reason: "test"},
|
{Consumer: engine.ConsumerSGReady, TurnOn: true, Reason: "test"},
|
||||||
})
|
})
|
||||||
if errs[0] != nil {
|
if err != nil {
|
||||||
t.Fatalf("Execute failed: %v", errs[0])
|
t.Fatalf("Execute failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !sg.state {
|
if !sg.state {
|
||||||
@@ -112,11 +112,11 @@ func TestExecuteTurnOffWallboxA(t *testing.T) {
|
|||||||
|
|
||||||
act := testActuator(t, ipFrom(dummySrv), ipFrom(wbASrv), ipFrom(dummySrv))
|
act := testActuator(t, ipFrom(dummySrv), ipFrom(wbASrv), ipFrom(dummySrv))
|
||||||
|
|
||||||
errs := act.Execute(context.Background(), []engine.Action{
|
err := act.Execute(context.Background(), []engine.Action{
|
||||||
{Consumer: engine.ConsumerWallboxA, TurnOn: false, Reason: "import"},
|
{Consumer: engine.ConsumerWallboxA, TurnOn: false, Reason: "import"},
|
||||||
})
|
})
|
||||||
if errs[0] != nil {
|
if err != nil {
|
||||||
t.Fatalf("Execute failed: %v", errs[0])
|
t.Fatalf("Execute failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if wbA.state {
|
if wbA.state {
|
||||||
@@ -164,14 +164,13 @@ func TestExecuteMultipleActions(t *testing.T) {
|
|||||||
|
|
||||||
act := testActuator(t, ipFrom(sgSrv), ipFrom(wbASrv), ipFrom(wbBSrv))
|
act := testActuator(t, ipFrom(sgSrv), ipFrom(wbASrv), ipFrom(wbBSrv))
|
||||||
|
|
||||||
for i, err := range act.Execute(context.Background(), []engine.Action{
|
err := act.Execute(context.Background(), []engine.Action{
|
||||||
{Consumer: engine.ConsumerSGReady, TurnOn: true},
|
{Consumer: engine.ConsumerSGReady, TurnOn: true},
|
||||||
{Consumer: engine.ConsumerWallboxA, TurnOn: true},
|
{Consumer: engine.ConsumerWallboxA, TurnOn: true},
|
||||||
{Consumer: engine.ConsumerWallboxB, TurnOn: true},
|
{Consumer: engine.ConsumerWallboxB, TurnOn: true},
|
||||||
}) {
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Execute action %d failed: %v", i, err)
|
t.Fatalf("Execute failed: %v", err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !sg.state || !wbA.state || !wbB.state {
|
if !sg.state || !wbA.state || !wbB.state {
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
// Package auth provides session-based authentication and CSRF protection
|
|
||||||
// for the EMS web UI. Both the session token and CSRF token are persisted
|
|
||||||
// to disk so they survive EMS restarts without logging out open browser tabs.
|
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/hex"
|
|
||||||
"html/template"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
cookieName = "ems_session"
|
|
||||||
csrfField = "csrf_token"
|
|
||||||
sessionFile = "session.token"
|
|
||||||
csrfFile = "csrf.token"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Manager handles login, session cookies, and CSRF tokens.
|
|
||||||
type Manager struct {
|
|
||||||
username string
|
|
||||||
password string
|
|
||||||
tokenDir string
|
|
||||||
mu sync.RWMutex
|
|
||||||
session string
|
|
||||||
csrf string
|
|
||||||
logger *slog.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a Manager and loads or generates persistent tokens from tokenDir.
|
|
||||||
// If password is empty, authentication is disabled (open access).
|
|
||||||
func New(username, password, tokenDir string, logger *slog.Logger) *Manager {
|
|
||||||
m := &Manager{
|
|
||||||
username: username,
|
|
||||||
password: password,
|
|
||||||
tokenDir: tokenDir,
|
|
||||||
logger: logger,
|
|
||||||
}
|
|
||||||
m.session = m.loadOrGenerate(sessionFile)
|
|
||||||
m.csrf = m.loadOrGenerate(csrfFile)
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enabled reports whether authentication is active.
|
|
||||||
func (m *Manager) Enabled() bool { return m.password != "" }
|
|
||||||
|
|
||||||
// CSRFToken returns the persistent CSRF token to embed in HTML forms.
|
|
||||||
func (m *Manager) CSRFToken() string {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
return m.csrf
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequireAuth is middleware that redirects unauthenticated requests to /login.
|
|
||||||
// Exempt paths: /login, /health (health checks don't need auth).
|
|
||||||
func (m *Manager) RequireAuth(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !m.Enabled() || r.URL.Path == "/login" || r.URL.Path == "/health" {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cookie, err := r.Cookie(cookieName)
|
|
||||||
if err != nil || !m.validSession(cookie.Value) {
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyCSRF is middleware that checks the csrf_token field on POST requests.
|
|
||||||
// On failure it redirects to / so the user gets a fresh form — avoids a
|
|
||||||
// confusing error page after an EMS restart regenerates the token.
|
|
||||||
func (m *Manager) VerifyCSRF(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if m.Enabled() && r.Method == http.MethodPost && r.URL.Path != "/login" && r.URL.Path != "/health" {
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
got := r.FormValue(csrfField)
|
|
||||||
m.mu.RLock()
|
|
||||||
ok := subtle.ConstantTimeCompare([]byte(got), []byte(m.csrf)) == 1
|
|
||||||
m.mu.RUnlock()
|
|
||||||
if !ok {
|
|
||||||
// Redirect to status page so the user gets a fresh form.
|
|
||||||
m.logger.Warn("CSRF token mismatch — redirecting to status page", "path", r.URL.Path)
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginHandler serves GET /login (form) and POST /login (credential check).
|
|
||||||
func (m *Manager) LoginHandler() http.HandlerFunc {
|
|
||||||
tmpl := template.Must(template.New("login").Parse(loginTemplate))
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method == http.MethodGet {
|
|
||||||
tmpl.Execute(w, nil) //nolint:errcheck
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user := r.FormValue("username")
|
|
||||||
pass := r.FormValue("password")
|
|
||||||
|
|
||||||
uOK := subtle.ConstantTimeCompare([]byte(user), []byte(m.username)) == 1
|
|
||||||
pOK := subtle.ConstantTimeCompare([]byte(pass), []byte(m.password)) == 1
|
|
||||||
if !uOK || !pOK {
|
|
||||||
m.logger.Warn("failed login attempt", "remote_addr", r.RemoteAddr)
|
|
||||||
tmpl.Execute(w, "Ungültige Zugangsdaten") //nolint:errcheck
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
m.mu.RLock()
|
|
||||||
session := m.session
|
|
||||||
m.mu.RUnlock()
|
|
||||||
|
|
||||||
// Set Secure flag when arriving via HTTPS reverse proxy.
|
|
||||||
secure := r.Header.Get("X-Forwarded-Proto") == "https"
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: cookieName,
|
|
||||||
Value: session,
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: secure,
|
|
||||||
SameSite: http.SameSiteStrictMode,
|
|
||||||
})
|
|
||||||
m.logger.Info("web UI login", "remote_addr", r.RemoteAddr)
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogoutHandler clears the session cookie and redirects to /login.
|
|
||||||
func (m *Manager) LogoutHandler() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: cookieName,
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
MaxAge: -1,
|
|
||||||
HttpOnly: true,
|
|
||||||
})
|
|
||||||
m.logger.Info("web UI logout", "remote_addr", r.RemoteAddr)
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) validSession(token string) bool {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
return subtle.ConstantTimeCompare([]byte(token), []byte(m.session)) == 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) loadOrGenerate(filename string) string {
|
|
||||||
path := filepath.Join(m.tokenDir, filename)
|
|
||||||
if data, err := os.ReadFile(path); err == nil {
|
|
||||||
if t := strings.TrimSpace(string(data)); len(t) == 64 {
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
token := newToken()
|
|
||||||
if err := os.MkdirAll(m.tokenDir, 0o700); err == nil {
|
|
||||||
_ = os.WriteFile(path, []byte(token), 0o600)
|
|
||||||
}
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
func newToken() string {
|
|
||||||
b := make([]byte, 32)
|
|
||||||
if _, err := rand.Read(b); err != nil {
|
|
||||||
panic("auth: cannot generate random token: " + err.Error())
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
const loginTemplate = `<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>EMS · Anmelden</title>
|
|
||||||
<style>
|
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
|
||||||
body{font-family:system-ui,sans-serif;background:#f1f5f9;display:flex;align-items:center;justify-content:center;min-height:100vh}
|
|
||||||
.card{background:#fff;border-radius:1rem;box-shadow:0 2px 12px rgba(0,0,0,.1);padding:2rem;width:100%;max-width:360px}
|
|
||||||
h1{font-size:1.4rem;margin-bottom:1.5rem;color:#0f172a;text-align:center}
|
|
||||||
label{display:block;font-size:.85rem;color:#64748b;margin-bottom:.3rem;margin-top:1rem}
|
|
||||||
input{width:100%;padding:.65rem .8rem;border:1px solid #cbd5e1;border-radius:.5rem;font-size:1rem;outline:none}
|
|
||||||
input:focus{border-color:#3b82f6;box-shadow:0 0 0 2px #bfdbfe}
|
|
||||||
button{width:100%;margin-top:1.5rem;padding:.75rem;background:#2563eb;color:#fff;border:none;border-radius:.5rem;font-size:1rem;cursor:pointer}
|
|
||||||
button:hover{background:#1d4ed8}
|
|
||||||
.error{background:#fee2e2;color:#b91c1c;border-radius:.5rem;padding:.6rem .9rem;margin-top:1rem;font-size:.9rem;text-align:center}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<h1>☀️ Solar EMS</h1>
|
|
||||||
<form method="post" action="/login">
|
|
||||||
<label for="u">Benutzer</label>
|
|
||||||
<input type="text" id="u" name="username" autocomplete="username" required autofocus>
|
|
||||||
<label for="p">Passwort</label>
|
|
||||||
<input type="password" id="p" name="password" autocomplete="current-password" required>
|
|
||||||
{{if .}}<div class="error">{{.}}</div>{{end}}
|
|
||||||
<button type="submit">Anmelden</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
@@ -26,8 +26,6 @@ type SystemState struct {
|
|||||||
PhaseL1PowerW float64 // per-phase grid power L1 (positive=import, negative=export)
|
PhaseL1PowerW float64 // per-phase grid power L1 (positive=import, negative=export)
|
||||||
PhaseL2PowerW float64 // per-phase grid power L2
|
PhaseL2PowerW float64 // per-phase grid power L2
|
||||||
PhaseL3PowerW float64 // per-phase grid power L3
|
PhaseL3PowerW float64 // per-phase grid power L3
|
||||||
WWTopTempC float64 // DHW cylinder top temperature (°C)
|
|
||||||
PVYieldTodayKWh float64 // cumulative PV production today (kWh)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsExporting returns true if the system is exporting to grid.
|
// IsExporting returns true if the system is exporting to grid.
|
||||||
@@ -93,8 +91,6 @@ func (c *Collector) Collect(ctx context.Context) (SystemState, error) {
|
|||||||
{"phase_l1_power", &state.PhaseL1PowerW, 1},
|
{"phase_l1_power", &state.PhaseL1PowerW, 1},
|
||||||
{"phase_l2_power", &state.PhaseL2PowerW, 1},
|
{"phase_l2_power", &state.PhaseL2PowerW, 1},
|
||||||
{"phase_l3_power", &state.PhaseL3PowerW, 1},
|
{"phase_l3_power", &state.PhaseL3PowerW, 1},
|
||||||
{"ww_top_temp", &state.WWTopTempC, 1},
|
|
||||||
{"pv_yield_today", &state.PVYieldTodayKWh, 0.001}, // Wh → kWh
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, t := range targets {
|
for _, t := range targets {
|
||||||
@@ -120,13 +116,10 @@ func (c *Collector) Collect(ctx context.Context) (SystemState, error) {
|
|||||||
"grid_w", state.GridPowerW,
|
"grid_w", state.GridPowerW,
|
||||||
"soc", state.BatterySOC,
|
"soc", state.BatterySOC,
|
||||||
"pv_w", state.PVProductionW,
|
"pv_w", state.PVProductionW,
|
||||||
"pv_today_kwh", fmt.Sprintf("%.1f", state.PVYieldTodayKWh),
|
|
||||||
"compressor_w", state.CompressorPowerW,
|
"compressor_w", state.CompressorPowerW,
|
||||||
"ambient_c", state.AmbientTempC,
|
|
||||||
"l1_w", state.PhaseL1PowerW,
|
"l1_w", state.PhaseL1PowerW,
|
||||||
"l2_w", state.PhaseL2PowerW,
|
"l2_w", state.PhaseL2PowerW,
|
||||||
"l3_w", state.PhaseL3PowerW,
|
"l3_w", state.PhaseL3PowerW,
|
||||||
"ww_top_c", state.WWTopTempC,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return state, nil
|
return state, nil
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -11,50 +10,17 @@ import (
|
|||||||
|
|
||||||
// Config is the top-level EMS configuration.
|
// Config is the top-level EMS configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Prometheus PrometheusConfig `yaml:"prometheus"`
|
Prometheus PrometheusConfig `yaml:"prometheus"`
|
||||||
Shelly ShellyConfig `yaml:"shelly"`
|
Shelly ShellyConfig `yaml:"shelly"`
|
||||||
Viessmann ViessmannConfig `yaml:"viessmann"`
|
Viessmann ViessmannConfig `yaml:"viessmann"`
|
||||||
SOC SOCThresholds `yaml:"soc_thresholds"`
|
SOC SOCThresholds `yaml:"soc_thresholds"`
|
||||||
Hysteresis HysteresisConfig `yaml:"hysteresis"`
|
Hysteresis HysteresisConfig `yaml:"hysteresis"`
|
||||||
Thresholds PowerThresholds `yaml:"thresholds"`
|
Thresholds PowerThresholds `yaml:"thresholds"`
|
||||||
Consumers ConsumersConfig `yaml:"consumers"`
|
Consumers ConsumersConfig `yaml:"consumers"`
|
||||||
Strategic StrategicConfig `yaml:"strategic"`
|
Strategic StrategicConfig `yaml:"strategic"`
|
||||||
Season SeasonConfig `yaml:"season"`
|
Season SeasonConfig `yaml:"season"`
|
||||||
Forecast ForecastConfig `yaml:"forecast"`
|
Forecast ForecastConfig `yaml:"forecast"`
|
||||||
Battery BatteryConfig `yaml:"battery"`
|
EMS EMSConfig `yaml:"ems"`
|
||||||
CarCharging CarChargingConfig `yaml:"car_charging"`
|
|
||||||
Cars map[string]CarProfile `yaml:"cars"`
|
|
||||||
EMS EMSConfig `yaml:"ems"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BatteryConfig holds physical battery properties.
|
|
||||||
type BatteryConfig struct {
|
|
||||||
CapacityKWh float64 `yaml:"capacity_kwh"` // usable battery capacity in kWh
|
|
||||||
}
|
|
||||||
|
|
||||||
// CarChargingConfig holds parameters for the proactive car charging strategy.
|
|
||||||
type CarChargingConfig struct {
|
|
||||||
MinSOC int `yaml:"min_soc"` // minimum SOC% to start car charging (e.g. 25)
|
|
||||||
SOCFloor int `yaml:"soc_floor"` // never drain battery below this % (e.g. 5)
|
|
||||||
SOCHighBypassPct int `yaml:"soc_high_bypass_pct"` // activate without forecast check when SOC ≥ this % (0 → default 90%)
|
|
||||||
EODSOCTarget int `yaml:"eod_soc_target"` // target SOC% to reach by sunset (e.g. 90)
|
|
||||||
EODTime string `yaml:"eod_time"` // soft-stop check starts at this time (e.g. "16:00")
|
|
||||||
NoCarRetryMin int `yaml:"no_car_retry_min"` // minutes before retrying after no-car detection
|
|
||||||
PVThresholdAW float64 `yaml:"pv_threshold_a_w"` // min PV production to start WallboxA (W)
|
|
||||||
PVThresholdBW float64 `yaml:"pv_threshold_b_w"` // min PV production to start WallboxB (W)
|
|
||||||
GridDeltaThreshW float64 `yaml:"grid_delta_thresh_w"` // min grid power shift after WallboxB activation = car detected (W)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CarChargingConfig) EODTimeParsed(ref time.Time) time.Time {
|
|
||||||
var h, m int
|
|
||||||
fmt.Sscanf(c.EODTime, "%d:%d", &h, &m)
|
|
||||||
return time.Date(ref.Year(), ref.Month(), ref.Day(), h, m, 0, 0, ref.Location())
|
|
||||||
}
|
|
||||||
|
|
||||||
// CarProfile holds the display name and battery capacity of a known vehicle.
|
|
||||||
type CarProfile struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
BatteryKWh float64 `yaml:"battery_kwh"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrometheusConfig holds Prometheus connection settings.
|
// PrometheusConfig holds Prometheus connection settings.
|
||||||
@@ -136,12 +102,9 @@ type PowerThresholds struct {
|
|||||||
|
|
||||||
// ConsumersConfig holds per-consumer behavior thresholds.
|
// ConsumersConfig holds per-consumer behavior thresholds.
|
||||||
type ConsumersConfig struct {
|
type ConsumersConfig struct {
|
||||||
CompressorIdleW int `yaml:"compressor_idle_w"` // below this = heat pump compressor idle (W)
|
CompressorIdleW int `yaml:"compressor_idle_w"` // below this = heat pump compressor idle (W)
|
||||||
WallboxMinChargeW int `yaml:"wallbox_min_charge_w"` // below this = car not charging (W)
|
WallboxMinChargeW int `yaml:"wallbox_min_charge_w"` // below this = car not charging (W)
|
||||||
IdleCycles int `yaml:"idle_cycles"` // consecutive idle cycles before early release
|
IdleCycles int `yaml:"idle_cycles"` // consecutive idle cycles before early release
|
||||||
SGReadyStartupMin int `yaml:"sg_ready_startup_min"` // grace period after SG-Ready activation before idle detection starts (min, 0 = disabled)
|
|
||||||
WallboxAAcceptedImportW float64 `yaml:"wallbox_a_accepted_import_w"` // tolerate this much grid import while WallboxA is running (0 = disabled)
|
|
||||||
WallboxBAcceptedImportW float64 `yaml:"wallbox_b_accepted_import_w"` // tolerate this much grid import while WallboxB is running (0 = disabled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StrategicConfig holds PV forecast based strategic settings.
|
// StrategicConfig holds PV forecast based strategic settings.
|
||||||
@@ -150,11 +113,9 @@ type StrategicConfig struct {
|
|||||||
ForecastMidKWh float64 `yaml:"forecast_mid_kwh"`
|
ForecastMidKWh float64 `yaml:"forecast_mid_kwh"`
|
||||||
WWBoostHighC float64 `yaml:"ww_boost_high_c"`
|
WWBoostHighC float64 `yaml:"ww_boost_high_c"`
|
||||||
WWBoostMidC float64 `yaml:"ww_boost_mid_c"`
|
WWBoostMidC float64 `yaml:"ww_boost_mid_c"`
|
||||||
WWBaseC float64 `yaml:"ww_base_c"` // normal WW setpoint (°C)
|
WWBaseC float64 `yaml:"ww_base_c"` // normal WW setpoint (°C)
|
||||||
WWMaxSetpointC float64 `yaml:"ww_max_setpoint_c"` // absolute maximum WW setpoint (°C), e.g. 60
|
WWWindowStart string `yaml:"ww_window_start"` // e.g. "12:30"
|
||||||
WWHysteresisC float64 `yaml:"ww_hysteresis_c"` // Viessmann switchOn = setpoint - hysteresis (°C), e.g. 5
|
WWWindowEnd string `yaml:"ww_window_end"` // e.g. "18:00"
|
||||||
WWWindowStart string `yaml:"ww_window_start"` // e.g. "12:30"
|
|
||||||
WWWindowEnd string `yaml:"ww_window_end"` // e.g. "18:00"
|
|
||||||
ScheduleOn string `yaml:"schedule_on"`
|
ScheduleOn string `yaml:"schedule_on"`
|
||||||
ScheduleOff string `yaml:"schedule_off"`
|
ScheduleOff string `yaml:"schedule_off"`
|
||||||
}
|
}
|
||||||
@@ -192,19 +153,12 @@ func (f *ForecastConfig) FetchIntervalParsed() time.Duration {
|
|||||||
// EMSConfig holds operational settings for the EMS daemon.
|
// EMSConfig holds operational settings for the EMS daemon.
|
||||||
type EMSConfig struct {
|
type EMSConfig struct {
|
||||||
PollInterval string `yaml:"poll_interval"`
|
PollInterval string `yaml:"poll_interval"`
|
||||||
ListenAddr string `yaml:"listen_addr"` // web UI address (authenticated)
|
ListenAddr string `yaml:"listen_addr"`
|
||||||
MetricsAddr string `yaml:"metrics_addr"` // Prometheus /metrics address (no auth, internal only)
|
|
||||||
LogLevel string `yaml:"log_level"`
|
LogLevel string `yaml:"log_level"`
|
||||||
StateFile string `yaml:"state_file"`
|
StateFile string `yaml:"state_file"`
|
||||||
RecoveryTimeout string `yaml:"recovery_timeout"`
|
RecoveryTimeout string `yaml:"recovery_timeout"`
|
||||||
OverrideTimeout string `yaml:"override_timeout"`
|
OverrideTimeout string `yaml:"override_timeout"`
|
||||||
OverrideMaxImportW float64 `yaml:"override_max_import_w"` // cancel override if grid import exceeds this (0 = disabled)
|
OverrideMaxImportW float64 `yaml:"override_max_import_w"` // cancel override if grid import exceeds this (0 = disabled)
|
||||||
MonitorOnlyFile string `yaml:"monitor_only_file"` // flag file path: presence = monitor-only mode active
|
|
||||||
WWBoostDisableFile string `yaml:"ww_boost_disable_file"` // flag file path: presence = WW boost disabled
|
|
||||||
TripGoalFile string `yaml:"trip_goal_file"` // persisted active trip goal
|
|
||||||
SessionLogFile string `yaml:"session_log_file"` // JSONL log of completed charge sessions
|
|
||||||
HTTPUsername string `yaml:"http_username"` // login username (empty = no auth)
|
|
||||||
HTTPPassword string `yaml:"http_password"` // login password
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *EMSConfig) PollIntervalParsed() time.Duration {
|
func (e *EMSConfig) PollIntervalParsed() time.Duration {
|
||||||
@@ -230,13 +184,6 @@ func (e *EMSConfig) OverrideTimeoutParsed() time.Duration {
|
|||||||
|
|
||||||
// Load reads and parses the YAML config file at the given path.
|
// Load reads and parses the YAML config file at the given path.
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
// Warn if config file is readable by group or others (contains credentials).
|
|
||||||
if info, err := os.Stat(path); err == nil {
|
|
||||||
if info.Mode().Perm()&0o077 != 0 {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARNING: config file %s has permissions %o — should be 0600 (contains credentials)\n", path, info.Mode().Perm())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("reading config file: %w", err)
|
return nil, fmt.Errorf("reading config file: %w", err)
|
||||||
@@ -267,21 +214,5 @@ func (c *Config) validate() error {
|
|||||||
if c.EMS.ListenAddr == "" {
|
if c.EMS.ListenAddr == "" {
|
||||||
return fmt.Errorf("ems.listen_addr is required")
|
return fmt.Errorf("ems.listen_addr is required")
|
||||||
}
|
}
|
||||||
// Validate Shelly IPs to prevent SSRF via config manipulation.
|
|
||||||
for name, dev := range map[string]ShellyDevice{
|
|
||||||
"sg_ready": c.Shelly.SGReady,
|
|
||||||
"wallbox_a": c.Shelly.WallboxA,
|
|
||||||
"wallbox_b": c.Shelly.WallboxB,
|
|
||||||
} {
|
|
||||||
if dev.IP != "" && net.ParseIP(dev.IP) == nil {
|
|
||||||
return fmt.Errorf("shelly.%s.ip %q is not a valid IP address", name, dev.IP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c.EMS.HTTPUsername != "" && c.EMS.HTTPPassword == "" {
|
|
||||||
return fmt.Errorf("ems.http_username is set but ems.http_password is empty")
|
|
||||||
}
|
|
||||||
if c.EMS.HTTPPassword != "" && c.EMS.HTTPUsername == "" {
|
|
||||||
return fmt.Errorf("ems.http_password is set but ems.http_username is empty")
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,14 +51,11 @@ type DeviceStatus struct {
|
|||||||
|
|
||||||
// ConsumerState tracks the runtime state of a single consumer.
|
// ConsumerState tracks the runtime state of a single consumer.
|
||||||
type ConsumerState struct {
|
type ConsumerState struct {
|
||||||
Active bool
|
Active bool
|
||||||
ActivatedAt time.Time // when it was last turned on
|
ActivatedAt time.Time // when it was last turned on
|
||||||
ManualOverride bool
|
ManualOverride bool
|
||||||
OverrideUntil time.Time
|
OverrideUntil time.Time
|
||||||
LowPowerCycles int // consecutive cycles with power below minimum threshold
|
LowPowerCycles int // consecutive cycles with power below minimum threshold
|
||||||
ProactiveCharging bool // true if activated by proactive car-charging logic (bypasses import shutdown)
|
|
||||||
ProbeStartGridW float64 // grid power snapshot at WallboxB activation (for no-car detection)
|
|
||||||
NoCarRetryUntil time.Time // don't retry WallboxB proactive charging until this time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OverrideInfo is returned to callers that need to display or record override state.
|
// OverrideInfo is returned to callers that need to display or record override state.
|
||||||
@@ -103,11 +100,9 @@ func NewEngine(cfg *config.Config, logger *slog.Logger) *Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decide evaluates the current system state and returns a list of actions.
|
// Decide evaluates the current system state and returns a list of actions.
|
||||||
//
|
// wwBoostC is the WW temperature boost in °C derived from the PV forecast
|
||||||
// - wwBoostC: WW temperature boost in °C from PV forecast (0 = no boost warranted)
|
// (0 = no forecast / forecast too low to warrant boosting).
|
||||||
// - forecastKWh: today's forecast total in kWh (0 if forecasting disabled)
|
func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC float64) []Action {
|
||||||
// - sunsetTime: estimated time of sunset (used for EOD soft stop); zero = disabled
|
|
||||||
func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC, forecastKWh float64, sunsetTime time.Time) []Action {
|
|
||||||
var actions []Action
|
var actions []Action
|
||||||
|
|
||||||
soc := state.BatterySOC
|
soc := state.BatterySOC
|
||||||
@@ -119,11 +114,10 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC, fo
|
|||||||
e.logger.Debug("decision input",
|
e.logger.Debug("decision input",
|
||||||
"grid_w", gridW,
|
"grid_w", gridW,
|
||||||
"soc", soc,
|
"soc", soc,
|
||||||
"pv_w", state.PVProductionW,
|
|
||||||
"forecast_kwh", forecastKWh,
|
|
||||||
"heating_period", heatingPeriod,
|
"heating_period", heatingPeriod,
|
||||||
"ww_window", wwWindow,
|
"ww_window", wwWindow,
|
||||||
"ww_boost_c", wwBoostC,
|
"ww_boost_c", wwBoostC,
|
||||||
|
"allowed", allowed,
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- SOC emergency brake ---
|
// --- SOC emergency brake ---
|
||||||
@@ -145,21 +139,14 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC, fo
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- EOD soft stop: after configured time, stop car charging if remaining PV insufficient ---
|
// --- Shutdown logic (reverse priority order) ---
|
||||||
actions = append(actions, e.checkEODSoftStop(state, now, sunsetTime)...)
|
|
||||||
|
|
||||||
// --- WallboxB no-car probe: check grid delta after activation ---
|
|
||||||
actions = append(actions, e.checkWallboxBProbe(state, now)...)
|
|
||||||
|
|
||||||
// --- Shutdown logic (reverse priority order, import hysteresis) ---
|
|
||||||
// Proactive wallboxes are skipped — EOD/probe/idle-cycles handle their stops.
|
|
||||||
if gridW > e.cfg.Thresholds.ImportOffW {
|
if gridW > e.cfg.Thresholds.ImportOffW {
|
||||||
if e.hyst.ImportSinceAbove.IsZero() {
|
if e.hyst.ImportSinceAbove.IsZero() {
|
||||||
e.hyst.ImportSinceAbove = now
|
e.hyst.ImportSinceAbove = now
|
||||||
}
|
}
|
||||||
importDuration := now.Sub(e.hyst.ImportSinceAbove)
|
importDuration := now.Sub(e.hyst.ImportSinceAbove)
|
||||||
if importDuration >= e.cfg.Hysteresis.ImportOffDurationParsed() {
|
if importDuration >= e.cfg.Hysteresis.ImportOffDurationParsed() {
|
||||||
if a := e.shutdownLastConsumer(now, gridW); a != nil {
|
if a := e.shutdownLastConsumer(now); a != nil {
|
||||||
actions = append(actions, *a)
|
actions = append(actions, *a)
|
||||||
e.hyst.ImportSinceAbove = time.Time{}
|
e.hyst.ImportSinceAbove = time.Time{}
|
||||||
}
|
}
|
||||||
@@ -169,20 +156,8 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC, fo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Compressor idle: release SG-Ready early if heat pump stopped ---
|
// --- Compressor idle: release SG-Ready early if heat pump stopped ---
|
||||||
// A startup grace period is applied after activation: the heat pump compressor
|
|
||||||
// takes several minutes to start after receiving the SG-Ready signal, so idle
|
|
||||||
// detection is suppressed until the grace period has elapsed.
|
|
||||||
if cs := e.consumers[ConsumerSGReady]; cs.Active {
|
if cs := e.consumers[ConsumerSGReady]; cs.Active {
|
||||||
startupMin := e.cfg.Consumers.SGReadyStartupMin
|
if state.CompressorPowerW < float64(e.cfg.Consumers.CompressorIdleW) {
|
||||||
inGrace := startupMin > 0 && !cs.ActivatedAt.IsZero() && now.Sub(cs.ActivatedAt) < time.Duration(startupMin)*time.Minute
|
|
||||||
|
|
||||||
if inGrace {
|
|
||||||
e.logger.Debug("SG-Ready: startup grace period, skipping idle check",
|
|
||||||
"activated_at", cs.ActivatedAt.Format("15:04"),
|
|
||||||
"grace_min", startupMin,
|
|
||||||
"elapsed_min", int(now.Sub(cs.ActivatedAt).Minutes()),
|
|
||||||
)
|
|
||||||
} else if state.CompressorPowerW < float64(e.cfg.Consumers.CompressorIdleW) {
|
|
||||||
cs.LowPowerCycles++
|
cs.LowPowerCycles++
|
||||||
e.logger.Debug("SG-Ready: compressor idle cycle",
|
e.logger.Debug("SG-Ready: compressor idle cycle",
|
||||||
"compressor_w", state.CompressorPowerW,
|
"compressor_w", state.CompressorPowerW,
|
||||||
@@ -216,17 +191,9 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC, fo
|
|||||||
e.logger.Info("wallbox released: car not charging",
|
e.logger.Info("wallbox released: car not charging",
|
||||||
"consumer", wb,
|
"consumer", wb,
|
||||||
"low_power_cycles", cs.LowPowerCycles,
|
"low_power_cycles", cs.LowPowerCycles,
|
||||||
"proactive", cs.ProactiveCharging,
|
|
||||||
)
|
)
|
||||||
cs.Active = false
|
cs.Active = false
|
||||||
cs.ProactiveCharging = false
|
|
||||||
cs.LowPowerCycles = 0
|
cs.LowPowerCycles = 0
|
||||||
// For WallboxA: set retry timeout so proactive logic doesn't immediately re-activate
|
|
||||||
if wb == ConsumerWallboxA && e.cfg.CarCharging.NoCarRetryMin > 0 {
|
|
||||||
e.consumers[ConsumerWallboxA].NoCarRetryUntil = now.Add(
|
|
||||||
time.Duration(e.cfg.CarCharging.NoCarRetryMin) * time.Minute,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
actions = append(actions, Action{
|
actions = append(actions, Action{
|
||||||
Consumer: wb,
|
Consumer: wb,
|
||||||
TurnOn: false,
|
TurnOn: false,
|
||||||
@@ -236,267 +203,51 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC, fo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Turn-on logic (priority order) ---
|
// --- Turn-on logic (priority order) ---
|
||||||
|
if gridW <= 0 {
|
||||||
|
// P1: SG-Ready (heating period only)
|
||||||
|
if heatingPeriod {
|
||||||
|
actions = append(actions, e.evaluateTurnOn(
|
||||||
|
ConsumerSGReady, gridW, e.cfg.Thresholds.SGReadyExportW,
|
||||||
|
allowed, now,
|
||||||
|
)...)
|
||||||
|
}
|
||||||
|
|
||||||
// P1: Proactive car charging — forecast-driven, no export threshold required.
|
// P2: WW boost (time window + forecast required)
|
||||||
// Mutual exclusion (A vs B) is enforced inside evaluateCarCharging.
|
if wwWindow && wwBoostC > 0 {
|
||||||
actions = append(actions, e.evaluateCarCharging(state, forecastKWh, now)...)
|
actions = append(actions, e.evaluateWWTurnOn(gridW, wwBoostC, allowed, now)...)
|
||||||
|
}
|
||||||
|
|
||||||
// P2: WW boost — time window + forecast, no export threshold, no car charging.
|
// P3: Wallbox A (2kW, single-phase) — only if Wallbox B is not active.
|
||||||
// Only runs if no wallbox is active (car charging takes priority).
|
// Uses per-phase export check if available, otherwise falls back to total.
|
||||||
if wwWindow && wwBoostC > 0 &&
|
// Re-reads Active state directly so a same-cycle activation of WallboxB blocks WallboxA.
|
||||||
!e.consumers[ConsumerWallboxA].Active && !e.consumers[ConsumerWallboxB].Active {
|
if !e.consumers[ConsumerWallboxB].Active {
|
||||||
actions = append(actions, e.evaluateWWTurnOn(state, wwBoostC, allowed, now)...)
|
phaseGridW := gridW // fallback: total grid power
|
||||||
}
|
if state.PhaseL1PowerW != 0 || state.PhaseL2PowerW != 0 || state.PhaseL3PowerW != 0 {
|
||||||
|
phaseGridW = min3(state.PhaseL1PowerW, state.PhaseL2PowerW, state.PhaseL3PowerW)
|
||||||
|
}
|
||||||
|
threshold := e.cfg.Thresholds.WallboxAExportW
|
||||||
|
if e.cfg.Thresholds.WallboxAPhaseExportW != 0 {
|
||||||
|
threshold = e.cfg.Thresholds.WallboxAPhaseExportW
|
||||||
|
}
|
||||||
|
actions = append(actions, e.evaluateTurnOn(
|
||||||
|
ConsumerWallboxA, phaseGridW, threshold,
|
||||||
|
allowed, now,
|
||||||
|
)...)
|
||||||
|
}
|
||||||
|
|
||||||
// P3: SG-Ready — reactive, export-threshold based, heating period only.
|
// P4: Wallbox B (4kW, 3-phase) — only if Wallbox A is not active.
|
||||||
// Only runs if no wallbox is active (car charging takes priority).
|
// Re-reads Active state so a same-cycle activation of WallboxA blocks WallboxB.
|
||||||
if heatingPeriod &&
|
if !e.consumers[ConsumerWallboxA].Active {
|
||||||
!e.consumers[ConsumerWallboxA].Active && !e.consumers[ConsumerWallboxB].Active {
|
actions = append(actions, e.evaluateTurnOn(
|
||||||
actions = append(actions, e.evaluateTurnOn(
|
ConsumerWallboxB, gridW, e.cfg.Thresholds.WallboxBExportW,
|
||||||
ConsumerSGReady, gridW, e.cfg.Thresholds.SGReadyExportW,
|
allowed, now,
|
||||||
allowed, now,
|
)...)
|
||||||
)...)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return actions
|
return actions
|
||||||
}
|
}
|
||||||
|
|
||||||
// evaluateCarCharging implements proactive forecast-driven car charging.
|
|
||||||
// Tries WallboxA first (has PM for car detection), then WallboxB (grid-delta probe).
|
|
||||||
// Does not require export surplus — just sufficient PV production and a good forecast.
|
|
||||||
func (e *Engine) evaluateCarCharging(state collector.SystemState, forecastKWh float64, now time.Time) []Action {
|
|
||||||
cc := e.cfg.CarCharging
|
|
||||||
|
|
||||||
// Skip if proactive charging is not configured (thresholds must be set)
|
|
||||||
if cc.PVThresholdAW == 0 && cc.PVThresholdBW == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine activation gate: either forecast is good, or SOC is already high
|
|
||||||
// (battery full → activate regardless of forecast; any PV surplus should charge the car).
|
|
||||||
socHighBypass := float64(cc.SOCHighBypassPct)
|
|
||||||
if socHighBypass == 0 {
|
|
||||||
socHighBypass = 90 // default: bypass forecast check when battery ≥ 90%
|
|
||||||
}
|
|
||||||
forecastOK := forecastKWh > 0 && forecastKWh >= float64(e.cfg.Strategic.ForecastMidKWh)
|
|
||||||
socHigh := state.BatterySOC >= socHighBypass
|
|
||||||
|
|
||||||
if !forecastOK && !socHigh {
|
|
||||||
e.logger.Debug("proactive car charging: skipped",
|
|
||||||
"forecast_kwh", forecastKWh,
|
|
||||||
"forecast_threshold", e.cfg.Strategic.ForecastMidKWh,
|
|
||||||
"soc", state.BatterySOC,
|
|
||||||
"soc_bypass_pct", socHighBypass,
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SOC must be above the minimum for proactive charging
|
|
||||||
if state.BatterySOC < float64(cc.MinSOC) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
csA := e.consumers[ConsumerWallboxA]
|
|
||||||
csB := e.consumers[ConsumerWallboxB]
|
|
||||||
|
|
||||||
// --- Try WallboxA (has PM, preferred) ---
|
|
||||||
if !csA.Active && !csB.Active {
|
|
||||||
// Respect no-car retry timeout (set after idle-cycles detection)
|
|
||||||
if !csA.NoCarRetryUntil.IsZero() && now.Before(csA.NoCarRetryUntil) {
|
|
||||||
e.logger.Debug("WallboxA proactive: skipping, in no-car retry window",
|
|
||||||
"retry_until", csA.NoCarRetryUntil.Format("15:04"),
|
|
||||||
)
|
|
||||||
// Fall through to WallboxB below
|
|
||||||
} else if state.PVProductionW >= cc.PVThresholdAW {
|
|
||||||
// Respect manual override
|
|
||||||
if csA.ManualOverride && now.Before(csA.OverrideUntil) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
e.logger.Info("proactive: activating WallboxA",
|
|
||||||
"pv_w", state.PVProductionW,
|
|
||||||
"threshold_w", cc.PVThresholdAW,
|
|
||||||
"soc", state.BatterySOC,
|
|
||||||
"forecast_kwh", forecastKWh,
|
|
||||||
)
|
|
||||||
csA.Active = true
|
|
||||||
csA.ActivatedAt = now
|
|
||||||
csA.ProactiveCharging = true
|
|
||||||
csA.LowPowerCycles = 0
|
|
||||||
delete(e.hyst.ExportSinceAbove, ConsumerWallboxA)
|
|
||||||
trigger := fmt.Sprintf("forecast %.1fkWh", forecastKWh)
|
|
||||||
if socHigh && !forecastOK {
|
|
||||||
trigger = fmt.Sprintf("SOC %.0f%% ≥ %.0f%% (bypass)", state.BatterySOC, socHighBypass)
|
|
||||||
}
|
|
||||||
return []Action{{
|
|
||||||
Consumer: ConsumerWallboxA,
|
|
||||||
TurnOn: true,
|
|
||||||
Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, %s", state.PVProductionW, cc.PVThresholdAW, trigger),
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Try WallboxB (no PM, uses grid-delta probe) ---
|
|
||||||
// Only if WallboxA is not active and B is not already running.
|
|
||||||
if !csA.Active && !csB.Active {
|
|
||||||
if csB.ManualOverride && now.Before(csB.OverrideUntil) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !csB.NoCarRetryUntil.IsZero() && now.Before(csB.NoCarRetryUntil) {
|
|
||||||
e.logger.Debug("WallboxB proactive: skipping, in no-car retry window",
|
|
||||||
"retry_until", csB.NoCarRetryUntil.Format("15:04"),
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if state.PVProductionW >= cc.PVThresholdBW {
|
|
||||||
e.logger.Info("proactive: activating WallboxB (grid-delta probe)",
|
|
||||||
"pv_w", state.PVProductionW,
|
|
||||||
"threshold_w", cc.PVThresholdBW,
|
|
||||||
"soc", state.BatterySOC,
|
|
||||||
"forecast_kwh", forecastKWh,
|
|
||||||
)
|
|
||||||
csB.Active = true
|
|
||||||
csB.ActivatedAt = now
|
|
||||||
csB.ProactiveCharging = true
|
|
||||||
csB.ProbeStartGridW = state.GridPowerW
|
|
||||||
csB.LowPowerCycles = 0
|
|
||||||
delete(e.hyst.ExportSinceAbove, ConsumerWallboxB)
|
|
||||||
trigger := fmt.Sprintf("forecast %.1fkWh", forecastKWh)
|
|
||||||
if socHigh && !forecastOK {
|
|
||||||
trigger = fmt.Sprintf("SOC %.0f%% ≥ %.0f%% (bypass)", state.BatterySOC, socHighBypass)
|
|
||||||
}
|
|
||||||
return []Action{{
|
|
||||||
Consumer: ConsumerWallboxB,
|
|
||||||
TurnOn: true,
|
|
||||||
Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, %s", state.PVProductionW, cc.PVThresholdBW, trigger),
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkWallboxBProbe checks whether a car is actually charging on WallboxB after activation.
|
|
||||||
// WallboxB has no PM, so we use grid-delta: if the grid power hasn't shifted by at least
|
|
||||||
// GridDeltaThreshW within the probe window, no car is connected → deactivate and set retry.
|
|
||||||
func (e *Engine) checkWallboxBProbe(state collector.SystemState, now time.Time) []Action {
|
|
||||||
cs := e.consumers[ConsumerWallboxB]
|
|
||||||
if !cs.Active || !cs.ProactiveCharging || cs.ActivatedAt.IsZero() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cc := e.cfg.CarCharging
|
|
||||||
probeDuration := e.cfg.Hysteresis.MinRuntimeWallboxParsed() / 2 // half the min runtime
|
|
||||||
if now.Sub(cs.ActivatedAt) < probeDuration {
|
|
||||||
return nil // probe window not reached yet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Already probed if ProbeStartGridW is zero after first probe (reset after detection)
|
|
||||||
if cs.ProbeStartGridW == 0 {
|
|
||||||
return nil // already concluded
|
|
||||||
}
|
|
||||||
|
|
||||||
gridDelta := cs.ProbeStartGridW - state.GridPowerW // negative = more import = car charging
|
|
||||||
// A 4kW wallbox causes a grid shift of ~4000W (or large export reduction).
|
|
||||||
// Positive gridDelta means we're importing more than at probe start (expected with car charging).
|
|
||||||
// We check: grid increased by at least threshold (car drawing power).
|
|
||||||
if gridDelta >= cc.GridDeltaThreshW || -gridDelta >= cc.GridDeltaThreshW {
|
|
||||||
// Either significantly more import or less export = car detected
|
|
||||||
e.logger.Info("WallboxB probe: car detected via grid delta",
|
|
||||||
"probe_start_w", cs.ProbeStartGridW,
|
|
||||||
"current_w", state.GridPowerW,
|
|
||||||
"delta_w", gridDelta,
|
|
||||||
)
|
|
||||||
cs.ProbeStartGridW = 0 // mark probe as concluded
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// No meaningful grid shift → no car connected
|
|
||||||
e.logger.Info("WallboxB probe: no car detected, deactivating",
|
|
||||||
"probe_start_w", cs.ProbeStartGridW,
|
|
||||||
"current_w", state.GridPowerW,
|
|
||||||
"delta_w", gridDelta,
|
|
||||||
"threshold_w", cc.GridDeltaThreshW,
|
|
||||||
)
|
|
||||||
cs.Active = false
|
|
||||||
cs.ProactiveCharging = false
|
|
||||||
cs.ProbeStartGridW = 0
|
|
||||||
if cc.NoCarRetryMin > 0 {
|
|
||||||
cs.NoCarRetryUntil = now.Add(time.Duration(cc.NoCarRetryMin) * time.Minute)
|
|
||||||
}
|
|
||||||
return []Action{{
|
|
||||||
Consumer: ConsumerWallboxB,
|
|
||||||
TurnOn: false,
|
|
||||||
Reason: fmt.Sprintf("no-car probe: grid delta %.0fW < %.0fW", gridDelta, cc.GridDeltaThreshW),
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkEODSoftStop implements the end-of-day battery protection.
|
|
||||||
// After CarCharging.EODTime, if the remaining estimated PV production is insufficient
|
|
||||||
// to fill the battery to the EOD target by sunset, proactive car charging is stopped.
|
|
||||||
func (e *Engine) checkEODSoftStop(state collector.SystemState, now time.Time, sunsetTime time.Time) []Action {
|
|
||||||
cc := e.cfg.CarCharging
|
|
||||||
if cc.EODTime == "" || cc.EODSOCTarget == 0 || e.cfg.Battery.CapacityKWh == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
eodTime := cc.EODTimeParsed(now)
|
|
||||||
if now.Before(eodTime) {
|
|
||||||
return nil // too early for EOD check
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine sunset reference
|
|
||||||
if sunsetTime.IsZero() || sunsetTime.Before(now) {
|
|
||||||
return nil // no valid sunset time, skip
|
|
||||||
}
|
|
||||||
|
|
||||||
hoursToSunset := sunsetTime.Sub(now).Hours()
|
|
||||||
if hoursToSunset <= 0 {
|
|
||||||
hoursToSunset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Estimate remaining PV production (current watt × hours to sunset)
|
|
||||||
remainingPVkWh := (state.PVProductionW / 1000.0) * hoursToSunset
|
|
||||||
|
|
||||||
// Battery energy needed to reach target SOC
|
|
||||||
socDeficitKWh := (float64(cc.EODSOCTarget)/100.0 - state.BatterySOC/100.0) * e.cfg.Battery.CapacityKWh
|
|
||||||
if socDeficitKWh <= 0 {
|
|
||||||
return nil // already at or above target SOC
|
|
||||||
}
|
|
||||||
|
|
||||||
// House base load consumption during remaining time
|
|
||||||
houseKWh := (e.cfg.Forecast.BaseLoadW / 1000.0) * hoursToSunset
|
|
||||||
|
|
||||||
// If remaining PV can't cover the battery deficit plus house load, stop charging
|
|
||||||
if remainingPVkWh >= socDeficitKWh+houseKWh {
|
|
||||||
return nil // enough PV remaining
|
|
||||||
}
|
|
||||||
|
|
||||||
e.logger.Info("EOD soft stop: remaining PV insufficient to reach target SOC",
|
|
||||||
"remaining_pv_kwh", fmt.Sprintf("%.2f", remainingPVkWh),
|
|
||||||
"soc_deficit_kwh", fmt.Sprintf("%.2f", socDeficitKWh),
|
|
||||||
"house_kwh", fmt.Sprintf("%.2f", houseKWh),
|
|
||||||
"hours_to_sunset", fmt.Sprintf("%.1f", hoursToSunset),
|
|
||||||
"current_soc", state.BatterySOC,
|
|
||||||
"target_soc", cc.EODSOCTarget,
|
|
||||||
)
|
|
||||||
|
|
||||||
var stopActions []Action
|
|
||||||
for _, wb := range []Consumer{ConsumerWallboxB, ConsumerWallboxA} {
|
|
||||||
cs := e.consumers[wb]
|
|
||||||
if !cs.Active || !cs.ProactiveCharging {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cs.Active = false
|
|
||||||
cs.ProactiveCharging = false
|
|
||||||
stopActions = append(stopActions, Action{
|
|
||||||
Consumer: wb,
|
|
||||||
TurnOn: false,
|
|
||||||
Reason: fmt.Sprintf("EOD soft stop: %.1fkWh PV remaining < %.1fkWh needed", remainingPVkWh, socDeficitKWh+houseKWh),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return stopActions
|
|
||||||
}
|
|
||||||
|
|
||||||
// min3 returns the minimum of three float64 values.
|
// min3 returns the minimum of three float64 values.
|
||||||
func min3(a, b, c float64) float64 {
|
func min3(a, b, c float64) float64 {
|
||||||
if b < a {
|
if b < a {
|
||||||
@@ -579,7 +330,7 @@ func (e *Engine) evaluateTurnOn(
|
|||||||
|
|
||||||
// All conditions met — turn on
|
// All conditions met — turn on
|
||||||
e.logger.Info("turning on consumer",
|
e.logger.Info("turning on consumer",
|
||||||
"consumer", consumer.String(),
|
"consumer", consumer,
|
||||||
"grid_w", gridW,
|
"grid_w", gridW,
|
||||||
"threshold", threshold,
|
"threshold", threshold,
|
||||||
"export_duration", exportDuration,
|
"export_duration", exportDuration,
|
||||||
@@ -601,8 +352,7 @@ func (e *Engine) evaluateTurnOn(
|
|||||||
|
|
||||||
// shutdownLastConsumer turns off the lowest-priority active consumer
|
// shutdownLastConsumer turns off the lowest-priority active consumer
|
||||||
// that has exceeded its minimum runtime.
|
// that has exceeded its minimum runtime.
|
||||||
// gridW is the current grid power (positive = import) used to check per-consumer import tolerance.
|
func (e *Engine) shutdownLastConsumer(now time.Time) *Action {
|
||||||
func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
|
|
||||||
// Reverse priority: WallboxB → WallboxA → WW → SGReady
|
// Reverse priority: WallboxB → WallboxA → WW → SGReady
|
||||||
order := []Consumer{ConsumerWallboxB, ConsumerWallboxA, ConsumerWW, ConsumerSGReady}
|
order := []Consumer{ConsumerWallboxB, ConsumerWallboxA, ConsumerWW, ConsumerSGReady}
|
||||||
|
|
||||||
@@ -615,42 +365,17 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
|
|||||||
// Manually overridden to ON — don't shut down until override expires
|
// Manually overridden to ON — don't shut down until override expires
|
||||||
if cs.ManualOverride && now.Before(cs.OverrideUntil) {
|
if cs.ManualOverride && now.Before(cs.OverrideUntil) {
|
||||||
e.logger.Debug("skipping shutdown, consumer is manually overridden",
|
e.logger.Debug("skipping shutdown, consumer is manually overridden",
|
||||||
"consumer", c.String(),
|
"consumer", c,
|
||||||
"override_until", cs.OverrideUntil.Format("15:04"),
|
"override_until", cs.OverrideUntil.Format("15:04"),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proactive wallboxes are not shut down by import hysteresis.
|
|
||||||
// Their stops are handled by EOD soft stop, no-car probe, and idle cycles.
|
|
||||||
if cs.ProactiveCharging && (c == ConsumerWallboxA || c == ConsumerWallboxB) {
|
|
||||||
e.logger.Debug("skipping shutdown, proactive car charging active", "consumer", c.String())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-consumer accepted import tolerance: if the current import is within
|
|
||||||
// the configured tolerance for this wallbox, skip shutdown.
|
|
||||||
var acceptedImportW float64
|
|
||||||
switch c {
|
|
||||||
case ConsumerWallboxA:
|
|
||||||
acceptedImportW = e.cfg.Consumers.WallboxAAcceptedImportW
|
|
||||||
case ConsumerWallboxB:
|
|
||||||
acceptedImportW = e.cfg.Consumers.WallboxBAcceptedImportW
|
|
||||||
}
|
|
||||||
if acceptedImportW > 0 && gridW <= acceptedImportW {
|
|
||||||
e.logger.Debug("skipping shutdown, import within accepted tolerance",
|
|
||||||
"consumer", c.String(),
|
|
||||||
"grid_w", gridW,
|
|
||||||
"accepted_import_w", acceptedImportW,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
minRuntime := e.minRuntime(c)
|
minRuntime := e.minRuntime(c)
|
||||||
runtime := now.Sub(cs.ActivatedAt)
|
runtime := now.Sub(cs.ActivatedAt)
|
||||||
if runtime < minRuntime {
|
if runtime < minRuntime {
|
||||||
e.logger.Debug("skipping shutdown, min runtime not reached",
|
e.logger.Debug("skipping shutdown, min runtime not reached",
|
||||||
"consumer", c.String(),
|
"consumer", c,
|
||||||
"runtime", runtime,
|
"runtime", runtime,
|
||||||
"min_runtime", minRuntime,
|
"min_runtime", minRuntime,
|
||||||
)
|
)
|
||||||
@@ -658,7 +383,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
|
|||||||
}
|
}
|
||||||
|
|
||||||
e.logger.Info("shutting down consumer",
|
e.logger.Info("shutting down consumer",
|
||||||
"consumer", c.String(),
|
"consumer", c,
|
||||||
"runtime", runtime,
|
"runtime", runtime,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -679,8 +404,6 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
|
|||||||
|
|
||||||
// socEmergencyBrake immediately shuts off consumers whose SOC threshold
|
// socEmergencyBrake immediately shuts off consumers whose SOC threshold
|
||||||
// is no longer met, ignoring minimum runtimes and manual overrides.
|
// is no longer met, ignoring minimum runtimes and manual overrides.
|
||||||
// Proactive car-charging wallboxes use the SOCFloor threshold instead of the
|
|
||||||
// standard SOC gates, allowing charging down to a lower limit during solar hours.
|
|
||||||
func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
|
func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
|
||||||
var actions []Action
|
var actions []Action
|
||||||
allowed := e.allowedConsumers(soc)
|
allowed := e.allowedConsumers(soc)
|
||||||
@@ -689,25 +412,12 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
|
|||||||
if !cs.Active {
|
if !cs.Active {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intentional wallbox charging (proactive or trip mode): only emergency-brake at SOCFloor.
|
|
||||||
// Trip mode uses ManualOverride=true, proactive charging uses ProactiveCharging=true —
|
|
||||||
// both deserve soc_floor protection instead of the standard block_all gate.
|
|
||||||
isIntentionalCharging := (cs.ProactiveCharging || cs.ManualOverride) &&
|
|
||||||
(c == ConsumerWallboxA || c == ConsumerWallboxB)
|
|
||||||
if isIntentionalCharging {
|
|
||||||
floor := float64(e.cfg.CarCharging.SOCFloor)
|
|
||||||
if floor > 0 && soc >= floor {
|
|
||||||
continue // still above floor, keep charging
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if allowed[c] {
|
if allowed[c] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
e.logger.Warn("SOC emergency brake",
|
e.logger.Warn("SOC emergency brake",
|
||||||
"consumer", c.String(),
|
"consumer", c,
|
||||||
"soc", soc,
|
"soc", soc,
|
||||||
"was_override", cs.ManualOverride,
|
"was_override", cs.ManualOverride,
|
||||||
)
|
)
|
||||||
@@ -715,7 +425,6 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
|
|||||||
cs.Active = false
|
cs.Active = false
|
||||||
cs.ManualOverride = false // EMS takes back full control after emergency
|
cs.ManualOverride = false // EMS takes back full control after emergency
|
||||||
cs.OverrideUntil = time.Time{}
|
cs.OverrideUntil = time.Time{}
|
||||||
cs.ProactiveCharging = false
|
|
||||||
a := Action{
|
a := Action{
|
||||||
Consumer: c,
|
Consumer: c,
|
||||||
TurnOn: false,
|
TurnOn: false,
|
||||||
@@ -746,7 +455,7 @@ func (e *Engine) overrideHardStop(gridW float64) []Action {
|
|||||||
}
|
}
|
||||||
|
|
||||||
e.logger.Warn("override hard stop: import exceeds limit",
|
e.logger.Warn("override hard stop: import exceeds limit",
|
||||||
"consumer", c.String(),
|
"consumer", c,
|
||||||
"grid_w", gridW,
|
"grid_w", gridW,
|
||||||
"limit_w", limit,
|
"limit_w", limit,
|
||||||
)
|
)
|
||||||
@@ -786,41 +495,13 @@ func (e *Engine) ApplyOverride(consumer Consumer, on bool, duration time.Duratio
|
|||||||
cs.ActivatedAt = time.Time{}
|
cs.ActivatedAt = time.Time{}
|
||||||
}
|
}
|
||||||
e.logger.Info("manual override applied",
|
e.logger.Info("manual override applied",
|
||||||
"consumer", consumer.String(),
|
"consumer", consumer,
|
||||||
"on", on,
|
"on", on,
|
||||||
"duration", duration,
|
"duration", duration,
|
||||||
"until", cs.OverrideUntil.Format("15:04"),
|
"until", cs.OverrideUntil.Format("15:04"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RollbackAction reverts the engine's internal state for an action that the actuator
|
|
||||||
// failed to execute. Without this, the engine believes the switch happened, diverges
|
|
||||||
// from hardware, and SyncHardwareState will misinterpret the next read-back as a
|
|
||||||
// manual override and apply a 1-hour lockout.
|
|
||||||
//
|
|
||||||
// After rollback the engine state matches hardware again, so the next cycle's
|
|
||||||
// SyncHardwareState sees no mismatch and the action is simply retried.
|
|
||||||
func (e *Engine) RollbackAction(action Action) {
|
|
||||||
cs, ok := e.consumers[action.Consumer]
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.logger.Warn("rolling back engine state after failed action",
|
|
||||||
"consumer", action.Consumer,
|
|
||||||
"turn_on", action.TurnOn,
|
|
||||||
)
|
|
||||||
cs.Active = !action.TurnOn
|
|
||||||
if action.TurnOn {
|
|
||||||
// Turn-on failed: undo activation side-effects
|
|
||||||
cs.ActivatedAt = time.Time{}
|
|
||||||
cs.ProactiveCharging = false
|
|
||||||
cs.ProbeStartGridW = 0
|
|
||||||
cs.LowPowerCycles = 0
|
|
||||||
}
|
|
||||||
// Turn-off failed: just restore Active=true. ActivatedAt is preserved
|
|
||||||
// (shutdown code doesn't reset it), so min-runtime stays correct.
|
|
||||||
}
|
|
||||||
|
|
||||||
// isHeatingPeriod returns true if heating is appropriate given the current month
|
// isHeatingPeriod returns true if heating is appropriate given the current month
|
||||||
// and outdoor temperature. If HeatingMinAmbientC is configured (> 0), ambient
|
// and outdoor temperature. If HeatingMinAmbientC is configured (> 0), ambient
|
||||||
// temperatures above that threshold suppress SG-Ready even within the heating months.
|
// temperatures above that threshold suppress SG-Ready even within the heating months.
|
||||||
@@ -867,51 +548,50 @@ func (e *Engine) minRuntime(c Consumer) time.Duration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// evaluateWWTurnOn checks whether WW boost should be activated.
|
// evaluateWWTurnOn checks whether WW boost should be activated.
|
||||||
// Prerequisites (time window, forecast > 0, no car charging) are already verified by the caller.
|
// Prerequisites (time window and forecast) are already verified by the caller.
|
||||||
// No export threshold is required — the heat pump compressor load is covered by PV.
|
func (e *Engine) evaluateWWTurnOn(gridW, wwBoostC float64, allowed map[Consumer]bool, now time.Time) []Action {
|
||||||
// Dynamic setpoint: current tank top + hysteresis + boost delta, capped at WWMaxSetpointC.
|
|
||||||
func (e *Engine) evaluateWWTurnOn(state collector.SystemState, wwBoostC float64, allowed map[Consumer]bool, now time.Time) []Action {
|
|
||||||
cs := e.consumers[ConsumerWW]
|
cs := e.consumers[ConsumerWW]
|
||||||
if cs.Active {
|
if cs.Active {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if cs.ManualOverride && now.Before(cs.OverrideUntil) {
|
if cs.ManualOverride && now.Before(cs.OverrideUntil) {
|
||||||
|
delete(e.hyst.ExportSinceAbove, ConsumerWW)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !allowed[ConsumerWW] {
|
if !allowed[ConsumerWW] {
|
||||||
|
delete(e.hyst.ExportSinceAbove, ConsumerWW)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if gridW > e.cfg.Thresholds.WWExportW {
|
||||||
|
delete(e.hyst.ExportSinceAbove, ConsumerWW)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic setpoint: set high enough above current temp to trigger heating immediately.
|
if _, ok := e.hyst.ExportSinceAbove[ConsumerWW]; !ok {
|
||||||
// Viessmann switchOn threshold = setpoint - hysteresis.
|
e.hyst.ExportSinceAbove[ConsumerWW] = now
|
||||||
// Target = current_top + hysteresis + boost_delta, capped at max.
|
|
||||||
hysteresis := e.cfg.Strategic.WWHysteresisC
|
|
||||||
if hysteresis == 0 {
|
|
||||||
hysteresis = 5 // safe default
|
|
||||||
}
|
}
|
||||||
maxSetpoint := e.cfg.Strategic.WWMaxSetpointC
|
exportDuration := now.Sub(e.hyst.ExportSinceAbove[ConsumerWW])
|
||||||
if maxSetpoint == 0 {
|
if exportDuration < e.cfg.Hysteresis.ExportOnDurationParsed() {
|
||||||
maxSetpoint = 60 // safe default
|
return nil
|
||||||
}
|
|
||||||
targetTemp := state.WWTopTempC + hysteresis + wwBoostC
|
|
||||||
if targetTemp > maxSetpoint {
|
|
||||||
targetTemp = maxSetpoint
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
targetTemp := e.cfg.Strategic.WWBaseC + wwBoostC
|
||||||
e.logger.Info("activating WW boost",
|
e.logger.Info("activating WW boost",
|
||||||
"ww_top_c", state.WWTopTempC,
|
"grid_w", gridW,
|
||||||
"ww_boost_c", wwBoostC,
|
"ww_boost_c", wwBoostC,
|
||||||
"target_temp_c", targetTemp,
|
"target_temp_c", targetTemp,
|
||||||
|
"export_duration", exportDuration,
|
||||||
)
|
)
|
||||||
|
|
||||||
cs.Active = true
|
cs.Active = true
|
||||||
cs.ActivatedAt = now
|
cs.ActivatedAt = now
|
||||||
|
delete(e.hyst.ExportSinceAbove, ConsumerWW)
|
||||||
|
|
||||||
return []Action{{
|
return []Action{{
|
||||||
Consumer: ConsumerWW,
|
Consumer: ConsumerWW,
|
||||||
TurnOn: true,
|
TurnOn: true,
|
||||||
TargetTempC: targetTemp,
|
TargetTempC: targetTemp,
|
||||||
Reason: fmt.Sprintf("WW boost +%.0f°C → setpoint %.0f°C (tank %.0f°C)", wwBoostC, targetTemp, state.WWTopTempC),
|
Reason: fmt.Sprintf("export %.0fW for %s, WW boost +%.0f°C", -gridW, exportDuration, wwBoostC),
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -971,10 +651,7 @@ func (e *Engine) RecoverState(states map[Consumer]DeviceStatus) {
|
|||||||
// On match: expired overrides are cleared, resuming normal EMS control.
|
// On match: expired overrides are cleared, resuming normal EMS control.
|
||||||
// Power readings (from PM-capable devices) update the low-power cycle counter for
|
// Power readings (from PM-capable devices) update the low-power cycle counter for
|
||||||
// car-not-charging detection.
|
// car-not-charging detection.
|
||||||
// When monitorOnly is true, hardware mismatches silently update engine state without
|
func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Time, overrideTimeout time.Duration) {
|
||||||
// logging or applying override lockouts — the EMS cannot act anyway, so treating
|
|
||||||
// every missed switch as a "manual override" would produce spurious log spam.
|
|
||||||
func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Time, overrideTimeout time.Duration, monitorOnly bool) {
|
|
||||||
for c, status := range states {
|
for c, status := range states {
|
||||||
cs, ok := e.consumers[c]
|
cs, ok := e.consumers[c]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -982,39 +659,27 @@ func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Ti
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cs.Active != status.On {
|
if cs.Active != status.On {
|
||||||
if monitorOnly {
|
// External change detected
|
||||||
// In monitor-only mode the EMS cannot execute switch actions, so a
|
e.logger.Info("manual override detected — external state change",
|
||||||
// mismatch just means a pending action couldn't be carried out.
|
"consumer", c,
|
||||||
// Silently realign engine state to hardware without locking.
|
"engine_state", cs.Active,
|
||||||
cs.Active = status.On
|
"hardware_state", status.On,
|
||||||
if !status.On {
|
"override_until", now.Add(overrideTimeout).Format("15:04"),
|
||||||
cs.ActivatedAt = time.Time{}
|
)
|
||||||
} else {
|
cs.Active = status.On
|
||||||
cs.ActivatedAt = now
|
cs.ManualOverride = true
|
||||||
}
|
cs.OverrideUntil = now.Add(overrideTimeout)
|
||||||
|
cs.LowPowerCycles = 0
|
||||||
|
if !status.On {
|
||||||
|
cs.ActivatedAt = time.Time{}
|
||||||
} else {
|
} else {
|
||||||
// External change detected — log and apply override lockout
|
cs.ActivatedAt = now
|
||||||
e.logger.Info("manual override detected — external state change",
|
|
||||||
"consumer", c.String(),
|
|
||||||
"engine_state", cs.Active,
|
|
||||||
"hardware_state", status.On,
|
|
||||||
"override_until", now.Add(overrideTimeout).Format("15:04"),
|
|
||||||
)
|
|
||||||
cs.Active = status.On
|
|
||||||
cs.ManualOverride = true
|
|
||||||
cs.OverrideUntil = now.Add(overrideTimeout)
|
|
||||||
cs.LowPowerCycles = 0
|
|
||||||
if !status.On {
|
|
||||||
cs.ActivatedAt = time.Time{}
|
|
||||||
} else {
|
|
||||||
cs.ActivatedAt = now
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if cs.ManualOverride && now.After(cs.OverrideUntil) {
|
} else if cs.ManualOverride && now.After(cs.OverrideUntil) {
|
||||||
// Override expired and state matches — resume EMS control
|
// Override expired and state matches — resume EMS control
|
||||||
cs.ManualOverride = false
|
cs.ManualOverride = false
|
||||||
cs.OverrideUntil = time.Time{}
|
cs.OverrideUntil = time.Time{}
|
||||||
e.logger.Info("manual override expired, resuming EMS control", "consumer", c.String())
|
e.logger.Info("manual override expired, resuming EMS control", "consumer", c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track low-power cycles for car-not-charging detection (PM devices only).
|
// Track low-power cycles for car-not-charging detection (PM devices only).
|
||||||
@@ -1023,7 +688,7 @@ func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Ti
|
|||||||
if cs.Active && status.PowerW < float64(e.cfg.Consumers.WallboxMinChargeW) {
|
if cs.Active && status.PowerW < float64(e.cfg.Consumers.WallboxMinChargeW) {
|
||||||
cs.LowPowerCycles++
|
cs.LowPowerCycles++
|
||||||
e.logger.Debug("wallbox low power cycle",
|
e.logger.Debug("wallbox low power cycle",
|
||||||
"consumer", c.String(),
|
"consumer", c,
|
||||||
"power_w", status.PowerW,
|
"power_w", status.PowerW,
|
||||||
"low_power_cycles", cs.LowPowerCycles,
|
"low_power_cycles", cs.LowPowerCycles,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func TestSOCBlocksAll(t *testing.T) {
|
|||||||
BatterySOC: 40, // below 50% → all blocked
|
BatterySOC: 40, // below 50% → all blocked
|
||||||
}
|
}
|
||||||
|
|
||||||
actions := eng.Decide(state, now, 0, 0, time.Time{})
|
actions := eng.Decide(state, now, 0)
|
||||||
if len(actions) != 0 {
|
if len(actions) != 0 {
|
||||||
t.Errorf("expected no actions with SOC 40%%, got %d actions", len(actions))
|
t.Errorf("expected no actions with SOC 40%%, got %d actions", len(actions))
|
||||||
}
|
}
|
||||||
@@ -79,13 +79,13 @@ func TestSOCAllowsSGReady(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// First call — starts hysteresis timer
|
// First call — starts hysteresis timer
|
||||||
actions := eng.Decide(state, base, 0, 0, time.Time{})
|
actions := eng.Decide(state, base, 0)
|
||||||
if len(actions) != 0 {
|
if len(actions) != 0 {
|
||||||
t.Errorf("expected no actions on first call (hysteresis), got %d", len(actions))
|
t.Errorf("expected no actions on first call (hysteresis), got %d", len(actions))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second call after 5 minutes — hysteresis passed
|
// Second call after 5 minutes — hysteresis passed
|
||||||
actions = eng.Decide(state, base.Add(5*time.Minute), 0, 0, time.Time{})
|
actions = eng.Decide(state, base.Add(5*time.Minute), 0)
|
||||||
if len(actions) != 1 {
|
if len(actions) != 1 {
|
||||||
t.Fatalf("expected 1 action after hysteresis, got %d", len(actions))
|
t.Fatalf("expected 1 action after hysteresis, got %d", len(actions))
|
||||||
}
|
}
|
||||||
@@ -107,8 +107,8 @@ func TestSOCBlocksWallboxAt60(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pass hysteresis
|
// Pass hysteresis
|
||||||
eng.Decide(state, base, 0, 0, time.Time{})
|
eng.Decide(state, base, 0)
|
||||||
actions := eng.Decide(state, base.Add(5*time.Minute), 0, 0, time.Time{})
|
actions := eng.Decide(state, base.Add(5*time.Minute), 0)
|
||||||
|
|
||||||
// Should only get SG-Ready, no wallboxes
|
// Should only get SG-Ready, no wallboxes
|
||||||
for _, a := range actions {
|
for _, a := range actions {
|
||||||
@@ -130,8 +130,8 @@ func TestSGReadyOnlyInHeatingPeriod(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pass hysteresis
|
// Pass hysteresis
|
||||||
eng.Decide(state, base, 0, 0, time.Time{})
|
eng.Decide(state, base, 0)
|
||||||
actions := eng.Decide(state, base.Add(5*time.Minute), 0, 0, time.Time{})
|
actions := eng.Decide(state, base.Add(5*time.Minute), 0)
|
||||||
|
|
||||||
for _, a := range actions {
|
for _, a := range actions {
|
||||||
if a.Consumer == ConsumerSGReady {
|
if a.Consumer == ConsumerSGReady {
|
||||||
@@ -149,14 +149,14 @@ func TestSOCEmergencyBrake(t *testing.T) {
|
|||||||
GridPowerW: -600,
|
GridPowerW: -600,
|
||||||
BatterySOC: 95,
|
BatterySOC: 95,
|
||||||
}
|
}
|
||||||
eng.Decide(state, base, 0, 0, time.Time{})
|
eng.Decide(state, base, 0)
|
||||||
eng.Decide(state, base.Add(5*time.Minute), 0, 0, time.Time{})
|
eng.Decide(state, base.Add(5*time.Minute), 0)
|
||||||
|
|
||||||
// Now SOC drops below threshold
|
// Now SOC drops below threshold
|
||||||
state.BatterySOC = 45
|
state.BatterySOC = 45
|
||||||
state.GridPowerW = -600 // still exporting, but SOC is too low
|
state.GridPowerW = -600 // still exporting, but SOC is too low
|
||||||
|
|
||||||
actions := eng.Decide(state, base.Add(10*time.Minute), 0, 0, time.Time{})
|
actions := eng.Decide(state, base.Add(10*time.Minute), 0)
|
||||||
|
|
||||||
foundBrake := false
|
foundBrake := false
|
||||||
for _, a := range actions {
|
for _, a := range actions {
|
||||||
@@ -190,7 +190,7 @@ func TestShutdownReverseOrder(t *testing.T) {
|
|||||||
GridPowerW: 500, // importing
|
GridPowerW: 500, // importing
|
||||||
BatterySOC: 95,
|
BatterySOC: 95,
|
||||||
}
|
}
|
||||||
actions := eng.Decide(state, base, 0, 0, time.Time{})
|
actions := eng.Decide(state, base, 0)
|
||||||
|
|
||||||
if len(actions) == 0 {
|
if len(actions) == 0 {
|
||||||
t.Fatal("expected shutdown action")
|
t.Fatal("expected shutdown action")
|
||||||
@@ -265,28 +265,18 @@ func TestWallboxMutualExclusion(t *testing.T) {
|
|||||||
cfg := testConfig()
|
cfg := testConfig()
|
||||||
cfg.Hysteresis.ExportOnDuration = "0s"
|
cfg.Hysteresis.ExportOnDuration = "0s"
|
||||||
cfg.Hysteresis.ImportOffDuration = "0s"
|
cfg.Hysteresis.ImportOffDuration = "0s"
|
||||||
// Configure proactive car charging
|
|
||||||
cfg.Strategic.ForecastMidKWh = 15
|
|
||||||
cfg.CarCharging = config.CarChargingConfig{
|
|
||||||
MinSOC: 35,
|
|
||||||
SOCFloor: 5,
|
|
||||||
PVThresholdAW: 1000,
|
|
||||||
PVThresholdBW: 2000,
|
|
||||||
}
|
|
||||||
|
|
||||||
eng := NewEngine(cfg, testLogger())
|
eng := NewEngine(cfg, testLogger())
|
||||||
base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC) // summer (no SG-Ready)
|
base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC) // summer (no SG-Ready)
|
||||||
|
|
||||||
// Good solar day — proactive charging should activate WallboxA
|
// Massive export — enough to meet both wallbox thresholds
|
||||||
state := collector.SystemState{
|
state := collector.SystemState{
|
||||||
PVProductionW: 3000, // ≥ PVThresholdA (1000W) and ≥ PVThresholdB (2000W)
|
GridPowerW: -5000,
|
||||||
GridPowerW: -5000,
|
BatterySOC: 95,
|
||||||
BatterySOC: 95,
|
|
||||||
}
|
}
|
||||||
forecastKWh := 20.0 // above ForecastMidKWh
|
|
||||||
|
|
||||||
// First Decide: WallboxA should activate (tried first), WallboxB must be blocked (mutex)
|
// First Decide: WallboxA should activate (P3), WallboxB must be blocked (mutex)
|
||||||
actions := eng.Decide(state, base, 0, forecastKWh, time.Time{})
|
actions := eng.Decide(state, base, 0)
|
||||||
|
|
||||||
var wbAOn, wbBOn bool
|
var wbAOn, wbBOn bool
|
||||||
for _, a := range actions {
|
for _, a := range actions {
|
||||||
@@ -305,7 +295,7 @@ func TestWallboxMutualExclusion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Second Decide with WallboxA still active: WallboxB must still be blocked
|
// Second Decide with WallboxA still active: WallboxB must still be blocked
|
||||||
actions = eng.Decide(state, base.Add(2*time.Minute), 0, forecastKWh, time.Time{})
|
actions = eng.Decide(state, base.Add(2*time.Minute), 0)
|
||||||
for _, a := range actions {
|
for _, a := range actions {
|
||||||
if a.Consumer == ConsumerWallboxB && a.TurnOn {
|
if a.Consumer == ConsumerWallboxB && a.TurnOn {
|
||||||
t.Error("WallboxB must not activate while WallboxA is active (second cycle)")
|
t.Error("WallboxB must not activate while WallboxA is active (second cycle)")
|
||||||
@@ -315,24 +305,16 @@ func TestWallboxMutualExclusion(t *testing.T) {
|
|||||||
|
|
||||||
func TestCarNotChargingReleasesWallbox(t *testing.T) {
|
func TestCarNotChargingReleasesWallbox(t *testing.T) {
|
||||||
cfg := testConfig()
|
cfg := testConfig()
|
||||||
|
cfg.Hysteresis.ExportOnDuration = "0s"
|
||||||
cfg.Consumers.IdleCycles = 3
|
cfg.Consumers.IdleCycles = 3
|
||||||
cfg.Consumers.WallboxMinChargeW = 50
|
cfg.Consumers.WallboxMinChargeW = 50
|
||||||
// Configure proactive car charging
|
|
||||||
cfg.Strategic.ForecastMidKWh = 15
|
|
||||||
cfg.CarCharging = config.CarChargingConfig{
|
|
||||||
MinSOC: 35,
|
|
||||||
SOCFloor: 5,
|
|
||||||
PVThresholdAW: 1000,
|
|
||||||
NoCarRetryMin: 30,
|
|
||||||
}
|
|
||||||
|
|
||||||
eng := NewEngine(cfg, testLogger())
|
eng := NewEngine(cfg, testLogger())
|
||||||
base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC)
|
base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC)
|
||||||
forecastKWh := 20.0
|
|
||||||
|
|
||||||
// Activate WallboxA via proactive charging
|
// Activate WallboxA
|
||||||
state := collector.SystemState{PVProductionW: 2000, GridPowerW: -2000, BatterySOC: 95}
|
state := collector.SystemState{GridPowerW: -2000, BatterySOC: 95}
|
||||||
actions := eng.Decide(state, base, 0, forecastKWh, time.Time{})
|
actions := eng.Decide(state, base, 0)
|
||||||
if len(actions) != 1 || actions[0].Consumer != ConsumerWallboxA || !actions[0].TurnOn {
|
if len(actions) != 1 || actions[0].Consumer != ConsumerWallboxA || !actions[0].TurnOn {
|
||||||
t.Fatalf("expected WallboxA to activate, got %v", actions)
|
t.Fatalf("expected WallboxA to activate, got %v", actions)
|
||||||
}
|
}
|
||||||
@@ -344,12 +326,11 @@ func TestCarNotChargingReleasesWallbox(t *testing.T) {
|
|||||||
map[Consumer]DeviceStatus{ConsumerWallboxA: lowPower},
|
map[Consumer]DeviceStatus{ConsumerWallboxA: lowPower},
|
||||||
base.Add(time.Duration(i+1)*2*time.Minute),
|
base.Add(time.Duration(i+1)*2*time.Minute),
|
||||||
time.Hour,
|
time.Hour,
|
||||||
false,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decide should now release WallboxA
|
// Decide should now release WallboxA
|
||||||
actions = eng.Decide(state, base.Add(8*time.Minute), 0, forecastKWh, time.Time{})
|
actions = eng.Decide(state, base.Add(8*time.Minute), 0)
|
||||||
found := false
|
found := false
|
||||||
for _, a := range actions {
|
for _, a := range actions {
|
||||||
if a.Consumer == ConsumerWallboxA && !a.TurnOn {
|
if a.Consumer == ConsumerWallboxA && !a.TurnOn {
|
||||||
@@ -377,7 +358,7 @@ func TestCompressorIdleReleasesSGReady(t *testing.T) {
|
|||||||
BatterySOC: 95,
|
BatterySOC: 95,
|
||||||
CompressorPowerW: 1500, // compressor running
|
CompressorPowerW: 1500, // compressor running
|
||||||
}
|
}
|
||||||
actions := eng.Decide(state, base, 0, 0, time.Time{})
|
actions := eng.Decide(state, base, 0)
|
||||||
if len(actions) != 1 || actions[0].Consumer != ConsumerSGReady || !actions[0].TurnOn {
|
if len(actions) != 1 || actions[0].Consumer != ConsumerSGReady || !actions[0].TurnOn {
|
||||||
t.Fatalf("expected SG-Ready to activate, got %v", actions)
|
t.Fatalf("expected SG-Ready to activate, got %v", actions)
|
||||||
}
|
}
|
||||||
@@ -385,7 +366,7 @@ func TestCompressorIdleReleasesSGReady(t *testing.T) {
|
|||||||
// Compressor drops to idle — 3 consecutive cycles
|
// Compressor drops to idle — 3 consecutive cycles
|
||||||
state.CompressorPowerW = 10 // below idle threshold
|
state.CompressorPowerW = 10 // below idle threshold
|
||||||
for i := 1; i <= 3; i++ {
|
for i := 1; i <= 3; i++ {
|
||||||
actions = eng.Decide(state, base.Add(time.Duration(i)*2*time.Minute), 0, 0, time.Time{})
|
actions = eng.Decide(state, base.Add(time.Duration(i)*2*time.Minute), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// After 3 idle cycles, SG-Ready should be released despite min-runtime not reached
|
// After 3 idle cycles, SG-Ready should be released despite min-runtime not reached
|
||||||
@@ -402,47 +383,24 @@ func TestCompressorIdleReleasesSGReady(t *testing.T) {
|
|||||||
|
|
||||||
func TestMinRuntimeRespected(t *testing.T) {
|
func TestMinRuntimeRespected(t *testing.T) {
|
||||||
cfg := testConfig()
|
cfg := testConfig()
|
||||||
|
cfg.Hysteresis.ExportOnDuration = "0s"
|
||||||
cfg.Hysteresis.MinRuntimeWallbox = "15m"
|
cfg.Hysteresis.MinRuntimeWallbox = "15m"
|
||||||
cfg.Hysteresis.ImportOffDuration = "0s"
|
|
||||||
cfg.Strategic.ForecastMidKWh = 15
|
|
||||||
cfg.CarCharging = config.CarChargingConfig{
|
|
||||||
MinSOC: 35,
|
|
||||||
SOCFloor: 5,
|
|
||||||
PVThresholdAW: 1000,
|
|
||||||
}
|
|
||||||
|
|
||||||
eng := NewEngine(cfg, testLogger())
|
eng := NewEngine(cfg, testLogger())
|
||||||
base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC) // summer
|
base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC) // summer
|
||||||
|
|
||||||
// Activate WallboxA via proactive charging
|
// Activate Wallbox A
|
||||||
state := collector.SystemState{
|
state := collector.SystemState{
|
||||||
PVProductionW: 2000,
|
GridPowerW: -2000,
|
||||||
GridPowerW: -2000,
|
BatterySOC: 95,
|
||||||
BatterySOC: 95,
|
|
||||||
}
|
}
|
||||||
eng.Decide(state, base, 0, 20.0, time.Time{}) // WallboxA activates
|
eng.Decide(state, base, 0)
|
||||||
|
|
||||||
// Drop PV — now importing; proactive charging is active so import doesn't shut it down.
|
// Try to shutdown after 5 minutes (< 15min minimum)
|
||||||
// But if we test a non-proactive consumer: inject WallboxA as non-proactive via RecoverState,
|
state.GridPowerW = 500
|
||||||
// and verify min-runtime is still respected for import-shutdown path.
|
eng.Decide(state, base.Add(1*time.Minute), 0) // start import timer
|
||||||
// Simpler: use RecoverState with SG-Ready active (min 30m), try to shut down in <30m.
|
|
||||||
eng2 := NewEngine(cfg, testLogger())
|
|
||||||
eng2.RecoverState(map[Consumer]DeviceStatus{
|
|
||||||
ConsumerSGReady: {On: true},
|
|
||||||
})
|
|
||||||
// SG-Ready ActivatedAt is zero (unknown) → treated as exceeding min-runtime, so it can be shut down.
|
|
||||||
// For a real min-runtime test, inject with SyncHardwareState to set ActivatedAt.
|
|
||||||
// Instead, manually set ActivatedAt via ApplyOverride then clear override:
|
|
||||||
cfg2 := testConfig()
|
|
||||||
cfg2.Hysteresis.MinRuntimeWallbox = "15m"
|
|
||||||
cfg2.Hysteresis.ImportOffDuration = "0s"
|
|
||||||
eng3 := NewEngine(cfg2, testLogger())
|
|
||||||
eng3.ApplyOverride(ConsumerWallboxA, true, 0) // turn on, no lock
|
|
||||||
// Reset override flag so import-shutdown applies
|
|
||||||
eng3.consumers[ConsumerWallboxA].ManualOverride = false
|
|
||||||
|
|
||||||
state2 := collector.SystemState{GridPowerW: 500, BatterySOC: 95}
|
actions := eng.Decide(state, base.Add(8*time.Minute), 0) // import for >6min
|
||||||
actions := eng3.Decide(state2, base.Add(5*time.Minute), 0, 0, time.Time{}) // 5min < 15min
|
|
||||||
|
|
||||||
for _, a := range actions {
|
for _, a := range actions {
|
||||||
if a.Consumer == ConsumerWallboxA && !a.TurnOn {
|
if a.Consumer == ConsumerWallboxA && !a.TurnOn {
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
// Package monitor provides a persistent monitor-only mode toggle.
|
|
||||||
// When active, the EMS continues polling and showing the status page
|
|
||||||
// but suppresses all actuator calls (Shelly switches, Viessmann API).
|
|
||||||
// State is stored as a flag file: presence = active, absence = normal.
|
|
||||||
package monitor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Mode is a thread-safe, file-backed monitor-only flag.
|
|
||||||
type Mode struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
active bool
|
|
||||||
filePath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a Mode and loads the current state from the flag file.
|
|
||||||
// If filePath is empty, the mode is in-memory only (not persistent).
|
|
||||||
func New(filePath string) *Mode {
|
|
||||||
m := &Mode{filePath: filePath}
|
|
||||||
if filePath != "" {
|
|
||||||
_, err := os.Stat(filePath)
|
|
||||||
m.active = err == nil // file exists → monitor-only
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsActive reports whether monitor-only mode is currently enabled.
|
|
||||||
func (m *Mode) IsActive() bool {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
return m.active
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set enables or disables monitor-only mode and persists the change to disk.
|
|
||||||
func (m *Mode) Set(active bool) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
m.active = active
|
|
||||||
|
|
||||||
if m.filePath == "" {
|
|
||||||
return nil // in-memory only
|
|
||||||
}
|
|
||||||
|
|
||||||
if active {
|
|
||||||
f, err := os.OpenFile(m.filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return f.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
err := os.Remove(m.filePath)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,6 @@ import (
|
|||||||
"github.com/tb/ems/internal/config"
|
"github.com/tb/ems/internal/config"
|
||||||
"github.com/tb/ems/internal/engine"
|
"github.com/tb/ems/internal/engine"
|
||||||
"github.com/tb/ems/internal/forecast"
|
"github.com/tb/ems/internal/forecast"
|
||||||
"github.com/tb/ems/internal/monitor"
|
|
||||||
"github.com/tb/ems/internal/trip"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// consumerMeta holds static display info for each consumer.
|
// consumerMeta holds static display info for each consumer.
|
||||||
@@ -23,12 +21,12 @@ var consumerMeta = map[engine.Consumer]struct{ Label, Icon string }{
|
|||||||
engine.ConsumerWallboxB: {"Wallbox B (4 kW)", "🔌"},
|
engine.ConsumerWallboxB: {"Wallbox B (4 kW)", "🔌"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// consumerOrder defines the display order of consumers — highest EMS priority first.
|
// consumerOrder defines the display order of consumers.
|
||||||
var consumerOrder = []engine.Consumer{
|
var consumerOrder = []engine.Consumer{
|
||||||
|
engine.ConsumerSGReady,
|
||||||
|
engine.ConsumerWW,
|
||||||
engine.ConsumerWallboxA,
|
engine.ConsumerWallboxA,
|
||||||
engine.ConsumerWallboxB,
|
engine.ConsumerWallboxB,
|
||||||
engine.ConsumerWW,
|
|
||||||
engine.ConsumerSGReady,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// consumerRecord tracks the state of a single consumer across cycles.
|
// consumerRecord tracks the state of a single consumer across cycles.
|
||||||
@@ -41,13 +39,6 @@ type consumerRecord struct {
|
|||||||
livePowerW float64 // from Shelly PM; 0 for non-PM devices
|
livePowerW float64 // from Shelly PM; 0 for non-PM devices
|
||||||
}
|
}
|
||||||
|
|
||||||
// CarOption is a selectable car for the trip mode form.
|
|
||||||
type CarOption struct {
|
|
||||||
Key string
|
|
||||||
Name string
|
|
||||||
BatteryKWh float64
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store holds the latest EMS snapshot and is safe for concurrent use.
|
// Store holds the latest EMS snapshot and is safe for concurrent use.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -57,35 +48,21 @@ type Store struct {
|
|||||||
dryRun bool
|
dryRun bool
|
||||||
wwConfigured bool
|
wwConfigured bool
|
||||||
strategic config.StrategicConfig
|
strategic config.StrategicConfig
|
||||||
carCharging config.CarChargingConfig
|
|
||||||
consumers map[engine.Consumer]*consumerRecord
|
consumers map[engine.Consumer]*consumerRecord
|
||||||
fcResult *forecast.Result
|
fcResult *forecast.Result
|
||||||
monitorMode *monitor.Mode
|
|
||||||
wwBoostDisabled *monitor.Mode
|
|
||||||
tripMgr *trip.Manager
|
|
||||||
carOptions []CarOption
|
|
||||||
csrfToken string
|
|
||||||
hasAuth bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStore creates a new status store.
|
// NewStore creates a new status store.
|
||||||
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, carCharging config.CarChargingConfig, mode *monitor.Mode, wwBoostDisabled *monitor.Mode, tm *trip.Manager, cars []CarOption, csrfToken string, hasAuth bool) *Store {
|
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig) *Store {
|
||||||
consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder))
|
consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder))
|
||||||
for _, c := range consumerOrder {
|
for _, c := range consumerOrder {
|
||||||
consumers[c] = &consumerRecord{}
|
consumers[c] = &consumerRecord{}
|
||||||
}
|
}
|
||||||
return &Store{
|
return &Store{
|
||||||
dryRun: dryRun,
|
dryRun: dryRun,
|
||||||
wwConfigured: wwConfigured,
|
wwConfigured: wwConfigured,
|
||||||
strategic: strategic,
|
strategic: strategic,
|
||||||
carCharging: carCharging,
|
consumers: consumers,
|
||||||
consumers: consumers,
|
|
||||||
monitorMode: mode,
|
|
||||||
wwBoostDisabled: wwBoostDisabled,
|
|
||||||
tripMgr: tm,
|
|
||||||
carOptions: cars,
|
|
||||||
csrfToken: csrfToken,
|
|
||||||
hasAuth: hasAuth,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,22 +109,18 @@ func (s *Store) Update(state collector.SystemState, actions []engine.Action, ove
|
|||||||
// --- Template data types ---
|
// --- Template data types ---
|
||||||
|
|
||||||
type consumerView struct {
|
type consumerView struct {
|
||||||
Icon string
|
Icon string
|
||||||
Label string
|
Label string
|
||||||
Priority string // e.g. "①"
|
Priority string // e.g. "①"
|
||||||
ConsumerKey string // e.g. "wallbox_a" — used for the override form
|
ConsumerKey string // e.g. "wallbox_a" — used for the override form
|
||||||
Active bool
|
Active bool
|
||||||
Since time.Time
|
Since time.Time
|
||||||
Reason string
|
Reason string
|
||||||
SubDetail string // extra context line: tank temp for WW, compressor for SG-Ready
|
Unconfigured bool
|
||||||
ReadinessHint string // shown when inactive: explains what's blocking activation
|
CanOverride bool // true for Shelly consumers (hardware read-back available)
|
||||||
Unconfigured bool
|
ManualOverride bool
|
||||||
CanOverride bool // true for Shelly consumers (hardware read-back available)
|
OverrideUntil time.Time
|
||||||
IsWW bool // true for ConsumerWW — shows reset button instead of override
|
LivePowerW float64 // from Shelly PM; 0 for non-PM devices
|
||||||
WWBoostDisabled bool // copied from pageData for template access inside range
|
|
||||||
ManualOverride bool
|
|
||||||
OverrideUntil time.Time
|
|
||||||
LivePowerW float64 // from Shelly PM; 0 for non-PM devices
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type phaseView struct {
|
type phaseView struct {
|
||||||
@@ -164,43 +137,29 @@ type forecastView struct {
|
|||||||
Quality string
|
Quality string
|
||||||
}
|
}
|
||||||
|
|
||||||
type tripView struct {
|
type chargingAdviceView struct {
|
||||||
Active bool
|
Show bool
|
||||||
CarName string
|
PlugInBy string // e.g. "11:30"
|
||||||
WallboxLabel string // "Wallbox A" or "Wallbox B"
|
WindowStart string // e.g. "12:00"
|
||||||
CurrentSOC float64
|
WindowEnd string // e.g. "16:00"
|
||||||
EnergyKWh float64 // energy needed
|
|
||||||
DeadlineStr string // e.g. "morgen 07:00"
|
|
||||||
StartsAtStr string // e.g. "heute 22:30" / "jetzt"
|
|
||||||
DurationStr string // estimated charge time, e.g. "15h 45min"
|
|
||||||
ChargingStarted bool
|
|
||||||
ChargingSince string // e.g. "seit 2h 10min"
|
|
||||||
IsTight bool // start time already passed, deadline at risk
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type pageData struct {
|
type pageData struct {
|
||||||
LastUpdate time.Time
|
LastUpdate time.Time
|
||||||
ErrMsg string
|
ErrMsg string
|
||||||
DryRun bool
|
DryRun bool
|
||||||
MonitorOnly bool
|
|
||||||
WWBoostDisabled bool
|
|
||||||
BatterySOC float64
|
BatterySOC float64
|
||||||
GridPowerW float64
|
GridPowerW float64
|
||||||
PVProductionW float64
|
PVProductionW float64
|
||||||
PVYieldTodayKWh float64
|
|
||||||
AmbientTempC float64
|
AmbientTempC float64
|
||||||
CompressorPowerW float64
|
CompressorPowerW float64
|
||||||
WWTopTempC float64
|
|
||||||
IsExporting bool
|
IsExporting bool
|
||||||
AbsGridW float64
|
AbsGridW float64
|
||||||
HasPhaseData bool
|
HasPhaseData bool
|
||||||
Phases []phaseView
|
Phases []phaseView
|
||||||
Forecast forecastView
|
Forecast forecastView
|
||||||
|
ChargingAdvice chargingAdviceView
|
||||||
Consumers []consumerView
|
Consumers []consumerView
|
||||||
Trip tripView
|
|
||||||
CarOptions []CarOption
|
|
||||||
CSRFToken string
|
|
||||||
HasAuth bool // true when login is required — shows logout button
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncConsumerStates updates active flags for all consumers directly from the
|
// SyncConsumerStates updates active flags for all consumers directly from the
|
||||||
@@ -268,41 +227,24 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
monitorOnly := s.monitorMode != nil && s.monitorMode.IsActive()
|
|
||||||
wwBoostDisabled := s.wwBoostDisabled != nil && s.wwBoostDisabled.IsActive()
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
l1, l2, l3 := s.state.PhaseL1PowerW, s.state.PhaseL2PowerW, s.state.PhaseL3PowerW
|
l1, l2, l3 := s.state.PhaseL1PowerW, s.state.PhaseL2PowerW, s.state.PhaseL3PowerW
|
||||||
hasPhase := l1 != 0 || l2 != 0 || l3 != 0
|
hasPhase := l1 != 0 || l2 != 0 || l3 != 0
|
||||||
forecastKWh := 0.0
|
|
||||||
if s.fcResult != nil {
|
|
||||||
forecastKWh = s.fcResult.TotalKWh
|
|
||||||
}
|
|
||||||
data := pageData{
|
data := pageData{
|
||||||
LastUpdate: s.lastUpdate,
|
LastUpdate: s.lastUpdate,
|
||||||
ErrMsg: s.errMsg,
|
ErrMsg: s.errMsg,
|
||||||
DryRun: s.dryRun,
|
DryRun: s.dryRun,
|
||||||
MonitorOnly: monitorOnly,
|
|
||||||
WWBoostDisabled: wwBoostDisabled,
|
|
||||||
BatterySOC: s.state.BatterySOC,
|
BatterySOC: s.state.BatterySOC,
|
||||||
GridPowerW: s.state.GridPowerW,
|
GridPowerW: s.state.GridPowerW,
|
||||||
PVProductionW: s.state.PVProductionW,
|
PVProductionW: s.state.PVProductionW,
|
||||||
PVYieldTodayKWh: s.state.PVYieldTodayKWh,
|
|
||||||
AmbientTempC: s.state.AmbientTempC,
|
AmbientTempC: s.state.AmbientTempC,
|
||||||
CompressorPowerW: s.state.CompressorPowerW,
|
CompressorPowerW: s.state.CompressorPowerW,
|
||||||
WWTopTempC: s.state.WWTopTempC,
|
|
||||||
IsExporting: s.state.GridPowerW < 0,
|
IsExporting: s.state.GridPowerW < 0,
|
||||||
AbsGridW: abs(s.state.GridPowerW),
|
AbsGridW: abs(s.state.GridPowerW),
|
||||||
HasPhaseData: hasPhase,
|
HasPhaseData: hasPhase,
|
||||||
CSRFToken: s.csrfToken,
|
|
||||||
HasAuth: s.hasAuth,
|
|
||||||
}
|
}
|
||||||
if hasPhase {
|
if hasPhase {
|
||||||
for _, ph := range []struct {
|
for _, ph := range []struct{ label string; w float64 }{
|
||||||
label string
|
|
||||||
w float64
|
|
||||||
}{
|
|
||||||
{"L1", l1}, {"L2", l2}, {"L3", l3},
|
{"L1", l1}, {"L2", l2}, {"L3", l3},
|
||||||
} {
|
} {
|
||||||
data.Phases = append(data.Phases, phaseView{
|
data.Phases = append(data.Phases, phaseView{
|
||||||
@@ -320,6 +262,25 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
Icon: s.fcResult.QualityIcon(s.strategic),
|
Icon: s.fcResult.QualityIcon(s.strategic),
|
||||||
Quality: s.fcResult.Quality(s.strategic),
|
Quality: s.fcResult.Quality(s.strategic),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Charging advice: show when surplus window is ahead and no wallbox is active
|
||||||
|
ws := s.fcResult.SurplusWindowStart
|
||||||
|
we := s.fcResult.SurplusWindowEnd
|
||||||
|
now := time.Now()
|
||||||
|
wallboxActive := s.consumers[engine.ConsumerWallboxA].active ||
|
||||||
|
s.consumers[engine.ConsumerWallboxB].active
|
||||||
|
if !ws.IsZero() && now.Before(ws) && !wallboxActive {
|
||||||
|
plugBy := ws.Add(-30 * time.Minute)
|
||||||
|
if plugBy.Before(now) {
|
||||||
|
plugBy = ws // less than 30 min to window — just show window start
|
||||||
|
}
|
||||||
|
data.ChargingAdvice = chargingAdviceView{
|
||||||
|
Show: true,
|
||||||
|
PlugInBy: plugBy.Format("15:04"),
|
||||||
|
WindowStart: ws.Format("15:04"),
|
||||||
|
WindowEnd: we.Format("15:04"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
prioritySymbols := []string{"①", "②", "③", "④"}
|
prioritySymbols := []string{"①", "②", "③", "④"}
|
||||||
for i, c := range consumerOrder {
|
for i, c := range consumerOrder {
|
||||||
@@ -330,36 +291,17 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
prio = prioritySymbols[i]
|
prio = prioritySymbols[i]
|
||||||
}
|
}
|
||||||
cv := consumerView{
|
cv := consumerView{
|
||||||
Icon: meta.Icon,
|
Icon: meta.Icon,
|
||||||
Label: meta.Label,
|
Label: meta.Label,
|
||||||
Priority: prio,
|
Priority: prio,
|
||||||
ConsumerKey: c.String(),
|
ConsumerKey: c.String(),
|
||||||
Active: rec.active,
|
Active: rec.active,
|
||||||
Since: rec.since,
|
Since: rec.since,
|
||||||
Reason: rec.reason,
|
Reason: rec.reason,
|
||||||
CanOverride: c != engine.ConsumerWW,
|
CanOverride: c != engine.ConsumerWW,
|
||||||
IsWW: c == engine.ConsumerWW,
|
ManualOverride: rec.manualOverride,
|
||||||
WWBoostDisabled: c == engine.ConsumerWW && wwBoostDisabled,
|
OverrideUntil: rec.overrideUntil,
|
||||||
ManualOverride: rec.manualOverride,
|
LivePowerW: rec.livePowerW,
|
||||||
OverrideUntil: rec.overrideUntil,
|
|
||||||
LivePowerW: rec.livePowerW,
|
|
||||||
}
|
|
||||||
// SubDetail: extra context line per consumer type
|
|
||||||
switch c {
|
|
||||||
case engine.ConsumerSGReady:
|
|
||||||
if s.state.CompressorPowerW > 0 {
|
|
||||||
cv.SubDetail = "Kompressor: " + formatW(s.state.CompressorPowerW)
|
|
||||||
} else {
|
|
||||||
cv.SubDetail = "Kompressor: Standby"
|
|
||||||
}
|
|
||||||
case engine.ConsumerWW:
|
|
||||||
if s.state.WWTopTempC > 0 {
|
|
||||||
cv.SubDetail = fmt.Sprintf("Speicher: %.1f°C", s.state.WWTopTempC)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ReadinessHint: shown when consumer is inactive and not overridden
|
|
||||||
if !rec.active && !rec.manualOverride {
|
|
||||||
cv.ReadinessHint = s.buildReadinessHint(c, s.state.BatterySOC, s.state.PVProductionW, forecastKWh)
|
|
||||||
}
|
}
|
||||||
if c == engine.ConsumerWW && !s.wwConfigured {
|
if c == engine.ConsumerWW && !s.wwConfigured {
|
||||||
cv.Unconfigured = true
|
cv.Unconfigured = true
|
||||||
@@ -368,12 +310,6 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
|
|
||||||
// Trip mode view (read outside store lock — tripMgr has its own lock)
|
|
||||||
data.CarOptions = s.carOptions
|
|
||||||
if s.tripMgr != nil {
|
|
||||||
data.Trip = buildTripView(s.tripMgr, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := tmpl.Execute(w, data); err != nil {
|
if err := tmpl.Execute(w, data); err != nil {
|
||||||
http.Error(w, "template error", http.StatusInternalServerError)
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
@@ -381,109 +317,6 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildReadinessHint returns a short string explaining why a consumer is currently inactive.
|
|
||||||
// Returns empty string when the consumer is not forecast/threshold driven.
|
|
||||||
func (s *Store) buildReadinessHint(c engine.Consumer, soc, pvW, forecastKWh float64) string {
|
|
||||||
cc := s.carCharging
|
|
||||||
switch c {
|
|
||||||
case engine.ConsumerWallboxA, engine.ConsumerWallboxB:
|
|
||||||
if cc.PVThresholdAW == 0 && cc.PVThresholdBW == 0 {
|
|
||||||
return "" // proactive not configured
|
|
||||||
}
|
|
||||||
midKWh := s.strategic.ForecastMidKWh
|
|
||||||
if forecastKWh == 0 {
|
|
||||||
return "Keine Prognose verfügbar"
|
|
||||||
}
|
|
||||||
if forecastKWh < midKWh {
|
|
||||||
return fmt.Sprintf("Prognose zu gering (%.0f kWh)", forecastKWh)
|
|
||||||
}
|
|
||||||
if float64(cc.MinSOC) > 0 && soc < float64(cc.MinSOC) {
|
|
||||||
return fmt.Sprintf("SOC zu niedrig (min. %d%%)", cc.MinSOC)
|
|
||||||
}
|
|
||||||
threshold := cc.PVThresholdAW
|
|
||||||
if c == engine.ConsumerWallboxB {
|
|
||||||
threshold = cc.PVThresholdBW
|
|
||||||
}
|
|
||||||
if pvW < threshold {
|
|
||||||
return fmt.Sprintf("Warte auf PV ≥ %.0f W (aktuell %.0f W)", threshold, pvW)
|
|
||||||
}
|
|
||||||
return "Bereit — aktiviert bei nächstem Zyklus"
|
|
||||||
case engine.ConsumerWW:
|
|
||||||
if s.strategic.ForecastMidKWh > 0 && forecastKWh < s.strategic.ForecastMidKWh {
|
|
||||||
return fmt.Sprintf("Prognose zu gering (%.0f kWh)", forecastKWh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildTripView constructs the trip card data for the current cycle.
|
|
||||||
func buildTripView(tm *trip.Manager, now time.Time) tripView {
|
|
||||||
goal := tm.ActiveGoal()
|
|
||||||
if goal == nil {
|
|
||||||
return tripView{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use rated wallbox power as fallback (learned rate improves over sessions)
|
|
||||||
// The actual rated power isn't available here, so use battery capacity / 10 as rough estimate.
|
|
||||||
// The real rate is computed in runCycle; here we just format what's stored.
|
|
||||||
rateKW := goal.BatteryKWh / 20.0 // conservative placeholder for display only
|
|
||||||
startTime := goal.StartTime(rateKW)
|
|
||||||
duration := goal.ChargeDuration(rateKW)
|
|
||||||
|
|
||||||
wbLabel := "Wallbox A"
|
|
||||||
if goal.Wallbox == "wallbox_b" {
|
|
||||||
wbLabel = "Wallbox B"
|
|
||||||
}
|
|
||||||
|
|
||||||
tv := tripView{
|
|
||||||
Active: true,
|
|
||||||
CarName: goal.CarName,
|
|
||||||
WallboxLabel: wbLabel,
|
|
||||||
CurrentSOC: goal.CurrentSOC,
|
|
||||||
EnergyKWh: goal.EnergyNeededKWh(),
|
|
||||||
DeadlineStr: formatDay(goal.Deadline, now),
|
|
||||||
DurationStr: formatDur(duration),
|
|
||||||
IsTight: goal.IsTight(now, rateKW),
|
|
||||||
}
|
|
||||||
|
|
||||||
if !goal.ChargingStartedAt.IsZero() {
|
|
||||||
tv.ChargingStarted = true
|
|
||||||
tv.ChargingSince = formatDur(now.Sub(goal.ChargingStartedAt))
|
|
||||||
} else if goal.ShouldStartNow(now, rateKW) {
|
|
||||||
tv.StartsAtStr = "jetzt"
|
|
||||||
} else {
|
|
||||||
tv.StartsAtStr = formatDay(startTime, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
return tv
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatDay(t, now time.Time) string {
|
|
||||||
today := now.Format("2006-01-02")
|
|
||||||
tomorrow := now.AddDate(0, 0, 1).Format("2006-01-02")
|
|
||||||
switch t.Format("2006-01-02") {
|
|
||||||
case today:
|
|
||||||
return "heute " + t.Format("15:04")
|
|
||||||
case tomorrow:
|
|
||||||
return "morgen " + t.Format("15:04")
|
|
||||||
default:
|
|
||||||
return t.Format("02.01. 15:04")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatDur(d time.Duration) string {
|
|
||||||
d = d.Round(time.Minute)
|
|
||||||
h := int(d.Hours())
|
|
||||||
m := int(d.Minutes()) % 60
|
|
||||||
if h == 0 {
|
|
||||||
return fmt.Sprintf("%d min", m)
|
|
||||||
}
|
|
||||||
if m == 0 {
|
|
||||||
return fmt.Sprintf("%d h", h)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d h %d min", h, m)
|
|
||||||
}
|
|
||||||
|
|
||||||
func abs(v float64) float64 {
|
func abs(v float64) float64 {
|
||||||
if v < 0 {
|
if v < 0 {
|
||||||
return -v
|
return -v
|
||||||
@@ -533,65 +366,8 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
.banner.warn { background: #fef9c3; color: #854d0e; border: 1px solid #fde68a; }
|
.banner.warn { background: #fef9c3; color: #854d0e; border: 1px solid #fde68a; }
|
||||||
.banner.error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
|
.banner.error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
|
||||||
.banner.monitor {
|
|
||||||
background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa;
|
|
||||||
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
|
|
||||||
}
|
|
||||||
.resume-btn {
|
|
||||||
background: #fff; border: 1px solid #9a3412; color: #9a3412;
|
|
||||||
border-radius: 7px; padding: 0.25rem 0.7rem; font-size: 0.8rem;
|
|
||||||
font-weight: 700; cursor: pointer; white-space: nowrap; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.resume-btn:active { opacity: 0.7; }
|
|
||||||
.monitor-toggle-btn {
|
|
||||||
background: none; border: 1px solid #d1d5db; color: #9ca3af;
|
|
||||||
border-radius: 8px; padding: 0.25rem 0.75rem; font-size: 0.72rem;
|
|
||||||
cursor: pointer; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.monitor-toggle-btn:hover { border-color: #9ca3af; color: #6b7280; }
|
|
||||||
|
|
||||||
/* Trip mode card */
|
|
||||||
.trip-card {
|
|
||||||
background: #f0fdf4; border: 1px solid #86efac;
|
|
||||||
border-radius: 14px; padding: 0.85rem 1rem; margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
.trip-card.tight { background: #fff7ed; border-color: #fed7aa; }
|
|
||||||
.trip-header {
|
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
.trip-title { font-size: 0.78rem; font-weight: 700; color: #15803d; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
||||||
.trip-card.tight .trip-title { color: #9a3412; }
|
|
||||||
.trip-cancel { background: none; border: 1px solid #d1d5db; color: #9ca3af; border-radius: 7px; padding: 0.2rem 0.55rem; font-size: 0.75rem; cursor: pointer; }
|
|
||||||
.trip-cancel:hover { border-color: #9ca3af; color: #6b7280; }
|
|
||||||
.trip-car { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.2rem; }
|
|
||||||
.trip-detail { font-size: 0.78rem; color: #6b7280; }
|
|
||||||
.trip-timing { font-size: 0.85rem; font-weight: 600; color: #15803d; margin-top: 0.35rem; }
|
|
||||||
.trip-card.tight .trip-timing { color: #9a3412; }
|
|
||||||
.trip-deadline { font-size: 0.78rem; color: #6b7280; margin-top: 0.1rem; }
|
|
||||||
|
|
||||||
/* Trip form */
|
|
||||||
.trip-form-card {
|
|
||||||
background: #fff; border-radius: 14px; padding: 0.85rem 1rem;
|
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.07); margin-top: 1.25rem;
|
|
||||||
}
|
|
||||||
.trip-form { display: flex; flex-direction: column; gap: 0.6rem; margin-top: 0.5rem; }
|
|
||||||
.trip-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
|
||||||
.trip-select, .trip-number, .trip-datetime {
|
|
||||||
border: 1px solid #d1d5db; border-radius: 8px; padding: 0.35rem 0.5rem;
|
|
||||||
font-size: 0.82rem; background: #f9fafb; color: #374151;
|
|
||||||
}
|
|
||||||
.trip-select { flex: 1; min-width: 0; }
|
|
||||||
.trip-number { width: 4.5rem; }
|
|
||||||
.trip-datetime { flex: 1; min-width: 0; }
|
|
||||||
.trip-label { font-size: 0.75rem; color: #6b7280; white-space: nowrap; }
|
|
||||||
.trip-submit {
|
|
||||||
background: #16a34a; color: #fff; border: none; border-radius: 8px;
|
|
||||||
padding: 0.45rem 1.2rem; font-size: 0.85rem; font-weight: 700; cursor: pointer; align-self: flex-end;
|
|
||||||
}
|
|
||||||
.trip-submit:active { opacity: 0.8; }
|
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -673,6 +449,33 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
margin-top: 0.1rem;
|
margin-top: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Charging advice card */
|
||||||
|
.advice-card {
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.advice-title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1d4ed8;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.advice-main {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e3a8a;
|
||||||
|
}
|
||||||
|
.advice-sub {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #3b82f6;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Live power badge on consumers */
|
/* Live power badge on consumers */
|
||||||
.power-badge {
|
.power-badge {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
@@ -739,20 +542,12 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.consumer-detail.active { color: #16a34a; font-weight: 500; }
|
.consumer-detail.active { color: #16a34a; font-weight: 500; }
|
||||||
.consumer-detail.override { color: #d97706; font-weight: 500; }
|
.consumer-detail.override { color: #d97706; font-weight: 500; }
|
||||||
.consumer-sub {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
margin-top: 0.1rem;
|
|
||||||
}
|
|
||||||
.consumer-hint {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: #6b7280;
|
|
||||||
margin-top: 0.1rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dur-select {
|
.dur-select {
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
@@ -784,31 +579,12 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
|
|
||||||
<header>
|
<header>
|
||||||
<h1>☀️ Solar Status</h1>
|
<h1>☀️ Solar Status</h1>
|
||||||
<div style="display:flex;align-items:center;gap:0.6rem">
|
{{if not .LastUpdate.IsZero}}
|
||||||
{{if not .LastUpdate.IsZero}}
|
<span class="updated">{{.LastUpdate.Format "15:04:05"}}</span>
|
||||||
<span class="updated">{{.LastUpdate.Format "15:04:05"}}</span>
|
{{end}}
|
||||||
{{end}}
|
|
||||||
{{if not .MonitorOnly}}
|
|
||||||
<form method="post" action="/monitor">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
|
||||||
<button type="submit" class="monitor-toggle-btn">⏸ Monitor</button>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
||||||
{{if .HasAuth}}
|
|
||||||
<a href="/logout" style="font-size:.8rem;color:#94a3b8;text-decoration:none;padding:.3rem .6rem;border:1px solid #e2e8f0;border-radius:.4rem">Abmelden</a>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{{if .MonitorOnly}}
|
{{if .DryRun}}
|
||||||
<div class="banner monitor">
|
|
||||||
<span>⏸ Monitor-Only — Keine Schaltvorgänge</span>
|
|
||||||
<form method="post" action="/monitor">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
|
||||||
<button type="submit" class="resume-btn">▶ Automatik</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{{else if .DryRun}}
|
|
||||||
<div class="banner warn">⚠️ Testmodus — keine echten Schaltvorgänge</div>
|
<div class="banner warn">⚠️ Testmodus — keine echten Schaltvorgänge</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .ErrMsg}}
|
{{if .ErrMsg}}
|
||||||
@@ -848,7 +624,6 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">PV-Leistung</div>
|
<div class="card-label">PV-Leistung</div>
|
||||||
<div class="card-value" style="color:#d97706">{{formatW .PVProductionW}}</div>
|
<div class="card-value" style="color:#d97706">{{formatW .PVProductionW}}</div>
|
||||||
{{if gt .PVYieldTodayKWh 0.0}}<div class="card-sub">Heute: {{printf "%.1f" .PVYieldTodayKWh}} kWh</div>{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Temperature -->
|
<!-- Temperature -->
|
||||||
@@ -881,7 +656,6 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
<div class="forecast-row">
|
<div class="forecast-row">
|
||||||
<span class="forecast-icon">{{.Forecast.Icon}}</span>
|
<span class="forecast-icon">{{.Forecast.Icon}}</span>
|
||||||
<span class="card-value" style="color:#d97706">{{printf "%.1f" .Forecast.KWh}} kWh</span>
|
<span class="card-value" style="color:#d97706">{{printf "%.1f" .Forecast.KWh}} kWh</span>
|
||||||
{{if gt .PVYieldTodayKWh 0.0}}<span class="card-sub" style="margin-left:1rem">Ist: {{printf "%.1f" .PVYieldTodayKWh}} kWh</span>{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="forecast-quality">{{.Forecast.Quality}}</div>
|
<div class="forecast-quality">{{.Forecast.Quality}}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -889,6 +663,16 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if .ChargingAdvice.Show}}
|
||||||
|
<div class="advice-card">
|
||||||
|
<div class="advice-title">🔌 Ladeempfehlung</div>
|
||||||
|
<div class="advice-main">Auto einstecken bis {{.ChargingAdvice.PlugInBy}} Uhr</div>
|
||||||
|
<div class="advice-sub">
|
||||||
|
Erwartetes Überschuss-Fenster: {{.ChargingAdvice.WindowStart}} – {{.ChargingAdvice.WindowEnd}} Uhr
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="section-title">Verbraucher — Priorität ↓</div>
|
<div class="section-title">Verbraucher — Priorität ↓</div>
|
||||||
|
|
||||||
{{range .Consumers}}
|
{{range .Consumers}}
|
||||||
@@ -905,22 +689,19 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
{{else if .ManualOverride}}
|
{{else if .ManualOverride}}
|
||||||
Manuell bis {{.OverrideUntil.Format "15:04"}}
|
Manuell bis {{.OverrideUntil.Format "15:04"}}
|
||||||
{{else if .Active}}
|
{{else if .Active}}
|
||||||
{{formatSince .Since}}{{if .Reason}} — {{.Reason}}{{end}}
|
{{formatSince .Since}}
|
||||||
|
{{else if .Reason}}
|
||||||
|
{{.Reason}}
|
||||||
{{else}}
|
{{else}}
|
||||||
Ausgeschaltet
|
Ausgeschaltet
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{if .SubDetail}}<div class="consumer-sub">{{.SubDetail}}</div>{{end}}
|
|
||||||
{{if and (not .Active) (not .ManualOverride) .ReadinessHint}}
|
|
||||||
<div class="consumer-hint">{{.ReadinessHint}}</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
{{if and .Active .LivePowerW}}
|
{{if and .Active .LivePowerW}}
|
||||||
<span class="power-badge">{{formatW .LivePowerW}}</span>
|
<span class="power-badge">{{formatW .LivePowerW}}</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .CanOverride}}
|
{{if .CanOverride}}
|
||||||
<form method="post" action="/override" style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0">
|
<form method="post" action="/override" style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
|
||||||
<input type="hidden" name="consumer" value="{{.ConsumerKey}}">
|
<input type="hidden" name="consumer" value="{{.ConsumerKey}}">
|
||||||
{{if .Active}}
|
{{if .Active}}
|
||||||
<input type="hidden" name="state" value="off">
|
<input type="hidden" name="state" value="off">
|
||||||
@@ -938,73 +719,13 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
{{end}}
|
{{end}}
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .IsWW}}
|
|
||||||
<div style="display:flex;flex-direction:column;gap:0.3rem;flex-shrink:0">
|
|
||||||
{{if not .Unconfigured}}
|
|
||||||
<form method="post" action="/ww/reset">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
|
||||||
<button type="submit" class="override-btn turn-off" style="width:100%" title="WW-Solltemperatur auf Basiswert zurücksetzen, Boost für heute sperren">🌡️ Zurücksetzen</button>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
||||||
<form method="post" action="/ww/boost">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
|
||||||
{{if .WWBoostDisabled}}
|
|
||||||
<button type="submit" class="override-btn turn-on" style="width:100%" title="WW Boost durch PV-Überschuss wieder erlauben">✅ Boost ein</button>
|
|
||||||
{{else}}
|
|
||||||
<button type="submit" class="override-btn turn-off" style="width:100%" title="WW Boost dauerhaft deaktivieren (z.B. im Sommer)">⛔ Boost aus</button>
|
|
||||||
{{end}}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .Trip.Active}}
|
<!-- Compressor status (shown when SG-Ready context is relevant) -->
|
||||||
<div class="trip-card{{if .Trip.IsTight}} tight{{end}}">
|
{{if .CompressorPowerW}}
|
||||||
<div class="trip-header">
|
<div style="margin-top:0.5rem;font-size:0.75rem;color:#9ca3af;text-align:right">
|
||||||
<span class="trip-title">🚗 Fahrt geplant</span>
|
Kompressor: {{formatW .CompressorPowerW}}
|
||||||
<form method="post" action="/trip/cancel">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
|
||||||
<button type="submit" class="trip-cancel">Abbrechen</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="trip-car">{{.Trip.CarName}} · {{.Trip.WallboxLabel}}</div>
|
|
||||||
<div class="trip-detail">Ladebedarf: {{printf "%.1f" .Trip.EnergyKWh}} kWh ({{printf "%.0f" .Trip.CurrentSOC}}% → 100%) · ca. {{.Trip.DurationStr}}</div>
|
|
||||||
{{if .Trip.ChargingStarted}}
|
|
||||||
<div class="trip-timing">⚡ Lädt seit {{.Trip.ChargingSince}}</div>
|
|
||||||
{{else if .Trip.IsTight}}
|
|
||||||
<div class="trip-timing">⚠ Zeitfenster knapp — sofort einstecken!</div>
|
|
||||||
{{else}}
|
|
||||||
<div class="trip-timing">Laden startet: {{.Trip.StartsAtStr}}</div>
|
|
||||||
{{end}}
|
|
||||||
<div class="trip-deadline">Bereit bis: {{.Trip.DeadlineStr}}</div>
|
|
||||||
</div>
|
|
||||||
{{else if .CarOptions}}
|
|
||||||
<div class="trip-form-card">
|
|
||||||
<div class="section-title">🚗 Fahrtziel planen</div>
|
|
||||||
<form method="post" action="/trip" class="trip-form">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
|
||||||
<div class="trip-row">
|
|
||||||
<select name="wallbox" class="trip-select">
|
|
||||||
<option value="wallbox_a">Wallbox A</option>
|
|
||||||
<option value="wallbox_b">Wallbox B</option>
|
|
||||||
</select>
|
|
||||||
<select name="car" class="trip-select">
|
|
||||||
{{range .CarOptions}}
|
|
||||||
<option value="{{.Key}}">{{.Name}} ({{.BatteryKWh}} kWh)</option>
|
|
||||||
{{end}}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="trip-row">
|
|
||||||
<span class="trip-label">Ladestand jetzt</span>
|
|
||||||
<input type="number" name="current_soc" min="1" max="99" value="50" class="trip-number"> %
|
|
||||||
</div>
|
|
||||||
<div class="trip-row">
|
|
||||||
<span class="trip-label">Bereit bis</span>
|
|
||||||
<input type="datetime-local" name="deadline" class="trip-datetime" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="trip-submit">Planen</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
@@ -1,297 +0,0 @@
|
|||||||
// Package trip manages trip-mode charging goals and session learning.
|
|
||||||
// A Goal records the user's intent ("car ready by HH:MM") and is persisted
|
|
||||||
// across restarts. Completed sessions are appended to a JSONL log so the
|
|
||||||
// charge rate estimate improves over time.
|
|
||||||
package trip
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"math"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
sessionHistory = 10 // sessions used for rate averaging
|
|
||||||
minSessionsForLearning = 3 // below this: fall back to rated power
|
|
||||||
chargingBuffer = 15 * time.Minute
|
|
||||||
minSessionKWh = 0.1 // ignore sessions shorter than this
|
|
||||||
minSessionDuration = 5 * time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
// Goal is a user-entered trip charging goal, persisted to disk.
|
|
||||||
type Goal struct {
|
|
||||||
Wallbox string `json:"wallbox"` // "wallbox_a" or "wallbox_b"
|
|
||||||
CarName string `json:"car_name"` // display label
|
|
||||||
BatteryKWh float64 `json:"battery_kwh"`
|
|
||||||
CurrentSOC float64 `json:"current_soc"` // % at time of entry
|
|
||||||
Deadline time.Time `json:"deadline"` // car must be ready by this time
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
ChargingStartedAt time.Time `json:"charging_started_at,omitempty"` // set when trip mode activates the wallbox
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnergyNeededKWh returns the energy required to charge from CurrentSOC to 100%.
|
|
||||||
func (g *Goal) EnergyNeededKWh() float64 {
|
|
||||||
return (100.0 - g.CurrentSOC) / 100.0 * g.BatteryKWh
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChargeDuration estimates the time required at the given charge rate.
|
|
||||||
func (g *Goal) ChargeDuration(rateKW float64) time.Duration {
|
|
||||||
hours := g.EnergyNeededKWh() / rateKW
|
|
||||||
return time.Duration(hours * float64(time.Hour))
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartTime returns when charging must begin to meet the deadline (including buffer).
|
|
||||||
func (g *Goal) StartTime(rateKW float64) time.Time {
|
|
||||||
return g.Deadline.Add(-(g.ChargeDuration(rateKW) + chargingBuffer))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShouldStartNow returns true if charging should begin immediately.
|
|
||||||
func (g *Goal) ShouldStartNow(now time.Time, rateKW float64) bool {
|
|
||||||
return !now.Before(g.StartTime(rateKW))
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsTight returns true if the start time has already passed (deadline at risk).
|
|
||||||
func (g *Goal) IsTight(now time.Time, rateKW float64) bool {
|
|
||||||
return g.StartTime(rateKW).Before(now) && g.ChargingStartedAt.IsZero()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session records a completed charging session for rate learning.
|
|
||||||
type Session struct {
|
|
||||||
Timestamp time.Time `json:"ts"`
|
|
||||||
Wallbox string `json:"wallbox"`
|
|
||||||
DurationMin float64 `json:"duration_min"`
|
|
||||||
KWhDelivered float64 `json:"kwh_delivered"`
|
|
||||||
AvgKW float64 `json:"avg_kw"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manager coordinates trip goals, session energy accumulation, and rate learning.
|
|
||||||
// All methods are safe for concurrent use (HTTP handler vs control loop).
|
|
||||||
type Manager struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
goal *Goal
|
|
||||||
goalFile string
|
|
||||||
sessionFile string
|
|
||||||
|
|
||||||
// per-wallbox energy accumulation for the in-progress charging session
|
|
||||||
accEnergy map[string]float64
|
|
||||||
accStart map[string]time.Time
|
|
||||||
|
|
||||||
// previous active state per wallbox — used to detect active→inactive transitions
|
|
||||||
prevActive map[string]bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a Manager and loads any persisted goal from disk.
|
|
||||||
func New(goalFile, sessionFile string) *Manager {
|
|
||||||
m := &Manager{
|
|
||||||
goalFile: goalFile,
|
|
||||||
sessionFile: sessionFile,
|
|
||||||
accEnergy: make(map[string]float64),
|
|
||||||
accStart: make(map[string]time.Time),
|
|
||||||
prevActive: make(map[string]bool),
|
|
||||||
}
|
|
||||||
m.loadGoal()
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) loadGoal() {
|
|
||||||
if m.goalFile == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(m.goalFile)
|
|
||||||
if err != nil {
|
|
||||||
return // no file = no active goal
|
|
||||||
}
|
|
||||||
var g Goal
|
|
||||||
if json.Unmarshal(data, &g) == nil {
|
|
||||||
m.goal = &g
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ActiveGoal returns a snapshot of the current goal, or nil if none is set.
|
|
||||||
func (m *Manager) ActiveGoal() *Goal {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
if m.goal == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
g := *m.goal
|
|
||||||
return &g
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetGoal persists and activates a new trip goal.
|
|
||||||
func (m *Manager) SetGoal(g Goal) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
m.goal = &g
|
|
||||||
return m.saveGoalLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearGoal removes the active trip goal from memory and disk.
|
|
||||||
func (m *Manager) ClearGoal() error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
m.goal = nil
|
|
||||||
if m.goalFile == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
err := os.Remove(m.goalFile)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) saveGoalLocked() error {
|
|
||||||
if m.goalFile == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(m.goalFile), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := json.MarshalIndent(m.goal, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(m.goalFile, data, 0644)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkChargingStarted records when trip-mode charging began in the persisted goal.
|
|
||||||
func (m *Manager) MarkChargingStarted(now time.Time) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
if m.goal == nil || !m.goal.ChargingStartedAt.IsZero() {
|
|
||||||
return nil // already marked, or no goal
|
|
||||||
}
|
|
||||||
m.goal.ChargingStartedAt = now
|
|
||||||
return m.saveGoalLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tick must be called once per cycle. It:
|
|
||||||
// 1. Accumulates energy from active PM readings into the running session total.
|
|
||||||
// 2. Detects active→inactive wallbox transitions and finalises those sessions.
|
|
||||||
//
|
|
||||||
// Returns the list of wallboxes whose sessions just completed this cycle.
|
|
||||||
// The caller uses this to auto-clear a trip goal when its wallbox finishes.
|
|
||||||
func (m *Manager) Tick(
|
|
||||||
activeStates map[string]bool,
|
|
||||||
powerReadingsW map[string]float64,
|
|
||||||
intervalMin float64,
|
|
||||||
) []string {
|
|
||||||
completed := m.tickLocked(activeStates, powerReadingsW, intervalMin)
|
|
||||||
for _, wb := range completed {
|
|
||||||
m.finalizeSession(wb)
|
|
||||||
}
|
|
||||||
return completed
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) tickLocked(activeStates map[string]bool, powerW map[string]float64, intervalMin float64) []string {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
for wb, active := range activeStates {
|
|
||||||
if active {
|
|
||||||
if _, ok := m.accStart[wb]; !ok {
|
|
||||||
m.accStart[wb] = time.Now()
|
|
||||||
m.accEnergy[wb] = 0
|
|
||||||
}
|
|
||||||
if pw := powerW[wb]; pw > 0 {
|
|
||||||
m.accEnergy[wb] += pw / 1000.0 * (intervalMin / 60.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var completed []string
|
|
||||||
for wb, active := range activeStates {
|
|
||||||
if m.prevActive[wb] && !active {
|
|
||||||
completed = append(completed, wb)
|
|
||||||
}
|
|
||||||
m.prevActive[wb] = active
|
|
||||||
}
|
|
||||||
return completed
|
|
||||||
}
|
|
||||||
|
|
||||||
// finalizeSession writes a completed session record to the log file.
|
|
||||||
// Called without m.mu held.
|
|
||||||
func (m *Manager) finalizeSession(wallbox string) {
|
|
||||||
m.mu.Lock()
|
|
||||||
start, ok := m.accStart[wallbox]
|
|
||||||
kwh := m.accEnergy[wallbox]
|
|
||||||
delete(m.accStart, wallbox)
|
|
||||||
delete(m.accEnergy, wallbox)
|
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
duration := time.Since(start)
|
|
||||||
if kwh < minSessionKWh || duration < minSessionDuration {
|
|
||||||
return // too short / too little energy — ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
s := Session{
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Wallbox: wallbox,
|
|
||||||
DurationMin: math.Round(duration.Minutes()*10) / 10,
|
|
||||||
KWhDelivered: math.Round(kwh*100) / 100,
|
|
||||||
AvgKW: math.Round(kwh/duration.Hours()*100) / 100,
|
|
||||||
}
|
|
||||||
_ = m.appendSession(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) appendSession(s Session) error {
|
|
||||||
if m.sessionFile == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(m.sessionFile), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
f, err := os.OpenFile(m.sessionFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
return json.NewEncoder(f).Encode(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LearnedRateKW returns the average charging rate (kW) from recent sessions
|
|
||||||
// for the given wallbox. Falls back to fallbackKW if fewer than
|
|
||||||
// minSessionsForLearning exist.
|
|
||||||
func (m *Manager) LearnedRateKW(wallbox string, fallbackKW float64) float64 {
|
|
||||||
sessions := m.recentSessions(wallbox, sessionHistory)
|
|
||||||
if len(sessions) < minSessionsForLearning {
|
|
||||||
return fallbackKW
|
|
||||||
}
|
|
||||||
var total float64
|
|
||||||
for _, s := range sessions {
|
|
||||||
total += s.AvgKW
|
|
||||||
}
|
|
||||||
return total / float64(len(sessions))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) recentSessions(wallbox string, n int) []Session {
|
|
||||||
if m.sessionFile == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
f, err := os.Open(m.sessionFile)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
var matched []Session
|
|
||||||
sc := bufio.NewScanner(f)
|
|
||||||
for sc.Scan() {
|
|
||||||
var s Session
|
|
||||||
if json.Unmarshal(sc.Bytes(), &s) == nil && s.Wallbox == wallbox {
|
|
||||||
matched = append(matched, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(matched) <= n {
|
|
||||||
return matched
|
|
||||||
}
|
|
||||||
return matched[len(matched)-n:]
|
|
||||||
}
|
|
||||||
395
main.go
395
main.go
@@ -9,23 +9,18 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/tb/ems/internal/actuator"
|
"github.com/tb/ems/internal/actuator"
|
||||||
"github.com/tb/ems/internal/auth"
|
|
||||||
"github.com/tb/ems/internal/collector"
|
"github.com/tb/ems/internal/collector"
|
||||||
"github.com/tb/ems/internal/config"
|
"github.com/tb/ems/internal/config"
|
||||||
"github.com/tb/ems/internal/engine"
|
"github.com/tb/ems/internal/engine"
|
||||||
"github.com/tb/ems/internal/forecast"
|
"github.com/tb/ems/internal/forecast"
|
||||||
"github.com/tb/ems/internal/metrics"
|
"github.com/tb/ems/internal/metrics"
|
||||||
"github.com/tb/ems/internal/monitor"
|
|
||||||
"github.com/tb/ems/internal/status"
|
"github.com/tb/ems/internal/status"
|
||||||
"github.com/tb/ems/internal/trip"
|
|
||||||
"github.com/tb/ems/internal/viessmann"
|
"github.com/tb/ems/internal/viessmann"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,101 +103,31 @@ func main() {
|
|||||||
reg := prometheus.NewRegistry()
|
reg := prometheus.NewRegistry()
|
||||||
m := metrics.NewMetrics(reg)
|
m := metrics.NewMetrics(reg)
|
||||||
|
|
||||||
// Monitor-only mode (persistent flag file)
|
|
||||||
monitorMode := monitor.New(cfg.EMS.MonitorOnlyFile)
|
|
||||||
if monitorMode.IsActive() {
|
|
||||||
logger.Warn("starting in monitor-only mode — actuator calls suppressed",
|
|
||||||
"flag_file", cfg.EMS.MonitorOnlyFile,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WW boost disable flag (persistent; presence of file = boost disabled)
|
|
||||||
wwBoostDisabled := monitor.New(cfg.EMS.WWBoostDisableFile)
|
|
||||||
if wwBoostDisabled.IsActive() {
|
|
||||||
logger.Info("WW boost disabled via flag file", "flag_file", cfg.EMS.WWBoostDisableFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trip mode manager (goal persistence + session learning)
|
|
||||||
tripMgr := trip.New(cfg.EMS.TripGoalFile, cfg.EMS.SessionLogFile)
|
|
||||||
if g := tripMgr.ActiveGoal(); g != nil {
|
|
||||||
logger.Info("trip goal loaded from disk",
|
|
||||||
"wallbox", g.Wallbox,
|
|
||||||
"car", g.CarName,
|
|
||||||
"deadline", g.Deadline.Format("02.01. 15:04"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build sorted car option list for the status page form
|
|
||||||
carOptions := buildCarOptions(cfg.Cars)
|
|
||||||
|
|
||||||
// Auth manager — loads or generates persistent session and CSRF tokens.
|
|
||||||
// Token files live alongside other persistent state in /var/lib/ems/.
|
|
||||||
tokenDir := filepath.Dir(cfg.EMS.SessionLogFile)
|
|
||||||
authMgr := auth.New(cfg.EMS.HTTPUsername, cfg.EMS.HTTPPassword, tokenDir, logger)
|
|
||||||
if authMgr.Enabled() {
|
|
||||||
logger.Info("web UI authentication enabled", "username", cfg.EMS.HTTPUsername)
|
|
||||||
} else {
|
|
||||||
logger.Warn("web UI authentication disabled — set ems.http_username and ems.http_password to enable")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status store (shared between HTTP handler and control loop)
|
// Status store (shared between HTTP handler and control loop)
|
||||||
wwConfigured := cfg.Viessmann.InstallationID != ""
|
wwConfigured := cfg.Viessmann.InstallationID != ""
|
||||||
statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, cfg.CarCharging, monitorMode, wwBoostDisabled, tripMgr, carOptions, authMgr.CSRFToken(), authMgr.Enabled())
|
statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic)
|
||||||
|
|
||||||
// Web UI server — authenticated, serves status page and control endpoints.
|
// Metrics HTTP server
|
||||||
uiMux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
uiMux.HandleFunc("/login", authMgr.LoginHandler())
|
mux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
|
||||||
uiMux.HandleFunc("/logout", authMgr.LogoutHandler())
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
uiMux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte("ok")) //nolint:errcheck
|
w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
uiMux.HandleFunc("/override", overrideHandler(act, eng, logger))
|
mux.HandleFunc("/override", overrideHandler(act, eng, logger))
|
||||||
uiMux.HandleFunc("/monitor", monitorHandler(monitorMode, logger))
|
mux.HandleFunc("/", statusStore.Handler())
|
||||||
uiMux.HandleFunc("/trip", tripSetHandler(tripMgr, cfg, logger))
|
|
||||||
uiMux.HandleFunc("/trip/cancel", tripCancelHandler(tripMgr, logger))
|
|
||||||
uiMux.HandleFunc("/ww/reset", wwResetHandler(act, eng, cfg, logger))
|
|
||||||
uiMux.HandleFunc("/ww/boost", wwBoostToggleHandler(wwBoostDisabled, logger))
|
|
||||||
uiMux.HandleFunc("/", statusStore.Handler())
|
|
||||||
|
|
||||||
var uiHandler http.Handler = authMgr.VerifyCSRF(uiMux)
|
srv := &http.Server{
|
||||||
uiHandler = authMgr.RequireAuth(uiHandler)
|
|
||||||
|
|
||||||
uiSrv := &http.Server{
|
|
||||||
Addr: cfg.EMS.ListenAddr,
|
Addr: cfg.EMS.ListenAddr,
|
||||||
Handler: uiHandler,
|
Handler: mux,
|
||||||
}
|
|
||||||
|
|
||||||
// Metrics server — no auth, for Prometheus scraping.
|
|
||||||
// Bind to MetricsAddr; if unset, skip the separate server (metrics stay on UI port).
|
|
||||||
var metricsSrv *http.Server
|
|
||||||
if cfg.EMS.MetricsAddr != "" && cfg.EMS.MetricsAddr != cfg.EMS.ListenAddr {
|
|
||||||
metMux := http.NewServeMux()
|
|
||||||
metMux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
|
|
||||||
metMux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("ok")) //nolint:errcheck
|
|
||||||
})
|
|
||||||
metricsSrv = &http.Server{Addr: cfg.EMS.MetricsAddr, Handler: metMux}
|
|
||||||
} else {
|
|
||||||
// Fallback: add /metrics to the UI mux (no separate port configured)
|
|
||||||
uiMux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
logger.Info("web UI listening", "addr", cfg.EMS.ListenAddr)
|
logger.Info("metrics server listening", "addr", cfg.EMS.ListenAddr)
|
||||||
if err := uiSrv.ListenAndServe(); err != http.ErrServerClosed {
|
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
||||||
logger.Error("web UI server error", "error", err)
|
logger.Error("metrics server error", "error", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if metricsSrv != nil {
|
|
||||||
go func() {
|
|
||||||
logger.Info("metrics server listening", "addr", cfg.EMS.MetricsAddr)
|
|
||||||
if err := metricsSrv.ListenAndServe(); err != http.ErrServerClosed {
|
|
||||||
logger.Error("metrics server error", "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -218,12 +143,12 @@ func main() {
|
|||||||
logger.Info("EMS control loop started")
|
logger.Info("EMS control loop started")
|
||||||
|
|
||||||
// Run once immediately
|
// Run once immediately
|
||||||
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode, wwBoostDisabled, tripMgr)
|
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode, wwBoostDisabled, tripMgr)
|
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun)
|
||||||
|
|
||||||
case sig := <-sigCh:
|
case sig := <-sigCh:
|
||||||
logger.Info("received signal, shutting down", "signal", sig)
|
logger.Info("received signal, shutting down", "signal", sig)
|
||||||
@@ -231,10 +156,7 @@ func main() {
|
|||||||
|
|
||||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer shutdownCancel()
|
defer shutdownCancel()
|
||||||
uiSrv.Shutdown(shutdownCtx) //nolint:errcheck
|
srv.Shutdown(shutdownCtx)
|
||||||
if metricsSrv != nil {
|
|
||||||
metricsSrv.Shutdown(shutdownCtx) //nolint:errcheck
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,12 +174,8 @@ func runCycle(
|
|||||||
stateFile string,
|
stateFile string,
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
dryRun bool,
|
dryRun bool,
|
||||||
monitorMode *monitor.Mode,
|
|
||||||
wwBoostDisabled *monitor.Mode,
|
|
||||||
tripMgr *trip.Manager,
|
|
||||||
) {
|
) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
pollMin := cfg.EMS.PollIntervalParsed().Minutes()
|
|
||||||
|
|
||||||
// Step 1: Collect current state from Prometheus
|
// Step 1: Collect current state from Prometheus
|
||||||
state, err := coll.Collect(ctx)
|
state, err := coll.Collect(ctx)
|
||||||
@@ -271,13 +189,11 @@ func runCycle(
|
|||||||
writeHeartbeat(stateFile, logger)
|
writeHeartbeat(stateFile, logger)
|
||||||
|
|
||||||
// Step 1b: Read Shelly states and sync to engine (detects manual overrides).
|
// Step 1b: Read Shelly states and sync to engine (detects manual overrides).
|
||||||
// Keep shellyStates accessible for trip mode energy accumulation.
|
// Partial results are fine — unreachable devices are logged inside ReadAllStates.
|
||||||
var shellyStates map[engine.Consumer]engine.DeviceStatus
|
if shellyStates, err := act.ReadAllStates(ctx); err != nil {
|
||||||
if states, err := act.ReadAllStates(ctx); err != nil {
|
|
||||||
logger.Warn("all Shelly devices unreachable, skipping override detection", "error", err)
|
logger.Warn("all Shelly devices unreachable, skipping override detection", "error", err)
|
||||||
} else {
|
} else {
|
||||||
shellyStates = states
|
eng.SyncHardwareState(shellyStates, now, cfg.EMS.OverrideTimeoutParsed())
|
||||||
eng.SyncHardwareState(shellyStates, now, cfg.EMS.OverrideTimeoutParsed(), monitorMode.IsActive())
|
|
||||||
store.SyncDeviceStates(shellyStates)
|
store.SyncDeviceStates(shellyStates)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,8 +202,8 @@ func runCycle(
|
|||||||
m.BatterySOC.Set(state.BatterySOC)
|
m.BatterySOC.Set(state.BatterySOC)
|
||||||
m.PVProductionW.Set(state.PVProductionW)
|
m.PVProductionW.Set(state.PVProductionW)
|
||||||
|
|
||||||
// Track energy flow (rough estimation based on poll interval)
|
// Track energy flow (rough estimation based on 2-min intervals)
|
||||||
intervalHours := pollMin / 60.0
|
intervalHours := 2.0 / 60.0
|
||||||
if state.GridPowerW > 0 {
|
if state.GridPowerW > 0 {
|
||||||
m.GridImportKWh.Add(state.GridPowerW / 1000.0 * intervalHours)
|
m.GridImportKWh.Add(state.GridPowerW / 1000.0 * intervalHours)
|
||||||
} else {
|
} else {
|
||||||
@@ -301,75 +217,11 @@ func runCycle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Run decision engine
|
// Step 3: Run decision engine
|
||||||
wwBoostC := 0.0
|
wwBoostC := computeWWBoost(fcResult, cfg)
|
||||||
if !wwBoostDisabled.IsActive() {
|
|
||||||
wwBoostC = computeWWBoost(fcResult, cfg)
|
|
||||||
}
|
|
||||||
forecastKWh := 0.0
|
|
||||||
if fcResult != nil {
|
|
||||||
forecastKWh = fcResult.TotalKWh
|
|
||||||
}
|
|
||||||
// Estimate sunset as SurplusWindowEnd + 1h; fall back to zero (disables EOD check)
|
|
||||||
var sunsetTime time.Time
|
|
||||||
if fcResult != nil && !fcResult.SurplusWindowEnd.IsZero() {
|
|
||||||
sunsetTime = fcResult.SurplusWindowEnd.Add(time.Hour)
|
|
||||||
}
|
|
||||||
decisionStart := time.Now()
|
decisionStart := time.Now()
|
||||||
actions := eng.Decide(state, now, wwBoostC, forecastKWh, sunsetTime)
|
actions := eng.Decide(state, now, wwBoostC)
|
||||||
m.DecisionDuration.Observe(time.Since(decisionStart).Seconds())
|
m.DecisionDuration.Observe(time.Since(decisionStart).Seconds())
|
||||||
|
|
||||||
// Step 3b: Trip mode — session energy tracking + wallbox activation
|
|
||||||
consumerStates := eng.ConsumerStates()
|
|
||||||
wallboxActive := map[string]bool{
|
|
||||||
engine.ConsumerWallboxA.String(): consumerStates[engine.ConsumerWallboxA],
|
|
||||||
engine.ConsumerWallboxB.String(): consumerStates[engine.ConsumerWallboxB],
|
|
||||||
}
|
|
||||||
wallboxPowerW := map[string]float64{}
|
|
||||||
if shellyStates != nil {
|
|
||||||
if s, ok := shellyStates[engine.ConsumerWallboxA]; ok {
|
|
||||||
wallboxPowerW[engine.ConsumerWallboxA.String()] = s.PowerW
|
|
||||||
}
|
|
||||||
if s, ok := shellyStates[engine.ConsumerWallboxB]; ok {
|
|
||||||
wallboxPowerW[engine.ConsumerWallboxB.String()] = s.PowerW
|
|
||||||
}
|
|
||||||
}
|
|
||||||
completed := tripMgr.Tick(wallboxActive, wallboxPowerW, pollMin)
|
|
||||||
|
|
||||||
// If a trip goal's wallbox just went idle → car is full, clear the goal
|
|
||||||
if goal := tripMgr.ActiveGoal(); goal != nil {
|
|
||||||
for _, wb := range completed {
|
|
||||||
if wb == goal.Wallbox {
|
|
||||||
logger.Info("trip mode: charging complete, clearing goal",
|
|
||||||
"wallbox", wb, "car", goal.CarName)
|
|
||||||
_ = tripMgr.ClearGoal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If trip goal's start time has arrived and wallbox is not yet active, force it on
|
|
||||||
if goal := tripMgr.ActiveGoal(); goal != nil {
|
|
||||||
consumer := tripConsumer(goal.Wallbox)
|
|
||||||
ratedKW := tripRatedKW(cfg, consumer)
|
|
||||||
learnedKW := tripMgr.LearnedRateKW(goal.Wallbox, ratedKW)
|
|
||||||
if goal.ShouldStartNow(now, learnedKW) && !consumerStates[consumer] {
|
|
||||||
actions = append(actions, engine.Action{
|
|
||||||
Consumer: consumer,
|
|
||||||
TurnOn: true,
|
|
||||||
Reason: fmt.Sprintf("trip mode: %s, bereit bis %s",
|
|
||||||
goal.CarName, goal.Deadline.Format("15:04")),
|
|
||||||
})
|
|
||||||
overrideDur := time.Until(goal.Deadline) + time.Hour
|
|
||||||
eng.ApplyOverride(consumer, true, overrideDur)
|
|
||||||
_ = tripMgr.MarkChargingStarted(now)
|
|
||||||
logger.Info("trip mode: activating wallbox",
|
|
||||||
"wallbox", goal.Wallbox,
|
|
||||||
"car", goal.CarName,
|
|
||||||
"learned_kw", learnedKW,
|
|
||||||
"deadline", goal.Deadline.Format("15:04"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update consumer state metrics
|
// Update consumer state metrics
|
||||||
m.UpdateConsumerStates(eng.ConsumerStates())
|
m.UpdateConsumerStates(eng.ConsumerStates())
|
||||||
|
|
||||||
@@ -388,13 +240,9 @@ func runCycle(
|
|||||||
// Step 4: Execute actions
|
// Step 4: Execute actions
|
||||||
m.RecordActions(actions)
|
m.RecordActions(actions)
|
||||||
|
|
||||||
if dryRun || monitorMode.IsActive() {
|
if dryRun {
|
||||||
mode := "DRY RUN"
|
|
||||||
if monitorMode.IsActive() {
|
|
||||||
mode = "MONITOR-ONLY"
|
|
||||||
}
|
|
||||||
for _, a := range actions {
|
for _, a := range actions {
|
||||||
logger.Info(fmt.Sprintf("[%s] would execute", mode),
|
logger.Info("[DRY RUN] would execute",
|
||||||
"consumer", a.Consumer,
|
"consumer", a.Consumer,
|
||||||
"turn_on", a.TurnOn,
|
"turn_on", a.TurnOn,
|
||||||
"reason", a.Reason,
|
"reason", a.Reason,
|
||||||
@@ -403,192 +251,8 @@ func runCycle(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, err := range act.Execute(ctx, actions) {
|
if err := act.Execute(ctx, actions); err != nil {
|
||||||
if err != nil {
|
logger.Error("execution failed", "error", err)
|
||||||
eng.RollbackAction(actions[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// tripConsumer maps a wallbox config key to an engine Consumer.
|
|
||||||
func tripConsumer(wallbox string) engine.Consumer {
|
|
||||||
if wallbox == "wallbox_b" {
|
|
||||||
return engine.ConsumerWallboxB
|
|
||||||
}
|
|
||||||
return engine.ConsumerWallboxA
|
|
||||||
}
|
|
||||||
|
|
||||||
// tripRatedKW returns the configured rated power for the given consumer in kW.
|
|
||||||
func tripRatedKW(cfg *config.Config, c engine.Consumer) float64 {
|
|
||||||
if c == engine.ConsumerWallboxB {
|
|
||||||
return float64(cfg.Shelly.WallboxB.PowerW) / 1000.0
|
|
||||||
}
|
|
||||||
return float64(cfg.Shelly.WallboxA.PowerW) / 1000.0
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildCarOptions returns a sorted list of car options for the status page form.
|
|
||||||
func buildCarOptions(cars map[string]config.CarProfile) []status.CarOption {
|
|
||||||
keys := make([]string, 0, len(cars))
|
|
||||||
for k := range cars {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
opts := make([]status.CarOption, 0, len(keys))
|
|
||||||
for _, k := range keys {
|
|
||||||
c := cars[k]
|
|
||||||
opts = append(opts, status.CarOption{Key: k, Name: c.Name, BatteryKWh: c.BatteryKWh})
|
|
||||||
}
|
|
||||||
return opts
|
|
||||||
}
|
|
||||||
|
|
||||||
// tripSetHandler handles the trip mode form submission.
|
|
||||||
func tripSetHandler(tm *trip.Manager, cfg *config.Config, logger *slog.Logger) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
carKey := r.FormValue("car")
|
|
||||||
wallbox := r.FormValue("wallbox")
|
|
||||||
socStr := r.FormValue("current_soc")
|
|
||||||
deadlineStr := r.FormValue("deadline") // "2006-01-02T15:04"
|
|
||||||
|
|
||||||
car, ok := cfg.Cars[carKey]
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "unknown car", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if wallbox != "wallbox_a" && wallbox != "wallbox_b" {
|
|
||||||
http.Error(w, "unknown wallbox", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
soc, err := strconv.ParseFloat(socStr, 64)
|
|
||||||
if err != nil || soc < 0 || soc >= 100 {
|
|
||||||
http.Error(w, "invalid SOC (must be 0–99)", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
deadline, err := time.ParseInLocation("2006-01-02T15:04", deadlineStr, time.Local)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "invalid deadline format", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if deadline.Before(time.Now()) {
|
|
||||||
http.Error(w, "deadline is in the past", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
goal := trip.Goal{
|
|
||||||
Wallbox: wallbox,
|
|
||||||
CarName: car.Name,
|
|
||||||
BatteryKWh: car.BatteryKWh,
|
|
||||||
CurrentSOC: soc,
|
|
||||||
Deadline: deadline,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
if err := tm.SetGoal(goal); err != nil {
|
|
||||||
logger.Error("failed to save trip goal", "error", err)
|
|
||||||
http.Error(w, "could not save goal — check logs", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.Info("trip goal set",
|
|
||||||
"wallbox", wallbox,
|
|
||||||
"car", car.Name,
|
|
||||||
"soc", soc,
|
|
||||||
"deadline", deadline.Format("02.01. 15:04"),
|
|
||||||
)
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// tripCancelHandler clears the active trip goal.
|
|
||||||
func tripCancelHandler(tm *trip.Manager, logger *slog.Logger) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := tm.ClearGoal(); err != nil {
|
|
||||||
logger.Error("failed to clear trip goal", "error", err)
|
|
||||||
}
|
|
||||||
logger.Info("trip goal cancelled by user")
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wwBoostToggleHandler toggles the WW boost disable flag on POST.
|
|
||||||
func wwBoostToggleHandler(disabled *monitor.Mode, logger *slog.Logger) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
newState := !disabled.IsActive()
|
|
||||||
if err := disabled.Set(newState); err != nil {
|
|
||||||
logger.Error("WW boost toggle failed", "error", err)
|
|
||||||
} else {
|
|
||||||
if newState {
|
|
||||||
logger.Info("WW boost disabled via web UI")
|
|
||||||
} else {
|
|
||||||
logger.Info("WW boost enabled via web UI")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wwResetHandler resets the WW temperature to the configured base setpoint and
|
|
||||||
// blocks further boosts for the rest of the day via an engine override.
|
|
||||||
func wwResetHandler(act *actuator.Actuator, eng *engine.Engine, cfg *config.Config, logger *slog.Logger) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block re-boost until midnight.
|
|
||||||
now := time.Now()
|
|
||||||
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
|
|
||||||
lockDur := time.Until(midnight)
|
|
||||||
|
|
||||||
eng.ApplyOverride(engine.ConsumerWW, false, lockDur)
|
|
||||||
|
|
||||||
// Immediately restore base WW temperature via Viessmann API.
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
action := engine.Action{
|
|
||||||
Consumer: engine.ConsumerWW,
|
|
||||||
TurnOn: false,
|
|
||||||
TargetTempC: cfg.Strategic.WWBaseC,
|
|
||||||
Reason: "manual WW reset via web UI",
|
|
||||||
}
|
|
||||||
if errs := act.Execute(ctx, []engine.Action{action}); errs[0] != nil {
|
|
||||||
logger.Error("WW reset: actuator failed", "error", errs[0])
|
|
||||||
} else {
|
|
||||||
logger.Info("WW boost reset", "base_c", cfg.Strategic.WWBaseC, "locked_until", midnight.Format("15:04"))
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// monitorHandler toggles monitor-only mode on POST and redirects to the status page.
|
|
||||||
func monitorHandler(mode *monitor.Mode, logger *slog.Logger) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
newState := !mode.IsActive()
|
|
||||||
if err := mode.Set(newState); err != nil {
|
|
||||||
logger.Error("failed to set monitor-only mode", "active", newState, "error", err)
|
|
||||||
http.Error(w, "could not update monitor mode — check logs", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.Info("monitor-only mode changed", "active", newState)
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,8 +319,8 @@ func overrideHandler(act *actuator.Actuator, eng *engine.Engine, logger *slog.Lo
|
|||||||
TurnOn: turnOn,
|
TurnOn: turnOn,
|
||||||
Reason: fmt.Sprintf("manual override via web UI (%s)", duration),
|
Reason: fmt.Sprintf("manual override via web UI (%s)", duration),
|
||||||
}
|
}
|
||||||
if errs := act.Execute(ctx, []engine.Action{action}); errs[0] != nil {
|
if err := act.Execute(ctx, []engine.Action{action}); err != nil {
|
||||||
logger.Error("web UI override failed", "consumer", consumerKey, "state", stateVal, "error", errs[0])
|
logger.Error("web UI override failed", "consumer", consumerKey, "state", stateVal, "error", err)
|
||||||
http.Error(w, "switch failed — check logs", http.StatusInternalServerError)
|
http.Error(w, "switch failed — check logs", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -793,4 +457,3 @@ func writeHeartbeat(stateFile string, logger *slog.Logger) {
|
|||||||
logger.Warn("could not write heartbeat file", "error", err)
|
logger.Warn("could not write heartbeat file", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user