Skip to content

Tesla BLE driver + vehicle SoC + joint fuse-budget allocator + EV bubble - #211

Merged
erikarenhill merged 9 commits into
masterfrom
tesla-ble-driver
Apr 27, 2026
Merged

erikarenhill merged 9 commits into
masterfrom
tesla-ble-driver

Conversation

@erikarenhill

@erikarenhill erikarenhill commented Apr 27, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

End-to-end Tesla BLE Proxy integration plus a fuse-aware coordination layer between battery dispatch and EV charging:

  • Tesla driver pipeline — `tesla_vehicle.lua` polls TeslaBLEProxy on the LAN, emits `DerVehicle` telemetry (SoC, charge_limit, charging_state, …). Steady-state poll runs cached; once every 30 min one poll forces a BLE wakeup. First poll after startup also forces wakeup so SoC isn't stale from the previous run.
  • Joint fuse-budget allocator (`go/internal/control/dispatch.go`) — when EV draw + planned battery charge would exceed the site fuse, both are scaled proportionally so they share the budget. Battery side mutated locally; EV side published as `state.FuseEVMaxW` + `FuseSaturated` for the loadpoint controller to honor on its next tick. Battery discharge never trips it.
  • Plan-time fuse awareness (`go/internal/mpc/service.go`) — fuse plumbed into every slot's `Limits.MaxImportW` so the DP joint-plans battery + EV under fuse from the start.
  • MPC reactive replan triggers (60 s cooldown):
    • Fuse-saturation rising edge.
    • Startup — fire one replan ~2 s after `mpcSvc.Start` so the dashboard isn't waiting up to 15 min for the first scheduled tick.
    • First DerVehicle SoC — once any vehicle driver reports SoC, redo the plan with measured truth (replaces the inferred-SoC startup replan).
  • `BatteryCoversEV=false` energy-path fix — toggle was honoured on legacy reactive PI but silently bypassed in `planner_arbitrage`/`planner_cheap`. Now capped at the level the reactive path would target.
  • DerVehicle SoC overlay on `/api/loadpoints` — picks the most-likely-connected car (rank: Charging > NoPower > Stopped/Complete > Disconnected, freshness as tiebreaker) so multi-vehicle households (one wallbox + 2 Teslas) get the right car automatically. Verified live with `tesla-vehicle` + `tesla-vehicle-2`. MPC `LoadpointSpec.InitialSoCPct` uses the same source.
  • EV bubble UI (`web/components/ftw-energy-flow.js`, `web/next-app.js`) — renders `24/50%` (current/charge_limit, no spaces, no "SoC" label since "%" already says it). `~` prefix when sourced from inferred path, `★` when reading is stale. Aggregated batteries get `Avg` prefix.

Test plan

  • `go test ./...` — all green incl. e2e (27 s)
  • 5 new control regression tests cover joint allocator (scaling, discharge no-op, no-EV no-op) + BatteryCoversEV energy-path
  • Cross-compiled `linux/arm64` deployed to fortytwo (192.168.1.139); container restart shows `(startup)` wakeup on both Teslas + `mpc: startup replan completed` within 3 s
  • Verify EV bubble renders `24/50%` once a Tesla driver is online + plugged in
  • Watch a real charging session for the previously-reported oscillation (battery ramp → fuse cut → ramp loop) — should stay smooth
  • Confirm `BatteryCoversEV=false` in `planner_arbitrage` no longer drains the home battery into the EV

🤖 Generated with Claude Code

erikarenhill and others added 5 commits April 27, 2026 13:40
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>
End-to-end driver-only slice: a Tesla vehicle reports its real BMS
SoC + charge limit + charging state into forty-two-watts via a
TeslaBLEProxy on the LAN. Read-only — this PR introduces no
loadpoint/MPC/planning integration; that comes later when we use the
actual SoC to compare against planned charging.

Driver
- drivers/tesla_vehicle.lua against the Tesla Owner API shape served
  by TeslaBLEProxy. Two YAML fields: ip + vin. Polls
  /api/1/vehicles/{vin}/vehicle_data?endpoints=charge_state every
  60 s, falls back to last-known when the car is asleep, marks
  stale after 15 min. Surfaces charge_amps + charger_actual_current
  so the driver card can show "Tesla throttled to 5 of 16 A".

Driver host
- DerVehicle telemetry type added alongside DerMeter/PV/Battery/EV.
  Carries soc/charge_limit_pct/charging_state/time_to_full_min/stale
  in Data; RawW is always 0 (vehicle readings don't contribute to
  dispatch math, only to UI + future planning compare).
- host.emit("vehicle", {...}) routes through the existing emit path.
- HTTPAllowedHosts added to HostEnv: drivers granted the http
  capability can be locked to a specific allowlist via
  capabilities.http.allowed_hosts. Empty list keeps the legacy
  any-host behaviour. Implemented as URL-host check, port-agnostic.
- Registry wires the allowlist from config when present.

API
- POST /api/drivers/verify_tesla — operator-facing "Verify connection"
  probe. Hardened against SSRF: ip is constrained to RFC1918 v4 only,
  port restricted to 80/443/8080, VIN regex-validated, redirects
  refused, response body never reflected, body cap 1 MB.
- GET /api/status driver entries surface vehicle_soc /
  vehicle_charge_limit_pct / vehicle_charging_state /
  vehicle_time_to_full_min / vehicle_charge_amps /
  vehicle_charger_actual_current / vehicle_stale.

UI
- Settings → Devices renders a "Vehicle" fieldset for drivers whose
  catalog entry advertises capability "vehicle" (or whose config
  shape declares ip/vin). Two inputs (Proxy IP + VIN) plus a Verify
  Connection button that calls the new endpoint and shows the result
  inline. Catalog-add seeds {ip:"", vin:""} for vehicle drivers.
  IP changes auto-sync into capabilities.http.allowed_hosts so a
  fresh tesla driver doesn't bounce off the http allowlist.
- Per-driver dashboard card grows a vehicle branch: SoC "X / Y %",
  charging state with semantic color, Amps (actual/limit) row that
  warns when the car throttles below the offered current, time-to-
  full, stale indicator, and a SoC progress bar.

What's intentionally NOT in this PR
- Loadpoint VehicleDriver binding / auto-discovery on plug-in
- MPC/planner integration (compare actual SoC to planned)
- Hero EV bubble showing vehicle SoC
- Plan-chart EV slot rendering
- Auto-charge schedule + AutoChargeTargetTimeLocal
- Driver alias UI

Those live in PR #184 and will land in follow-up PRs once we want
to plan against the actual reading.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The steady-state poll deliberately omits wakeup=true so the proxy
serves cached data — an early iteration that did wakeup=true on every
poll caused HTTP 503 storms when polling overlapped with operator pokes.
But the cached-only mode lets SoC drift forever while the car sleeps.

Compromise: every WAKEUP_INTERVAL_MS (30 min) a single poll attaches
wakeup=true, forcing one BLE wake. Logged at info so operators can see
the cadence in the journal. Other 29 polls/hour stay cached, preserving
the 503-avoidance guarantee.

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

Three coupled fixes for the operator-reported oscillation when EV
charging and the plan wants to charge battery simultaneously:

1) Plan-time: feed the site fuse into every MPC slot's
   Limits.MaxImportW so the DP joint-plans battery + EV under the
   fuse from the start. Slot.Limits.allowsImport was already wired in
   the optimizer (mpc.go:450); it was just never fed.

2) Dispatch-time: joint fuse-budget allocator runs after totalCorrection
   is computed and before slew/fuseGuard. When EV draw + planned
   battery charge would bust the fuse, both are scaled proportionally
   so they share the budget instead of competing. The battery side is
   mutated locally; the EV side is published as state.FuseEVMaxW +
   FuseSaturated for the loadpoint controller to read on its next Tick.
   Battery discharge never trips the allocator (helps the fuse rather
   than competing).

3) Loadpoint controller: SetFuseEVMax wires a closure into the
   controller; computeCommand consults it before SnapChargeW so the EV
   command lands under the cap.

Also: BatteryCoversEV=false safety net on the energy-allocation path.
The toggle was honored on the legacy reactive path (gridW -=
EVChargingW) but the energy path executed plan's BatteryEnergyWh
directive blindly, letting the home battery silently feed the EV.
Cap commanded discharge to the level the reactive path would target
(zero "house" side), leaving charge untouched.

Plus a fuse-saturation rising-edge replan trigger from main.go (60s
cooldown) so the MPC redraws with current overage knowledge instead
of fighting itself.

Plus: prefer live DerVehicle SoC over the loadpoint manager's
inferred plug-in-anchor estimate when a vehicle driver is online.
Multi-vehicle ranking by Tesla charging_state semantics
(Charging/Starting > NoPower > Stopped/Complete > Disconnected/
unknown), tiebreaks by freshness — so a household with 2 Teslas
sharing one wallbox picks the car physically connected right now.

Tests: 5 new regression tests in control_test.go covering joint
allocator (scaling, discharge no-op, no-EV no-op) and BatteryCoversEV
energy-path (off enforces cap, on lets plan run). All green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the loop on DerVehicle telemetry: dashboard's EV planet now
renders the connected vehicle's actual BMS SoC plus its
vehicle-configured charge limit, with honest provenance markers.

Backend (api):
- loadpoint.State gains 6 omitempty fields (VehicleSoCPct,
  VehicleChargeLimitPct, VehicleChargingState, VehicleDriver,
  VehicleStale, SoCSource) — the manager's internal data path is
  unchanged; these are filled at API serialisation time.
- handleLoadpoints overlays the best-matching DerVehicle reading on
  every plugged-in loadpoint, picking by charging_state ranking
  (Charging/Starting > NoPower > Stopped/Complete > Disconnected/
  unknown) with freshness as tiebreaker. Multi-vehicle households
  with one wallbox get the right car automatically.
- When the overlay fires, CurrentSoCPct is replaced with the
  measured value and SoCSource flips from "inferred" to "vehicle"
  so clients can render provenance.

Frontend:
- next-app.js fetches /api/loadpoints in parallel with /api/status,
  caches by driver_name, and feeds vehicle SoC / chargeLimit /
  socStale / socSource into the EV planet builder.
- ftw-energy-flow.js renderCircleNode renders the new fields:
    "SoC 24 / 50 %"      both current + charge limit known
    "SoC 24 %"            only current known
    "SoC ~24 %"           soc_source="inferred" (session-Wh estimate)
    "SoC 24 / 50 % ★"     vehicle reading is stale

No behavioural change for sites without a DerVehicle driver — the
planet falls through to its previous kW-only rendering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@erikarenhill erikarenhill changed the title feat(tesla): TeslaBLEProxy driver + DerVehicle SoC pipeline (driver-only slice of #184) Tesla BLE driver + vehicle SoC + joint fuse-budget allocator + EV bubble Apr 27, 2026
erikarenhill and others added 4 commits April 27, 2026 16:56
Use last_wakeup_ms == 0 as the "never woken since process start"
sentinel so the FIRST poll always attaches wakeup=true. The car may
have slept for hours while forty-two-watts was down; the proxy's
cached charge_state is stale from the previous run. We want ground-
truth SoC on the dashboard immediately, not after a 30-min wait.

After the initial wake, the 30-min cadence takes over as before.
Logged as "startup" vs "30-min cadence" so operators can tell which
fired.

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

Three small but related improvements after deploying #211 to a host
with two Teslas + one wallbox:

1. Startup replan: the scheduled MPC tick is up to 15 min away. After
   process start (or container restart), fire one immediate replan
   2 s after Service.Start so /api/mpc/plan is populated and the
   dashboard stops showing "plan stale" right out of the gate.

2. First-vehicle-SoC replan: the startup replan ran with whatever
   fallback SoC was available (loadpoint manager's pluginSoC +
   deliveredWh estimate, or naive default). As soon as ANY DerVehicle
   driver comes online and reports SoC, fire one more replan with
   the measured truth — especially important when a target deadline
   is set and the inferred SoC was off by 10+ pp.

3. EV bubble label cleanup: the "%" already says "SoC" — drop the
   label text. Aggregated batteries keep an "Avg " prefix to flag the
   cross-inverter mean. EV bubble's "24 / 50 %" tightens to "24/50%"
   — the slash reads as "current of limit"; the spaces just diluted
   the glyph.

Verified live: both Tesla drivers logged "(startup)" wakeup, "mpc:
startup replan completed" landed within 3 s of container start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
operator report: "MPC plans to charge battery at 5.5 kW for hours
during PV — would way overshoot my 20 kWh battery." Investigation
showed the planner was running with capacity_wh=170000:

  pixii (Pixii):           20 000 Wh   ← actual home battery
  tesla-vehicle (Tesla):   75 000 Wh   ← Model 3 BMS (DerVehicle)
  tesla-vehicle-2 (Tesla): 75 000 Wh   ← Model Y BMS (DerVehicle)
  ──────────────────────────────────
  sum:                    170 000 Wh   ← what the DP saw

`buildBatteryCapacities` filters EV-charger drivers via
`isLikelyEVDriver`, but its allowlist only matched
{easee, ocpp, ctek, chargestorm}. `tesla_vehicle.lua` slipped through
and its `battery_capacity_wh` was summed into the stationary fleet,
causing the DP to schedule charging into 170 kWh of phantom storage
at 11 kW (default 0.5C of 85 kW per Tesla, fuse-clamped down).

Add `tesla_vehicle` and a generic `vehicle` prefix to the allowlist.
Drivers that emit DerVehicle (any vehicle, not just Tesla) should
never be counted as stationary battery capacity — the EV is already
modelled separately via the loadpoint.LoadpointSpec dimension of the
joint DP.

Live verification on fortytwo (one Pixii + two Teslas):

  Before                 After
  capacity_wh: 170 000   capacity_wh:  20 000   (actual Pixii)
  max_charge:   11 040   max_charge:    8 000   (Pixii config)
  max_discharge: 11 040  max_discharge: 8 000
  night discharge depth:                        ↓ 60.7% → ↓ 10.2%
  slots charging > 5 kW: 24                     →    4

Operator's expected "drain at night, refill from PV" plan now emerges
naturally — no terminal-price tuning needed; the previous bug was
masking the planner's correctness.

This is the surgical patch. Follow-up tracked separately:
  - filter by emitted DerType instead of Lua filename prefix
  - rename battery_capacity_wh on vehicle drivers to vehicle_capacity_wh
  - gate LoadpointSpec on actual schedule presence (issue: planner
    speculates EV charging across full 48h horizon today)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Today the MPC extends its DP with an EV-SoC dimension whenever a
loadpoint reports plugged_in — even when no target SoC or deadline
has been set. The DP then plans EV charging speculatively across the
full 48 h horizon, drawing battery + grid budget against a target
the operator never asked for.

Gate the LoadpointSpec on schedule presence: skip the spec unless the
operator has set BOTH target_soc_pct > 0 AND a future target_time.
With no schedule active, the EV is left to the loadpoint controller's
reactive surplus-only behaviour (its existing behaviour pre-MPC).

Also tighten MaxPct: use the lower of TargetSoCPct and the vehicle's
own charge_limit_pct (decoded from DerVehicle data) as the DP's SoC
ceiling. The car won't accept current beyond charge_limit anyway, so
planning past it is wasted DP grid space. Falls back gracefully when
the limit is unknown or current SoC already exceeds the ceiling.

Round-trip live verification on fortytwo:
  no schedule       → 0 EV charge slots in plan ✓
  target 50% by ... → 3 EV charge slots in plan ✓
  clear target      → 0 EV charge slots in plan ✓

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@erikarenhill
erikarenhill merged commit 60a986c into master Apr 27, 2026
1 check passed
erikarenhill added a commit that referenced this pull request Apr 29, 2026
fix(tesla-ble): apply must/should items from multi-agent review of #211
erikarenhill added a commit that referenced this pull request Apr 29, 2026
fix(tesla-ble,mpc,api): post-#211 review fixes — fuse cap republish, vehicle SoC sanity, tesla-verify hardening
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