Skip to content

feat(ev): add support for TeslaBLEProxy, better planning for surplus charging, better fuse protection (in MPC planner), AND automatic phase switching in Easee driver - #184

Closed
erikarenhill wants to merge 33 commits into
masterfrom
teslable-ev-surplus

Conversation

@erikarenhill

@erikarenhill erikarenhill commented Apr 23, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Full EV charging pipeline from plan to car:

  • Tesla BLE (via TeslaBLEProxy) — new tesla_vehicle.lua driver + DerVehicle telemetry type. Two-field config (proxy IP + VIN); "Verify connection" button in Settings → Devices; the EV planet in the hero bubble shows real BMS SoC as SoC 24 / 80 % (current / vehicle charge-limit), with ~ prefix for inferred-only sites and ★ for stale readings.
  • Energy-source policy gates in the MPC DP — per-schedule {allow_grid, allow_battery_support, only_surplus} flags that combine. Night slots unrestricted under only_surplus; allow_grid=false applies day AND night; allow_battery_support=false forbids (battW<0, evW>0) pairs. Plugs into the existing DP action loop.
  • EV charging priority over home battery — 0.25× effPrice cost bias on battery charge when an EV is plugged with outstanding demand. Breaks ties toward EV; when surplus supports both, they share.
  • Schedule persistence — new loadpoint_schedules table in state.db with future-ready columns (enabled, priority, recurrence, the three policy flags). Legacy POST /api/loadpoints/{id}/target now persists; targets survive container restart. Schema allows multi-schedule UI later without migrations.
  • Plan-time + dispatch-time fuse protection:
    • Plan-time: MPC DP rejects action combos whose predicted gridW exceeds cfg.Fuse.SafeMaxPowerW(). The plan chart matches what dispatch will deliver.
    • Dispatch-time: existing applyFuseGuard continues reactive battery scaling.
    • Both respect a new fuse.safety_margin_amps (default 0.5 A) so controlled loads never walk the breaker line.
  • Advanced YAML config editor — CodeMirror 6 lazy-loaded + vendored locally (no CDN). Live js-yaml lint, server-side Validate(), diff-confirm modal before apply, light-theme syntax colors.
  • EV plan-chart overlay — green bars for planned EV slots alongside battery bars, dashed green EV-SoC line on the SoC axis, tooltip + summary annotations ("EV 80% by 16:00, 12.4 kWh over 4 slots").
  • 1Φ/3Φ auto-switching with per-phase fuse clamp — Easee driver accepts a phases field in ev_set_current; controller picks 1Φ vs 3Φ based on wantW + hysteresis hold.

Tests

  • 11 TDD tests on the phase-switching + fuse clamp (loadpoint/controller_phase_test.go)
  • 8 tests on the YAML config endpoints (api/api_config_raw_test.go)
  • 4 tests on schedule persistence (state/loadpoint_schedules_test.go)
  • 4 tests on the MPC policy gates (mpc/loadpoint_policy_test.go)
  • 3 tests on the vehicle SoC override path (loadpoint/vehicle_test.go)
  • Full go test ./... green, including test/e2e.

Verified on hardware

Continuously deployed to a Raspberry Pi running the Pi setup (Ferroamp + Pixii + Solaredge + Easee + Tesla on TeslaBLEProxy). Confirmed end-to-end:

  • Tesla driver polls the proxy every 60 s and emits real SoC/limit
  • Loadpoint manager flips soc_source from inferred to vehicle
  • Schedule persisted across docker compose restart
  • MPC plan respects fuse — gridW caps at ~10.7 kW on a 16 A fuse with 0.5 A margin
  • EV-first bias demonstrably allocates 11 kW to EV while battery sits at 0 W
  • YAML editor round-trips with secrets preserved

Test plan

  • Merge into master and confirm e2e suite still green in CI
  • Smoke-test on a site WITHOUT a vehicle driver configured (legacy path)
  • Smoke-test on a site WITHOUT an EV loadpoint (pure battery, unchanged)
  • Verify fuse_over_limit alarm still fires at nominal MaxAmps (not the margined value)

🤖 Generated with Claude Code

erikarenhill and others added 14 commits April 23, 2026 10:07
Wires up the previously-orphaned EV settings tab (the script loaded
but no button existed in the tab bar) and extends it with a Charge
Plans section for managing loadpoints.

- Add EV tab button to settings/index.html
- ev.js: editable list of config.loadpoints — ID, driver (dropdown
  populated from config.drivers), min/max charge W, vehicle capacity,
  plug-in SoC, allowed_steps_w as comma-separated list. Add/remove
  controls; saves through the existing /api/config POST flow.
- ev.js: live-state cards per loadpoint polled from /api/loadpoints
  every 5s, with runtime target SoC + deadline inputs that POST to
  /api/loadpoints/{id}/target. Inputs are seeded once from current
  state; polling only refreshes readouts so user input isn't clobbered.
Adds runtime phase switching for EV loadpoints: the controller picks
1 or 3 phases per dispatch tick based on wantW and a configurable
split threshold, subject to a minimum hold-time to prevent contactor
flap. A per-phase fuse clamp applied pre- and post-snap guarantees
the EV can never request more current than the site fuse tolerates.

- Config: loadpoint.Config gains PhaseMode ("auto"|"1p"|"3p"), PhaseSplitW
  (default 3680 W), MinPhaseHoldS (default 60 s). Empty PhaseMode defaults
  to "3p" for backward compatibility with pre-switching installs.
- Fuse: new SiteFuse type (MaxAmps + Voltage) wired via Controller.
  SetSiteFuse from main.go using cfg.Fuse.
- Dispatch: computeCommand now returns (power_w, phases). AllowedStepsW
  is filtered by phase using a magnitude classifier (≤split → 1Φ, >split
  → 3Φ) so MPC's existing snap-to-nearest logic just works.
- Easee driver: ev_set_current accepts a `phases` field. When it changes
  vs last-sent, the driver POSTs `phaseMode` to /chargers/{id}/settings
  before updating the amp math. The dynamicChargerCurrent write still
  happens every tick.
- Tests: 11 new TDD cases cover phase decisions (auto/locked/default),
  fuse clamping per phase, step filtering, hysteresis hold, and payload
  shape. Full loadpoint + e2e suites pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New "Advanced (YAML)" tab in the settings modal: a full-text
CodeMirror 6 editor with YAML syntax highlighting, live lint,
server-side schema validation, and a diff-confirm step before any
write lands on disk.

Backend (go/internal/api/api_config_raw.go, new file per the
api/CLAUDE.md file-per-feature rule):
- GET /api/config/raw — serves the canonical YAML with the EV
  charger password resolved from state.db and injected into the
  ev_charger block. The field is tagged yaml:"-" on the struct, so
  the resolve happens via a generic-map re-marshal pass after the
  typed marshal.
- POST /api/config/raw — parses text/yaml, extracts the password
  out of the raw body via a separate generic-map pass (same reason),
  runs PreserveMaskedSecrets, routes the password to state.db,
  Validate(), atomic SaveConfig, propagates to Ctrl under
  CtrlMu.Lock — behaviourally identical to POST /api/config, just a
  different wire format.
- POST /api/config/validate — dry-run: parse + Validate + unified
  diff against the canonical current config. Both sides of the diff
  have secrets injected so an unchanged password doesn't show as a
  hunk. 64 KB body cap on all three.
- Wired into routes() in api.go.

Frontend (web/settings/tabs/advanced.js, new):
- Lazy-imports /vendor/codemirror/codemirror-yaml.bundle.js on tab
  open — operators who never use this tab pay zero startup cost.
- Hides the shell's footer Save (which would POST JSON and clobber
  in-progress YAML edits) and renders its own Validate & Save flow.
- Save click: POST /validate → render unified diff in a modal →
  operator confirms → POST /raw. Cmd/Ctrl+S also triggers save.
- Reload button pulls the canonical YAML back (shows the
  server-side normalization).

Vendor bundle (web/vendor/codemirror/, new):
- codemirror-yaml.bundle.js = CodeMirror 6 core + @codemirror/{lang-
  yaml,lint,view,state,commands,language,search,autocomplete} +
  js-yaml, minified via esbuild. ~425 KB raw, ~139 KB gzip. Served
  by the existing static handler under the main API port — never
  hits CDN / internet at runtime.

Tests (api_config_raw_test.go): 8 cases covering YAML round-trip,
parse-error → 400 with line number, validation-error → 400 with
field message, password round-trip through state.db, diff output
on /validate, body-size cap. Full suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The MPC plan already exposes a per-slot loadpoint_w + loadpoint_soc_pct
(mpc/mpc.go:157-165), but the operator-facing plan chart only drew
the battery. This overlays the EV schedule on the same chart so
operators can see PV/price/battery/EV in one frame.

- New green "EV" action band below the battery mode band, painted
  for every slot with a non-zero loadpoint_w allocation.
- EV power bars alongside the battery bars: when both share a slot,
  battery takes the left 55% and EV the right 45%; either alone
  takes the full slot width.
- Dashed green EV SoC line on the shared SoC axis, anchored at the
  current observed EV SoC from /api/loadpoints.
- Tooltip gains "EV" and "EV SoC (end)" rows when the slot has
  loadpoint_w / loadpoint_soc_pct.
- Header summary appends "EV 80% by 16:00 (12.4 kWh over 4 slots)"
  when a target is active.

fetchAll() now also pulls /api/loadpoints so the current EV SoC
anchor and target info are available in one tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Target + energy-source policy set via POST /api/loadpoints/{id}/target
now survives container restarts. Until today, loadpoint.Manager held
everything in RAM only — a docker compose up wiped the user's intent.

- New loadpoint_schedules table (STRICT-ish schema, CREATE IF NOT
  EXISTS migration) with future-ready columns: id, loadpoint_id,
  name, target_soc_pct, target_time_ms, enabled, priority, recurrence,
  plus three energy-source policy flags (allow_grid,
  allow_battery_support, only_surplus). Indexed on loadpoint_id for
  the list-per-lp query.
- state.Store helpers: SaveLoadpointSchedule (insert/update by ID),
  UpsertPrimaryLoadpointSchedule (the legacy single-target path's
  helper), ListLoadpointSchedules, AllLoadpointSchedules (startup
  restore), DeleteLoadpointSchedule. All STRICT about nil stores so
  the in-memory test harness keeps working.
- loadpoint.Manager gains SetTargetPersister + RestoreTarget. The
  legacy SetTarget now routes through an injected TargetPersister so
  every mutation that lands in RAM also hits disk. RestoreTarget
  seeds state without firing the callback — no write-back loop at
  startup.
- main.go wires the persister to state.Store and pre-loads every
  stored "primary" schedule on boot. Failures log WARN but never
  block dispatch; a readonly DB degrades to the pre-persistence
  behaviour.
- 4 new state tests cover CRUD round-trip, multi-loadpoint listing,
  and the upsert path. Existing loadpoint_test.go updated for the
  SetTarget signature change (now takes *Policy).

Schema is forward-compatible: priority / recurrence / name columns
are all there with sensible defaults, so the multi-schedule UI
(stacked commute + weekend schedules etc.) can add rows without
another migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-schedule flags steer the MPC DP action loop for EV loadpoints:

  - AllowGrid: when false, DP rejects any (battW, evW) combo that
    forces net site import > eps while EV is drawing. Hard ban on
    grid charging day and night.
  - AllowBatterySupport: when false, DP rejects concurrent EV-draw +
    battery-discharge — the home battery may not prop up EV.
  - OnlySurplus (already present): daytime slots clamp evW to the
    forecast PV surplus; night slots fall through this gate (pair
    with AllowGrid=false for strict no-import).

The three flags group under a new Policy *Policy field on
LoadpointSpec. Nil policy preserves the legacy unrestricted DP —
critical because several existing tests use zero-value LoadpointSpec
literals and would otherwise flip to restrictive-by-default. main.go
builds the policy from the restored loadpoint.Policy at probe time
so the DP consults whatever the operator last persisted.

Combinations useful day-to-day:

  - unrestricted (default)               allow_grid=1, batt=1, surplus=0
  - today's setting on the prod Pi       allow_grid=1, batt=1, surplus=1
  - strict solar-only                    allow_grid=0, batt=1, surplus=1
  - grid-or-PV, no home-batt drain       allow_grid=1, batt=0, surplus=0
  - pure PV, no battery assist           allow_grid=0, batt=0, surplus=1

POST /api/loadpoints/{id}/target accepts optional allow_grid,
allow_battery_support, only_surplus booleans. Any single flag
present triggers a policy update built off DefaultPolicy() so
partial requests (e.g. only_surplus:true alone) don't silently flip
the other two to false. Absent = preserve current schedule policy.

Tests: 4 new MPC policy-gate cases (nil unrestricted, surplus-
clamp, AllowGrid=false rejects night import, AllowBatterySupport=
false blocks discharge+EV pairing). Full mpc + api + state + e2e
suites green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Read-only vehicle-side telemetry channel, distinct from DerEV (the
charger). Groundwork for showing the vehicle's own SoC + charge
limit in the EV bubble (target: "24 / 50 %" inline) and for letting
the loadpoint manager prefer vehicle-reported SoC over the
delivered-Wh inference.

- telemetry.Store: new DerVehicle type value, String() and
  ParseDerType("vehicle"), plus inclusion in WatchdogScan's iterate
  list so staleness bubbles up like every other DER. No Kalman — a
  vehicle SoC reading is a point in time, not a power signal.
- drivers/lua.go emit switch: "vehicle" routes through the existing
  emitTelemetry path (ParseDerType already maps the string); the
  host-API comment documents the allowed payload fields (soc
  required; charge_limit_pct, charging_state, time_to_full_min,
  stale optional).
- drivers/tesla_vehicle.lua: new Lua driver against the Tesla Owner
  API shape. Transparent to transport — the typical deployment
  points base_url at a TeslaBLEProxy on the LAN, which speaks
  HTTP/JSON upstream and BLE to the car internally. Poll
  /api/1/vehicles/{id}/vehicle_data, extract charge_state.{battery_
  level, charge_limit_soc, charging_state, time_to_full_charge},
  emit. Falls back to last-known when the car is asleep (typical
  Tesla 408 response) until stale_after_s.

Consumption side — loadpoint manager reading vehicle SoC, API state
exposure, EV-bubble UI label — is deliberately not in this commit.
Driver is inert without an entry in drivers: in config.yaml.

Future work captured on the parked task #14:
  - Auto-discover which vehicle is plugged in when Easee flips
    connected false → true (iterate /vehicles, match by is_charging
    or nearest-SoC).
  - Persist chosen vehicle_id so subsequent sessions stick.
  - Support multi-vehicle configs (array instead of single id).
  - Use endpoints=charge_state&wakeup=true on the vehicle_data URL
    to reduce latency and auto-wake.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…bble

Closes the loop on the DerVehicle scaffolding: a loadpoint can now
point at a vehicle driver, the manager prefers the car's real BMS
SoC over the pluginSoC + deliveredWh inference, and the UI renders
"SoC 24 / 50 %" (current / charge-limit) in the EV planet.

Backend:
- config.Loadpoint gains `vehicle_driver` YAML field (optional) that
  buildLoadpointConfigs hands through to loadpoint.Config. Empty =
  legacy inferred-SoC behaviour; no behavioural change until the
  operator wires a driver in config.yaml.
- loadpoint.Manager gains SetVehicleTelemetry + a per-loadpoint
  VehicleSample cache. Observe now:
    * when VehicleDriver is set AND the getter returns OK && !Stale:
      currentSoCPct comes from vs.SoCPct and soc_source="vehicle"
    * otherwise falls back to pluginSoC + deliveredWh/capacity with
      soc_source="inferred"
    * stale readings are still stored for UI display so the operator
      can tell a lost connection from a never-connected vehicle
- State struct exposes VehicleSoCPct, VehicleChargeLimitPct,
  VehicleChargingState, VehicleTimeToFullMin, VehicleStale,
  SoCSource, VehicleDriver. All omitempty so clients that only read
  current_soc_pct are unaffected.
- Reload path in Manager.Load carries the vehicle cache across so a
  hot config reload mid-charge doesn't drop the vehicle readings.
- main.go wires tel.Get(driver, DerVehicle) → VehicleSample with
  JSON decode of the tesla_vehicle.lua payload. Empty driver or no
  reading → OK=false → manager falls back.
- 3 new loadpoint tests: fresh vehicle wins over inferred, stale
  vehicle falls back, no vehicle driver uses inference.

Frontend:
- next-app.js fetches /api/loadpoints in parallel with /api/status
  and caches the lookup by driver_name. EV planet construction
  injects soc / chargeLimit / socStale / socSource onto the planet
  when a matching loadpoint exists.
- ftw-energy-flow.js renderCircleNode accepts the new fields.
  Rendering:
    * "SoC 24 / 50 %" when both current and charge_limit are known
    * "SoC 24 %" when only current is known
    * "SoC ~24 %" (tilde) when soc_source="inferred", signalling the
      value came from session-Wh math, not the car
    * "SoC 24 / 50 % ★" (star) when the vehicle reading has gone
      stale past stale_after_s
  Honest provenance in the hero — operators see at a glance whether
  the number is measured, estimated, or potentially out of date.

Driver code (tesla_vehicle.lua) + telemetry.DerVehicle landed in the
prior commit; this wires them up end-to-end. Still inert on sites
without a drivers: entry; the UI falls through to legacy rendering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… form

Prior iteration exposed base_url / vehicle_id / access_token /
poll_interval_s / stale_after_s as operator-visible config.
TeslaBLEProxy handles authentication via BLE pairing with the car
itself — there is no cloud credential to manage from forty-two-watts.
Collapsing the config surface makes the driver match how operators
actually use it.

Driver (drivers/tesla_vehicle.lua):
- Config fields: `ip` and `vin`. Nothing else.
- base_url constructed as http://<ip>:8080 (TeslaBLEProxy's port).
- access_token removed; the proxy is the key, no bearer needed.
- poll cadence + stale threshold promoted to module constants
  (POLL_INTERVAL_MS = 60000, STALE_AFTER_MS = 900000) — tune in
  code if you need to, not in YAML.
- Vehicle-data fetch now passes ?endpoints=charge_state&wakeup=true
  per the TeslaBLEProxy README: narrows the payload to the fields
  we actually read AND asks the proxy to wake the car over BLE if
  it's sleeping, so every poll reflects current state rather than
  stale cache. Trades a few seconds of BLE wake latency on the
  first poll for honest fresh data thereafter.

Settings → Devices tab (web/settings/tabs/devices.js):
- New "Vehicle" fieldset rendered when the driver has the "vehicle"
  catalog capability OR a config.ip / config.vin field. Shows only
  the Proxy IP and VIN inputs — no email, password, charger-serial,
  or Connect button.
- Site-meter checkbox suppressed for drivers whose catalog
  capabilities don't include meter/pv/battery. A Tesla reads no
  grid current, so offering the site-meter role only invites
  misconfiguration. Legacy drivers without capability metadata
  continue to show the checkbox (safe default).
- Catalog-add flow seeds driver.config = {ip:"", vin:""} when the
  picked driver advertises "vehicle" capability, preserving the
  email/password + host branches for existing cloud / local-HTTP
  drivers.
- Catalog response is cached on window.FTWSettings.catalogByLua so
  the site-meter-visibility check runs synchronously on re-render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit cached the driver catalog by re-rendering the
tab after the fetch resolved so canBeSiteMeter could consult it
synchronously. Problem: every re-render calls the tab's `after` hook
again, which re-fires the fetch, which re-renders, which re-fires…
The dropdown was getting wiped every few ms, so clicking it just
collapsed the <select> before the user could pick an option.

Guard with a `needRerender` flag captured BEFORE the fetch — only
take the refresh path when the cache was actually empty. Subsequent
opens skip the re-render and populate the <select> in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lish

Shipping the Tesla vehicle integration live on the Pi surfaced a
pile of paper-cut bugs. This commit is the full end-to-end fix:

Driver (drivers/tesla_vehicle.lua):
- host.http_get returns (body, nil)/(nil, err), NOT a table with
  .body — the prior `resp.body` access evaluated to nil on every
  successful call, emit_last()'d into a silent loop. Now unpacks
  correctly.
- TeslaBLEProxy wraps the vehicle_data response DOUBLE: the real
  shape is `response.response.charge_state.*`, not the Owner-API
  `response.charge_state.*`. Parser now tries all three nesting
  levels in order (double-wrapped, OEM envelope, bare).
- TeslaBLEProxy emits `minutes_to_full_charge` (integer minutes);
  Tesla Owner API emits `time_to_full_charge` (fractional hours).
  Driver handles both, normalizing to minutes.
- Driver now calls host.set_poll_interval(60000) at init. The
  registry uses PollInterval() (seeded by set_poll_interval), NOT
  the return value of driver_poll — the 60 000 ms return was
  being ignored and the driver was polling every 5 s (the host
  default). Added info-level log at init confirming the cadence.
- `ip` config accepts bare host or `host:port` — split on the last
  colon and use the default 8080 otherwise.
- Removed cloud-credential fields (access_token, poll_interval_s,
  stale_after_s) from the operator-visible config surface: just
  `ip` and `vin` now.
- Debug log lines on every poll (body length, emit soc/limit/state)
  so the next failure mode leaves a trail.

Backend verify endpoint (go/internal/api/api_tesla_verify.go, new):
- POST /api/drivers/verify_tesla `{ip, vin}` issues a single
  vehicle_data GET from the backend and returns a shaped
  `{ok, soc_pct, charge_limit_pct, charging_state}` plus the URL
  that was probed (so the operator can eyeball it). Tries all three
  response envelopes, mirrors driver behaviour.
- Distinguishes vehicle-asleep (408) from misconfig (VIN not paired
  → 200 but empty charge_state) from network errors.

MPC effective-target (main.go LoadpointProbe):
- When the vehicle reports its own charge_limit_soc, clamp the
  MPC target at min(user_target, vehicle_charge_limit). The car
  physically stops at the in-app limit regardless of what MPC
  plans; planning past it wastes DP cycles and misleads the UI's
  "kWh planned" count. Debug-log the clamp when it activates.
- InitialSoCPct already pulls from Manager.Observe which prefers
  the vehicle BMS reading over the deliveredWh inference — no
  change needed, documented in the comment.

Settings UI (web/settings/tabs/devices.js):
- "caps is not defined" render error fixed: the catalog-entry
  lookup moved below the earlier site-meter refactor; restored
  the declaration before the isVehicleDriver check.
- Site-meter post-render DOM edit replaces the earlier
  renderTab-after-fetch approach: rendering is always sync and
  catalog-based hiding happens as a post-fetch pass. Kills an
  infinite-re-render loop that was wiping the dropdown when
  users clicked it.
- Vehicle driver form grows a Verify-connection button + status
  line. Calls /api/drivers/verify_tesla and surfaces the ok
  summary or error inline.
- `capabilities.http.allowed_hosts` auto-synced from the configured
  `ip` on input/blur. Previous "vehicle" driver entries with empty
  allowed_hosts got stuck in watchdog stale forever because the
  HTTP capability gate denied every request.
- Catalog-add flow seeds `{ip, vin}` for drivers with the
  "vehicle" capability.
- Module-level S._catalogPromise caches the fetch across tab opens.
- IP placeholder hints at `host:port` form.

Advanced YAML editor (web/settings/tabs/advanced.js):
- Light background (#f7f7f2) with matching gutter and selection
  tints. Bundled CodeMirror ships a dark-theme highlight style by
  default, so CSS overrides for the standard token classes
  (keyword / string / number / comment / propertyName) keep tokens
  readable on light.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the operator has a car plugged with outstanding charge demand
AND the DP is weighing charging the home battery in the same slot,
apply a small cost bias on the battery side (0.25 × effPrice per
kWh of battery charge) so EV wins ties.

Rationale: sites with a home battery AND an EV were seeing the DP
happily charge the battery from surplus while the EV sat at 0 W,
because both absorb the same surplus and the DP's terminal-SoC
credit on the battery tilted the optimum toward battery charge.
Operators expect the opposite — the EV has a hard deadline and a
specific kWh demand (80% − 24% × 75 kWh ≈ 42 kWh), while the
battery has soft "fill opportunistically" semantics.

Design properties:
- Only activates when an EV loadpoint exists AND is plugged AND has
  demand (TargetSoCPct > InitialSoCPct). Sites without EV see zero
  change.
- Asymmetric: penalizes (battW>0, evW=0) vs (battW=0, evW>0). When
  there's enough surplus for BOTH (battW>0 AND evW>0 both positive)
  the penalty is skipped — they share freely.
- Magnitude tuned: 0.25×effPrice is big enough to break a true tie
  but small enough that genuine price/SoC decisions (e.g. "battery
  NEEDS to charge because SoC is at floor and price drops
  tomorrow") still dominate.
- Slot-local, not summed over horizon: the DP naturally plans
  battery charging for later slots when no EV competition exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a per-fuse headroom (default 0.5 A) that every controlled load
stays below, so the plan + live dispatch never walk right up
against the hardware breaker. Alarms still watch the true rating —
the margin only constrains what forty-two-watts itself commands.

- config.Fuse gains `safety_margin_amps` (YAML: safety_margin_amps,
  default 0.5 A). MaxPowerW() returns the nominal trip rating
  unchanged (used by fuse_over_limit alarm). New SafeMaxPowerW()
  returns the controllable budget = (MaxAmps − SafetyMarginAmps) ×
  V × phases.
- main.go routes SafeMaxPowerW() to:
    * ComputeDispatch's site-wide battery fuse guard
    * aggregateBatteryLimits (per-driver cap clamp)
    * loadpoint.SiteFuse (EV per-phase clamp)
- Loadpoint controller's fuse clamp was already plumbed via
  SiteFuse{MaxAmps,Voltage}; we just hand it the margined amps now.

Default 0.5 A is conservative on a 16 A breaker (≈3% margin) —
enough to absorb live-load swings between plan time and the next
reactive replan. Operators with tighter safety requirements can
crank it to 1-2 A via the YAML editor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small but visible fixes:

1. MPC plan now respects the site fuse. Previously the DP only
   honored per-slot PowerLimits (a future DSO-curtailment knob, empty
   today), so it happily planned 15 kW imports on an 11 kW fuse when
   EV + battery both wanted to charge. Runtime applyFuseGuard caught
   it reactively — batteries got scaled down, sometimes forced into
   discharge to offset EV — but the plan chart misled operators into
   thinking the site would actually import that much.

   mpc.Service gains FuseMaxImportW. When > 0, every built slot
   inherits Limits.MaxImportW = FuseMaxImportW. main.go wires this
   to cfg.Fuse.SafeMaxPowerW() on boot and on every hot-reload of
   the fuse config. The DP then rejects action combos whose
   predicted gridW would breach the safe budget, so the plan chart
   matches what dispatch can actually deliver.

   Dispatch-layer applyFuseGuard is unchanged — still a belt-and-
   braces reactive clamp for live-load surprises where the forecast
   and reality diverge.

2. Settings modal inputs (.modal-body input / select / textarea)
   get a brighter well (rgba white 0.06→0.10 on hover/focus) with an
   accent-tinted border so each field visibly separates from the
   modal background. Previously the --surface2 fill + near-black
   border made fields blend into the dialog, especially the white-
   on-very-dark text. Adds a subtle focus ring using the --accent
   color. Placeholder contrast raised from ~0.25 alpha (unreadable)
   to 0.38.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@erikarenhill

Copy link
Copy Markdown
Collaborator Author

@frahlg här får du något att bita i :) Funkar klockrent hemma hos mig iallafall

The Settings → Planner tab had a "Mode" select tied to
planner.mode in config.yaml. The dashboard's mode buttons
(planner_self / planner_cheap / planner_arbitrage) bypass
config.yaml entirely — they persist to state.db under key "mode"
and at boot main.go (see lines 173+738) replays that into
mpcSvc.SetMode. Result: the dashboard ran the DP in arbitrage
while Settings still showed self_consumption, confusing operators.

Cleanest fix is to stop advertising planner.mode as an operator
control: replace the select with a one-liner pointing at the
Control page. The YAML field stays in place as the fallback MPC
default when state.db has nothing yet, and can still be edited
via the Advanced (YAML) tab for truly exotic setups.
Restores the Mode dropdown but wires it to /api/mode — the same
endpoint the dashboard mode-buttons use — instead of the dead YAML
planner.mode field. One source of truth (state.db), updated from
either surface and reflected by both.

- Dropdown options (self_consumption / cheap_charge / arbitrage)
  map 1:1 to dashboard modes (planner_self / planner_cheap /
  planner_arbitrage) via a pair of lookup tables.
- On tab open, after() fetches /api/mode and seeds the select with
  the live value. If the dashboard is in a non-planner mode (e.g.
  plain self_consumption, idle, peak_shaving), a one-liner below
  the select explains — picking a planner strategy will switch
  the dashboard into the matching planner_* mode.
- On change, the select POSTs /api/mode. The shell's regular
  /api/status poll (every ~5 s) picks up the new mode and the
  dashboard mode-buttons update their active state automatically.
  Status line confirms "Applied" or surfaces the error.
- Also mirrors the chosen strategy back onto the in-memory
  config.planner.mode so a subsequent footer-Save writes a
  consistent value to YAML. Purely cosmetic (main.go's
  state.db mode key wins at boot regardless) but keeps the
  YAML honest for anyone reading it.
@erikarenhill erikarenhill changed the title feat(ev): add support for TeslaBLEProxy + better planning for surplus charging feat(ev): add support for TeslaBLEProxy, better planning for surplus charging, better fuse protection (in MPC planner), AND automatic phase switching in Easee driver Apr 23, 2026
@erikarenhill

erikarenhill commented Apr 23, 2026 •

Copy link
Copy Markdown
Collaborator Author

Might be better off breaking it down in multiple PR's but I just ran claude session and syncing
to my instance to verify it working all together

erikarenhill and others added 11 commits April 23, 2026 15:06
Multi-vehicle households couldn't add a second tesla_vehicle via
Settings because the catalog-add flow picked the DRIVER table's
default id ("tesla-vehicle") for every new entry — config.Validate
rejected the duplicate name. Fix:

- config.Driver gains an optional `alias` YAML field. Pure UI label
  (e.g. "Garage Tesla", "Spouse car"). Name remains the stable
  identifier every code path keys on (telemetry, device registry,
  loadpoint.vehicle_driver references, state.db keys). Empty alias
  falls back to Name in every UI.

- Settings → Devices renders both fields per driver: the stable
  Name input (editable but flagged as load-bearing) and the Alias
  input. The per-device header line shows "Alias (name)" when an
  alias exists, otherwise just the name.

- Catalog-add "+ Add selected" auto-dedupes against existing driver
  names: the first collision becomes "$base-2", then "$base-3", etc.
  Operators can still type whatever they want in the friendly-name
  field; this only protects against the default picker producing
  duplicates. config.Validate's "duplicate name" check still
  catches manual entries that collide.
Two new behaviours fire on each connected: false → true transition
of an EV charger:

1. Vehicle auto-discovery
   When loadpoint.vehicle_driver is empty (the multi-car-household
   case) the manager iterates every live DerVehicle reading and
   scores each candidate:
     - charging_state="Charging"        → 100 (this car definitely)
     - "Stopped"/"Complete"/"Starting"  → 40  (could be this one)
     - ""                                → 40  (unknown but online)
     - "Disconnected"                    → 0   (definitely not)
     - any other state                   → 20
     - stale reading                     → halved
   Highest score wins; ties broken alphabetically by driver name.
   The pick is stored on the runtime only (not YAML) so a different
   car plugging in next time re-runs discovery. YAML override (
   explicit vehicle_driver) wins unconditionally — operators on
   single-car installs aren't affected.

   New Manager.SetVehicleSnapshot(func() map[string]VehicleSample)
   feeds the discovery; main.go wires it to
   tel.ReadingsByType(DerVehicle).

   Manager.State now exposes the EFFECTIVE driver
   (YAML | discovered) on the .vehicle_driver field — the UI sees
   the same thing whether bound or auto-discovered.

2. Auto-charge schedule
   When loadpoint.AutoChargeEnabled is true AND a plug-in
   transition finds no existing target, the manager automatically
   posts one. Target SoC: configured AutoChargeTargetSoCPct
   (default 80 %), clamped to the bound vehicle's
   charge_limit_soc when tighter. Deadline:
   AutoChargeTargetTimeLocal (default "06:00") parsed as HH:MM
   in the host timezone, next occurrence — matches the typical
   "plug in overnight, ready by 6 am" use case.

   Existing manual targets are respected (no overwrite). The
   regular SetTarget path runs so the schedule persists to
   state.db and triggers an MPC replan.

Settings → EV tab gains an "Auto-charge on plug-in" fieldset per
loadpoint with the three controls (enabled / target SoC / target
time HH:MM).

config.Loadpoint + loadpoint.Config carry the three new fields
through; buildLoadpointConfigs threads them. Tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vehicle drivers (tesla_vehicle.lua and friends) had nothing useful
to display in the dashboard's per-driver Advanced cards: the EV
detector keyed off DerEV-only fields and the fall-through layout
rendered three rows of zeros (meter / pv / battery / soc — none
of which a vehicle reports).

Backend:
- /api/status driver entries now surface DerVehicle fields:
  vehicle_soc, vehicle_charge_limit_pct, vehicle_charging_state,
  vehicle_time_to_full_min, vehicle_stale. Same shape the
  loadpoint manager already consumes — no double-source.
- Each driver entry also carries `alias` from cfg.Drivers.Alias
  when set, so every UI surface (dashboard cards, energy-flow
  bubbles, future device pickers) can render the friendly label
  alongside the stable name.

Dashboard (next-app.js renderDrivers):
- New `isVehicle` branch detects vehicle drivers by the
  presence of vehicle_soc / vehicle_charge_limit_pct and
  renders a vehicle-specific card body: SoC "56 / 80 %" line,
  charging-state with semantic color (Charging=ok, Stopped/
  Starting=warn, Disconnected=dim), time-to-full as "Xh Ym"
  or "X min", a stale-data note when applicable, and a SoC
  progress bar at the bottom matching the existing battery
  card layout.
- Card header shows the alias prominently with the raw
  driver name as a subtitle in a dim smaller font when both
  exist. Operators with two Teslas can tell them apart at a
  glance ("Nya bilen (tesla-vehicle)" vs
  "Min Tesla (tesla-vehicle-2)").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-car households (or one operator who plugs in different cars
on different days) need the schedule from session N to NOT carry
over into session N+1 — the previous car's "80 % by 06:00" is
meaningless once a different vehicle is on the cable.

Manager.Observe now treats connected: true → false as a transition
in its own right, mirroring the existing plug-in transition:

- Clears target_soc_pct and target_time on the runtime.
- Persists the cleared state via the same TargetPersister callback
  (state.UpsertPrimaryLoadpointSchedule) so state.db, /api/loadpoints,
  and the dashboard EV bubble all reflect "no schedule" within the
  next refresh.
- Daisy-chains naturally with the existing auto-discovery +
  auto-charge logic on the next plug-in: discovery re-runs from
  empty (already cleared discoveredVehicleDriver), and
  auto-charge fires because targetSoCPct is now 0.

Effect: unplug → fresh slate. Plug back in (same car or different) →
new vehicle discovered + new auto-target posted from the
loadpoint's AutoChargeTargetSoCPct/TimeLocal config (or vehicle
charge_limit_soc when tighter).

Operator manual targets posted MID-SESSION are wiped on unplug too —
that matches the user's mental model ("schedule belongs to the
plugged car, not the loadpoint").
The driver previously seeded host.set_poll_interval(POLL_INTERVAL_MS)
in driver_init, which made the registry's initial timer wait the
full 60 s before the first poll. Result: dashboards came up with
"no vehicle data" for a minute after every container restart, every
hot-reload, every config change — annoying when verifying changes.

Two-phase cadence:

1. driver_init: host.set_poll_interval(500)
   First poll fires within ~1 s of driver_added. The dashboard EV
   bubble + driver card light up before the operator can switch
   tabs.

2. driver_poll: bumps interval back to POLL_INTERVAL_MS (60 s)
   on every invocation. Idempotent; subsequent polls go back to
   the conservative cadence that keeps Tesla cloud + BLE wake-ups
   from churning. The registry re-reads PollInterval() each
   iteration so the mid-flight bump takes effect immediately.

Verified live: with two Tesla drivers (different VINs) both
initialized at T+0.5s, the first vehicle_data emit landed at
T+1.76s with soc/limit/charging_state populated.
Three small things in one commit because they touched the same
plug-in flow:

1. Refactor: pulled vehicle-discovery + auto-charge helpers into
   their own files. loadpoint/loadpoint.go was creeping past
   600 lines with three separate concerns interleaved (Manager
   core, plug-in scoring, schedule auto-posting). New layout:
     - loadpoint.go         — Config + Policy + State + Manager
     - vehicle_discovery.go — VehicleSample/{Telemetry,Snapshot,
                              Poke}Func types + pickPluggedVehicle
     - auto_charge.go       — applyAutoSchedule + HH:MM helpers
   No behaviour change; just file moves.

2. Plug-in poke: drivers/registry.go gains PokePoll(name) which
   queues a "poll" command type onto the driver's cmdCh. The
   runLoop's command handler invokes driver.Poll out-of-cycle and
   updates DriverHealth like a regular tick. Manager.Observe now
   pokes every known vehicle driver on EV plug-in transition so
   the auto-discovery scoring sees fresh BMS readings instead of
   up-to-60-s-stale ones. Best-effort + non-blocking; the channel
   has 8-slot buffer and skips poke when full (next scheduled tick
   covers the gap).

3. EV bubble label: the planet sub-text now sources the charger
   state-label from the driver itself (Easee's ev_state_label
   etc.) instead of binarising on "charging vs not". Critically,
   when ev_connected is false the bubble reads "disconnected"
   instead of the ambiguous "idle" — matches what the per-driver
   Advanced card shows for the same charger.
Auto-discovery only fired on plug-in transition (false→true). That
left two annoying gaps:

- Container restart while car is plugged → no transition fires →
  no binding → SoC source falls back to inferred even though a
  vehicle driver is sitting right there with real data.
- Operator clears YAML vehicle_driver mid-session expecting auto-
  discovery to take over → won't happen until they re-plug.

Manager.Observe now runs discovery on EVERY tick when:
  - lp.VehicleDriver (YAML) is empty AND
  - lp.discoveredVehicleDriver (runtime) is empty AND
  - the loadpoint is plugged AND
  - vehicleSnap is wired

YAML binding still wins unconditionally; once a runtime binding is
set it sticks until unplug clears it. Pokes are still constrained
to plug-in transition only (no point spamming refreshes every 5 s).

Verified live: clearing the static binding for "tesla-vehicle"
(empty proxy data) auto-promoted to "tesla-vehicle-2" on the next
tick, flipping SoC source from "inferred 25%" to "vehicle 37/70%".
… card

Operators kept hitting "wallbox commands 16A but EV draws 5A and I
have no idea why" — the in-app current limit on the Tesla itself
silently capped the negotiated current. Easee's charger reported
phases:3 and max_a:16 (correct) but the actual delivered power was
~3.5 kW instead of the expected 11. The cause was sitting in the
TeslaBLEProxy vehicle_data response all along (charge_amps and
charger_actual_current fields) but nothing surfaced it.

Driver (drivers/tesla_vehicle.lua):
- Pulls charge_state.charge_amps and charge_state.
  charger_actual_current out of the proxy response.
- Adds them to both the live host.emit("vehicle", ...) payload and
  the cached emit_last() fallback so re-emits during proxy
  hiccups carry the same values.
- Init log now reads "soc=X limit=Y state=Z amps=actual/limit" so
  the asymmetry is visible in driver logs too.

Backend (go/internal/api/api.go status handler):
- Vehicle telemetry parser surfaces vehicle_charge_amps and
  vehicle_charger_actual_current alongside the existing soc /
  charge_limit / charging_state / time_to_full / stale fields.

Dashboard (web/next-app.js renderDrivers vehicle branch):
- New "Amps (actual/limit)" row formatted "14 / 14 A". Color
  flags warn when actual lags the limit by more than 0.5 A —
  the operator immediately sees "Tesla throttled to 5 of 16 A"
  vs "Tesla taking everything offered". Skipped entirely when
  neither field is reported (older proxies without those keys).
Plug-in transition pokes the bound vehicle driver so discovery
sees fresh data, but plug-in fires the moment the cable handshake
completes — the car may still be in "Stopped" or "Starting" state
for ~5-15 s before current actually flows. The interesting
vehicle-side numbers (charge_amps, charger_actual_current,
charging_state="Charging") only become meaningful at the moment
power starts flowing. Poking on plug-in alone meant the dashboard
showed "amps: ?/?" or stale "0/0" for up to 60 s after the session
actually started.

EVSample gains a Charging bool, populated by the easee telemetry
adapter from the driver's `charging` field. Manager.Observe tracks
the previous value on the runtime and detects a charging:
false→true transition, then pokes the bound vehicle driver
(if any) once. The poke is independent of plug-in transition —
plug-in still pokes for discovery scoring, charging-start pokes
for amp/state freshness.

Charging-stop transition is intentionally NOT poked; we already
have the cached "last good" reading and the regular 60-s poll is
enough once current stops flowing.

Test harness Observe calls updated for the new signature; behaviour
of all existing tests unchanged (charging defaults to false in
fixtures that don't care).
Container restart was killing live charge sessions. Sequence:
  t+0s:   main starts, lpController.Tick runs every 5 s
  t+1-5s: MPC's first replan hasn't landed yet, c.plan(now) → ok=false
  t+1-5s: controller sent ev_set_current {power_w=0, phases=0}
  t+1-5s: Easee set dynamicChargerCurrent=0 → car stops
  t+60s:  MPC finally produces a plan with evW=11000
  t+60s:  controller sends 11 kW → Easee re-offers current
  t+60s+: but user may never notice; if they query status in the
          window they see state_label="awaiting start" reason=
          "max dynamic charger current too low" and think it
          stopped permanently.

Root: computeCommand returned (0, 0) for three distinct cases:
  a) No plan at all               — MPC hasn't decided
  b) Plan stale / out of horizon  — same
  c) Plan present but no alloc    — MPC explicitly said "pause"

Cases (a) and (b) should leave the charger alone (ride the
previous setpoint). Only (c) should send 0 W.

computeCommand now returns (W, phases, planReady). tickOne skips
the send entirely when planReady=false. Plan-exists-but-no-alloc
still returns planReady=true so the existing explicit-standdown
semantic (covered by TestTickBudgetMissingForLoadpointCommandsZero)
is unchanged. Renamed the old "no plan → 0 W" test to
"no plan → skip send" and flipped its assertion to match the fix.

Verified on the Pi: restart with an active charge session no
longer zeroes max_a; Easee kept running at 16 A / 9.5 kW across
the restart boundary.
Polling the TeslaBLEProxy with wakeup=true on every call forced a
BLE scan+connect each iteration. Combined with the new pokes on
plug-in transition + charging-start transition, the proxy's BLE
queue jammed — every subsequent poll returned HTTP 503 "Command
Disallowed" and the dashboard went stale for minutes.

Regular 60-s polls now hit the proxy without wakeup — the proxy
returns data from its own background sync (typically ≤ 5 s old,
good enough for our purposes). Wakeup stays intentionally omitted
until we add an explicit "force wake" command type for operators
who want to trigger it deliberately.

Also demoted the 503 poll-error log line to debug, since the
fallback path (emit cached data with stale=false and move on) is
the correct behaviour — no point spamming WARN once per minute
when the proxy is transiently busy.

Commit is a driver-side hygiene fix. Does NOT resolve a proxy that
is ALREADY in a stuck state (observed today: even curl direct to
the proxy returns 503 with no forty-two-watts involvement). That
requires restarting the TeslaBLEProxy process itself.
erikarenhill and others added 6 commits April 23, 2026 18:04
MPC replans on a 15-min scheduled cadence + reactive PV/load
forecast triggers. Vehicle SoC / charge-limit / source-flip was
NOT on the reactive list — a TeslaBLEProxy that wakes up after a
stale window and reveals "car is actually at 65 %, not the 40 %
we inferred" would mislead the plan for up to 15 min, wasting
grid import or missing the deadline.

New ReplanHintFunc on Manager, called from Observe when a
vehicle reading carries one of:
  * SoC delta ≥ 3 pp vs last-hinted value (ignores BMS / Tesla
    rounding flicker)
  * charge_limit_pct changed at all (operator bumped the Tesla
    app slider mid-session — direct intent)
  * soc_source flipped inferred → vehicle (proxy just recovered
    from a stale window with fresh data)

Rate-limited per loadpoint to 1 hint / minute via replanHintLast
bookkeeping. Orthogonal to MPC's internal PV/load reactive-replan
cooldown — this keeps loadpoint hints on their own budget.

main.go wires SetReplanHint to go mpcSvc.Replan(ctx) so the DP
solve doesn't block the controller tick. When mpcSvc is nil
(planner disabled) the callback no-ops.

4 new tests cover: first-reading fires on source flip, SoC delta
past threshold fires, sub-threshold deltas are swallowed by
cooldown, charge-limit change fires, rate-limit enforces 1-per-
cooldown, nil callback is a safe no-op.

Does NOT touch MPC package — the hint is purely a push trigger;
MPC's existing replan path picks up whatever the Loadpoint probe
returns on next solve, so fresh vehicle data flows through the
same pipe as today's scheduled replans.
Safety-critical. Today's flow missed exactly one scenario: house
load spikes (oven + kitchen + heater) PLUS EV at max, with
batteries already discharging at their own cap. applyFuseGuard
sees the import overage but has no charging targets left to scale
— its own comment says "operator/BMS/load shedding is the next
lever" and that lever wasn't wired. EV kept at 11 kW, main fuse
tripped.

Root: the loadpoint controller's existing per-phase fuse clamp
(fuse.MaxAmps × V × phases) assumes the EV is the only load. The
oven draws invisible to that math. Need a LIVE clamp that
subtracts non-EV load from the fuse budget on every tick.

Design: self-contained in loadpoint.Controller, no cross-layer
plumbing, converges in one dispatch cycle.

  gridW_site = load + pv + battery + ev     (site convention)
  non_ev     = gridW_site - evW
  headroom   = fuseSafeW - non_ev
  evCap      = max(0, headroom)

Controller:
- SetLiveSiteReader installs a getter returning (gridW, evW, ok)
  from telemetry.Store. Wired in main.go to the site meter +
  SumOnlineEVW. When unavailable the clamp is skipped; pre-clamp
  behaviour preserved for tests + bare-bones deployments.
- In computeCommand, the live headroom is applied BEFORE the
  snap (to narrow the search window) AND AFTER the snap
  (because snap-to-nearest can round UPWARD past the cap). The
  post-snap stage picks the largest allowed step ≤ headroom
  via the new snap.stepAtOrBelow helper, or 0 if nothing fits.
- SiteFuse gains a Phases field so the live clamp knows the
  total site power budget (PerPhaseMaxW × phases). Defaults to
  3 for backward compat with callers that passed Voltage+Amps
  but not the phase count.

Self-correcting convergence: sudden oven pushes grid past fuse
→ next 5-s tick clamps EV → grid drops → next tick may relax
clamp if load went away. No oscillation in steady state because
snap-to-nearest-allowed-step gives discrete plateaus.

Tests:
- CapsEVBelowHouseSpike — non-EV load already exceeds fuse,
  EV forced to 0.
- ShrinksEVToFitHeadroom — 7 kW house + 11 kW fuse → EV snapped
  to largest step ≤ 4 kW headroom.
- AccountsForOwnDraw — grid=12 kW but includes 11 kW EV →
  non-EV load is only 1 kW, EV gets 10 kW of real headroom
  (not clamped to 0).
- DisabledWhenReaderAbsent — preserves pre-clamp behaviour when
  no reader is wired.

Does NOT remove the existing applyFuseGuard battery scaling —
that continues to work for battery-import spikes. This clamp is
the second lever (EV throttle) the guard comment always said we
needed.
Container TZ is UTC, so `time.Date(..., now.Location())` put the
06:00 default at 06:00 UTC = 08:00 Swedish wall-clock (CEST). Plug
in at 23:00, car charges to be ready for a wakeup two hours after
the commute. Not useful.

Switch the timezone used by nextLocalTimeOfDay to a cached
Europe/Stockholm location (DST-aware: UTC+2 in summer, UTC+1 in
winter), with a fixed UTC+2 fallback when tzdata isn't compiled
into the binary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up #187 on this branch so the EV work in flight can test
against a dual-Zap deployment without waiting on the other PR
to merge. Zap driver reads config.disable_pv; devices UI reveals
a "Disable PV readings" checkbox on any driver whose catalog
advertises both meter and pv, using the existing post-fetch DOM
pass so the catalog race doesn't hide it on first paint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundle of correctness + safety + security fixes surfaced by the 5-lens
review on PR #184. Each fix stands on its own; grouped here because
they all address reviewer-flagged ship-blockers in the same feature
surface.

Correctness / safety:

* B1 phase config reachable — loadpoint.Config's PhaseMode/PhaseSplitW/
  MinPhaseHoldS fields were missing from the YAML-facing
  config.Loadpoint struct, so the entire 1Φ/3Φ auto-switch was dead on
  arrival from production config. Wire the three fields through
  buildLoadpointConfigs.
* B5 Easee phase-switch safety — on phaseMode POST failure, the driver
  was still commanding dynamicChargerCurrent computed against the
  *new* phase count, producing a 32 A single-phase command (trips a
  16 A breaker) when the backing switch didn't actually happen. Fail
  the command instead; controller retries next tick.
* B4 EV-first bias sign — 0.25×effPrice battery cost nudge inverts at
  negative spot prices, rewarding home-battery charge over EV exactly
  when free grid energy is available. Clamp to max(effPrice, 0).
* B6 legacy dispatch budget — control loop's fuseMaxW was silently
  migrated to SafeMaxPowerW(), shrinking every existing battery-only
  site's dispatch headroom by ~345 W on a 16 A / 3Φ install. Revert to
  MaxPowerW() at the dispatch clamp; safety margin stays applied at
  the MPC + loadpoint layers where it was designed for.
* B3 unplug-persist race — SetTarget and Observe's unplug-clear both
  released m.mu before calling the TargetPersister callback, so a
  concurrent POST could persist 80% and then be clobbered by the
  unplug's persisted 0. New persistMu serializes the window; unplug
  re-reads target state under RLock so whichever write was latest
  wins.
* B2 partial policy merge — POST /api/loadpoints/{id}/target now
  starts from the loadpoint's *current* Policy when only one or two
  fields are sent, instead of DefaultPolicy. A user toggling only
  only_surplus no longer silently flips allow_grid /
  allow_battery_support back to permissive.
* B9 raw-config defaults — handlePost/Validate on /api/config/raw now
  runs ApplyDefaults() before the in-memory swap so the brief window
  before fsnotify reloads doesn't drop SafetyMarginAmps to 0.

Security:

* S1 /api/drivers/verify_tesla — validates IP is RFC1918 (no loopback,
  no link-local, no public), ports restricted to 80/443/8080, VIN
  regex-validated (17 char, A-HJ-NPR-Z + 0-9), redirects refused,
  response body capped at 1 MB, upstream body never reflected to
  caller.
* S2 capabilities.http.allowed_hosts — enforced in host.http_get /
  host.http_post. Empty list keeps legacy "any host" behaviour;
  non-empty list is the URL-host allowlist the Tesla driver's docs
  already promised.
* M1 config sanity caps in Validate() — fuse.max_amps ≤ 250, phases ∈
  {1,3}, voltage ≤ 260, safety_margin_amps ≥ 0 and < max_amps.
  Prevents a YAML editor POST from installing a 1000 A "safe" budget.
* M2 strict unmarshal — /api/config/raw POST and Validate now use
  yaml.NewDecoder(...).KnownFields(true), so a typo'd key like
  max_anps: 16 is a 400 instead of silently unapplied intent.
  ev_charger.password (yaml:"-") is extracted+scrubbed from the body
  before strict-unmarshal.
* M3 YAML bomb guard — pre-parse walk rejects docs with > 64 anchors
  or nesting > 64 levels, so billion-laughs style inputs under the 64
  KB byte cap still bail before Unmarshal expands them.

All fixes are below architecture-level refactors the review also
flagged (loadpoint.go split, DerVehicle relocation, Tesla parser
dedup) — those are appropriate for follow-up PRs, not more churn in
this one. Full test suite green incl. e2e.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In-progress local work that was sitting untracked in the worktree
during the PR #184 review-fix cycle. Compiles clean, no test files
yet. Capturing on the branch so nothing's left dangling when we flip
the Pi back to the official docker-compose image.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
erikarenhill added a commit that referenced this pull request Apr 26, 2026
Adds phase-mode awareness to the loadpoint controller and the Easee
cloud driver so a 16 A site can deliver low-W charge sessions on a
single phase (≥ 6 A min), then flip up to three phases when the MPC
budget pushes past the per-phase ceiling.

Config (per-loadpoint):

  loadpoints:
    - id: garage
      driver_name: easee
      phase_mode: auto      # "1p" | "3p" | "auto" — empty == "3p"
      phase_split_w: 0      # 0 = derive from site fuse (V × A per phase)
      min_phase_hold_s: 60  # hysteresis dwell, "auto" only

The split is derived from SiteFuse.MaxAmps × SiteFuse.Voltage so a
240 V or 220 V install is handled correctly without hard-coding 230 V.
The Easee Lua driver now also accepts a `voltage` field on the
ev_set_current command so its W→A conversion uses the same site
voltage instead of a baked 230.

Safety:

  * Per-phase fuse clamp applied BEFORE step filtering AND a
    second-line check after snapping so a misconfigured AllowedStepsW
    can't punch through the breaker.
  * On 1Φ↔3Φ transitions, the Easee driver POSTs phaseMode FIRST.
    If that POST fails, the W→A write is skipped entirely — the
    failure mode would otherwise compute amps against the old phase
    count (e.g. 7400 W ÷ 1Φ = 32 A), trip a 16 A breaker. Controller
    retries next tick.
  * MinPhaseHoldS hysteresis (default 60 s) prevents contactor flap
    when the MPC budget oscillates around the split. Locked modes
    (1p, 3p) bypass the hold.
  * Empty PhaseMode ("") behaves identically to legacy locked-3Φ —
    no behaviour change for existing fleets on upgrade.

Tests: 12 (controller_phase_test.go). Covers auto split direction,
locked modes, fuse clamping at multiple breaker sizes, non-230V
voltage propagation, hysteresis, payload shape, and the zero-fuse
no-clamp guard.

Carved off the larger PR #184 review surface — extracted only the
phase-switching pieces; schedule persistence, MPC policy gates,
EV-first cost bias, and the live-load fuse clamp stay on the branch
for separate evaluation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
erikarenhill added a commit that referenced this pull request Apr 26, 2026
…#203)

* feat(loadpoint,easee): 1Φ/3Φ auto-switching with per-phase fuse clamp

Adds phase-mode awareness to the loadpoint controller and the Easee
cloud driver so a 16 A site can deliver low-W charge sessions on a
single phase (≥ 6 A min), then flip up to three phases when the MPC
budget pushes past the per-phase ceiling.

Config (per-loadpoint):

  loadpoints:
    - id: garage
      driver_name: easee
      phase_mode: auto      # "1p" | "3p" | "auto" — empty == "3p"
      phase_split_w: 0      # 0 = derive from site fuse (V × A per phase)
      min_phase_hold_s: 60  # hysteresis dwell, "auto" only

The split is derived from SiteFuse.MaxAmps × SiteFuse.Voltage so a
240 V or 220 V install is handled correctly without hard-coding 230 V.
The Easee Lua driver now also accepts a `voltage` field on the
ev_set_current command so its W→A conversion uses the same site
voltage instead of a baked 230.

Safety:

  * Per-phase fuse clamp applied BEFORE step filtering AND a
    second-line check after snapping so a misconfigured AllowedStepsW
    can't punch through the breaker.
  * On 1Φ↔3Φ transitions, the Easee driver POSTs phaseMode FIRST.
    If that POST fails, the W→A write is skipped entirely — the
    failure mode would otherwise compute amps against the old phase
    count (e.g. 7400 W ÷ 1Φ = 32 A), trip a 16 A breaker. Controller
    retries next tick.
  * MinPhaseHoldS hysteresis (default 60 s) prevents contactor flap
    when the MPC budget oscillates around the split. Locked modes
    (1p, 3p) bypass the hold.
  * Empty PhaseMode ("") behaves identically to legacy locked-3Φ —
    no behaviour change for existing fleets on upgrade.

Tests: 12 (controller_phase_test.go). Covers auto split direction,
locked modes, fuse clamping at multiple breaker sizes, non-230V
voltage propagation, hysteresis, payload shape, and the zero-fuse
no-clamp guard.

Carved off the larger PR #184 review surface — extracted only the
phase-switching pieces; schedule persistence, MPC policy gates,
EV-first cost bias, and the live-load fuse clamp stay on the branch
for separate evaluation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: phase decision moves into the Easee driver

Reframes the phase-switching design after review feedback: deciding
1Φ vs 3Φ is a charger-physics question (knows its min/max amps,
contactor switching latency, manufacturer-specific phaseMode wire
format) — it does NOT belong in the cross-driver Go controller.

Wire-format change to the ev_set_current command:

  Before:  power_w + phases + voltage   (controller decided phases)
  After:   power_w + phase_mode + phase_split_w + min_phase_hold_s
           + voltage + max_amps_per_phase + site_phases
           (driver decides phases from operator preferences + site
           fuse params)

The Go controller now does only what's universal: convert MPC's Wh
budget for the slot to a continuous wantW, snap to the loadpoint's
configured AllowedStepsW (a planning hint), and pass the operator's
phase_* preferences and the site fuse parameters through verbatim.
SetSiteFuse stays as a setter so main.go can inject the fuse values
once at startup.

Easee Lua now owns:

  * pick_phases() — locked / auto with hysteresis using host.millis()
    for the dwell timer (no Go-side state map per loadpoint anymore).
  * per_phase_amps() — W → A conversion using the controller-supplied
    voltage; clamps to the per-phase fuse ceiling AND to Easee's
    physical 0/6/32 A band (round-not-floor so 5.8 A → 6 A clears the
    minimum).
  * The phaseMode-fail-safe early-return: if the phaseMode POST fails
    on a transition, we abort the dynamicChargerCurrent write rather
    than command 32 A on a now-stale 1Φ phase count.

Tests:

  * controller_phase_handoff_test.go — 7 tests on the controller side
    asserting the cmd JSON carries the operator preferences and site
    fuse params correctly. Empty PhaseMode is omitted (legacy shape
    preserved). The Voltage 240 V case proves no 230 V hardcode.
  * phase_helpers_test.go — 9 tests on PhaseFor + FilterStepsByPhase
    (now exported as advisory reference implementations). The Easee
    Lua driver implements the same rule locally; these tests pin the
    contract for any future Go-side driver.

Driver-side phase logic is verified against real hardware in
integration testing — there's no Lua test harness in the project
yet (followup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@erikarenhill
erikarenhill marked this pull request as draft April 26, 2026 17:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant