21 Commits

Author SHA1 Message Date
eaefe4c519 Merge house/lutz — compressor fix, WW boost toggle, button layout
- Compressor: PromQL expression returns actual watts (sensors% × kW × 10),
  idle threshold raised to 500W; fixes silent early-release of SG-Ready
- WW boost toggle: persistent flag file (/etc/ems/ww-boost-off) disables
  WW boost globally; / button on WW card, survives reboots
- WW reset button: restores base temp + blocks boost until midnight
- WW buttons stacked vertically in consumer card
- Grafana dashboard: full EMS dashboard pushed to Grafana instance
2026-04-06 16:23:35 +02:00
009f8b05f3 Stack WW boost buttons vertically in consumer card
The reset and boost toggle buttons were side by side, looking cramped.
Wrapped them in a flex column div so they stack neatly below each other.

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:00:42 +02:00
2936078faa Merge house/lutz into main — v0.9
- Monitor-only mode: flag-file toggle, amber banner, suppresses all actuator calls
- Trip mode: self-learning EV charging scheduler (Mini Cooper SE / BMW ix2,
  selectable per trip, learned rate from session JSONL log)
- Compressor idle detection: correct metric (sensors_power_value = % of rated)
  and threshold (20 cleanly separates 0% idle from ≥27% running)
- Grafana dashboard: full EMS dashboard with power flow, consumer states,
  energy totals, raw Viessmann data, health panels, log analysis rows
2026-04-06 14:49:44 +02:00
a537b02adb Fix compressor metric comments: sensors_power_value is % of rated power
heating_compressors_0_sensors_power_value returns 0-100% of rated
compressor power, not watts. heating_compressors_0_power_value is the
constant rated power in kW (10 kW). Actual watts = sensors% × kW × 10.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:59:57 +02:00
590ba1e675 docs: update README for all features added since initial commit
- Forecast config: fetch_interval, fetch_window_start/end, base_load_w,
  min_surplus_w — 3x daily refresh within daylight window
- EMS config: override_max_import_w — hard-stop override on excess import
- Decision logic: charging advice concept and surplus window algorithm
- Decision logic: override hard-stop conditions (SOC brake + import limit)
- Web interface: charging advice card, PM live power badges, phase display,
  compressor power, override duration selector (30m/1h/2h/4h)
- Makefile: note that make install skips config if file already exists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
v0.9
2026-04-06 10:51:22 +02:00
7a971c4c41 Add configurable override duration and hard-stop thresholds
Override duration (web UI):
- Dropdown on Ein button: 30min / 1h / 2h / 4h
- Engine notified immediately via ApplyOverride() — no waiting for
  next SyncHardwareState cycle
- Aus button always uses 1h lockout (keeps consumer off for 1h)

Hard-stop thresholds that cancel active overrides:
- SOC emergency brake: now also clears ManualOverride flag so EMS
  resumes full control after the safety shutdown
- override_max_import_w (default 800W): if grid import exceeds this
  while an override is active, override is cancelled immediately
  (no hysteresis delay — protection is instant)

Config: ems.override_max_import_w (0 = disabled)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:43:49 +02:00
4530b672d2 Tune base_load_w to 450W (measured steady-state house consumption)
Kettle/dishwasher spikes are seconds-long against hourly forecast
averages — negligible. Correct value is the steady-state draw.

Effect: surplus window today widens from 11:00-13:00 to 10:00-17:00.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:33:53 +02:00
0020f1268b Add EV charging advice notification to status page
Uses hourly PV forecast data (watts per hour from forecast.solar) to
find the first contiguous block of hours where:
  PV production - base_load_w >= min_surplus_w

Shows a blue advice card on the status page:
  "Auto einstecken bis HH:MM Uhr"
  "Erwartetes Überschuss-Fenster: HH:MM – HH:MM Uhr"

Card is hidden when:
- No surplus window found (weak forecast day)
- Window has already started or passed
- A wallbox is currently active (car already charging)

Config: forecast.base_load_w (1000W), forecast.min_surplus_w (1800W)
Timestamp parsing handles both "HH:MM:SS" and "HH:MM" key formats.
Today: surplus window 11:00–13:00 (24.3 kWh forecast).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:30:58 +02:00
5f22df3e0f Refresh forecast up to 3× per day within daylight window
forecast.solar updates estimates as the day progresses, so a single
morning fetch can be stale by afternoon. New behaviour:
- Re-fetch every 4h within 07:00–19:00 window (3 fetches/day)
- Outside the window always serve the cached value
- Free tier limit is 12 req/day — 3 fetches is well within budget
- First startup outside window still fetches if cache is empty

Config: forecast.fetch_interval, fetch_window_start, fetch_window_end

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:19:29 +02:00
79828a46c5 Add status page enhancements and engine improvements
Status page:
- Per-phase grid power (L1/L2/L3) with export/import direction
- Shelly PM live power badge on active wallbox rows
- Compressor power shown below consumer list
- SyncDeviceStates() method on Store for PM power readback

Engine:
- Fix wallbox mutex: re-read Active state after P3 so same-cycle
  WallboxA activation correctly blocks WallboxB in P4
- Fix RecoverState: set ActivatedAt to zero so recovered consumers
  are immediately eligible for shutdown decisions
- Add 3 new unit tests: mutual exclusion, car-not-charging,
  compressor-idle early release

Makefile:
- make install no longer overwrites /etc/ems/ems-config.yaml if it
  already exists (prevents password loss on updates)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:06:01 +02:00
99613c52ae Initial commit: EMS — Energie Management System
Complete self-consumption optimisation system for 7 kWp PV installation:
- Prometheus collector (grid power, SOC, PV, per-phase, compressor)
- Pure decision engine with SOC gates, hysteresis, priority ordering
- Shelly Gen1/Gen2 actuator (SHA-256 Digest auth, PM power readback)
- Viessmann OAuth2 client for DHW temperature control
- PV forecast integration (forecast.solar)
- Wallbox mutual exclusion (VX3 4.6 kW AC output constraint)
- Car-not-charging detection via Shelly PM
- Compressor idle → early SG-Ready release
- Per-phase grid power for single-phase wallbox decisions
- Manual override detection and web UI with override buttons
- Full unit test coverage for decision engine
- systemd service, Makefile, complete documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:02:16 +02:00