Repository navigation
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
Conversation
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>
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.
Collaborator
Author
|
Might be better off breaking it down in multiple PR's but I just ran claude session and syncing |
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.
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>
3 of 4 tasks
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
marked this pull request as draft
April 26, 2026 17:09
3 of 6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Full EV charging pipeline from plan to car:
tesla_vehicle.luadriver +DerVehicletelemetry type. Two-field config (proxy IP + VIN); "Verify connection" button in Settings → Devices; the EV planet in the hero bubble shows real BMS SoC asSoC 24 / 80 %(current / vehicle charge-limit), with~prefix for inferred-only sites and★for stale readings.{allow_grid, allow_battery_support, only_surplus}flags that combine. Night slots unrestricted underonly_surplus;allow_grid=falseapplies day AND night;allow_battery_support=falseforbids(battW<0, evW>0)pairs. Plugs into the existing DP action loop.effPricecost bias on battery charge when an EV is plugged with outstanding demand. Breaks ties toward EV; when surplus supports both, they share.loadpoint_schedulestable in state.db with future-ready columns (enabled, priority, recurrence, the three policy flags). LegacyPOST /api/loadpoints/{id}/targetnow persists; targets survive container restart. Schema allows multi-schedule UI later without migrations.gridWexceedscfg.Fuse.SafeMaxPowerW(). The plan chart matches what dispatch will deliver.applyFuseGuardcontinues reactive battery scaling.fuse.safety_margin_amps(default 0.5 A) so controlled loads never walk the breaker line.Validate(), diff-confirm modal before apply, light-theme syntax colors.phasesfield inev_set_current; controller picks 1Φ vs 3Φ based on wantW + hysteresis hold.Tests
loadpoint/controller_phase_test.go)api/api_config_raw_test.go)state/loadpoint_schedules_test.go)mpc/loadpoint_policy_test.go)loadpoint/vehicle_test.go)go test ./...green, includingtest/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:
soc_sourcefrominferredtovehicledocker compose restartgridWcaps at ~10.7 kW on a 16 A fuse with 0.5 A marginTest plan
fuse_over_limitalarm still fires at nominal MaxAmps (not the margined value)🤖 Generated with Claude Code