Commit Graph
8 Commits
Author SHA1 Message Date
tajniak81andClaude Opus 5 a809980d8b Anker health: count the chargers the panel lists, not the ones one endpoint admits to
The probe still asked get_user_bind_and_not_in_station_evchargers and read its
userBindEvChargersCount, so it reported "0 EV charger(s) bound to account" for an
account whose two chargers the panel was listing directly underneath — the same
blind spot the capability was just moved off, left behind in the health check. It
now takes the same inventory the chargers capability returns and counts that.

Authenticated with nothing on the account is degraded rather than ok, following
Greencell's rule: the half we address answers, and the empty half is the account
or the country that picks the regional server, so the message says so instead of
reporting a healthy connection to nothing. A count reached with some view missing
says how many views stayed silent, because the number is then a floor rather than
a total. The web panel colours degraded amber, as it already did for Greencell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:46:02 +02:00
tajniak81andClaude Opus 5 e138fad3f4 Anker: every charger on the account, not just the ones outside a station
get_user_bind_and_not_in_station_evchargers is the only list the connector ever
asked for, and its name says exactly what it withholds. A charger that belongs to
a system is not in it. Its userBindEvChargersCount, though, counts every charger
bound to the account — so an owner with two chargers in a system got "authenticated;
2 EV charger(s) bound to account" from the health probe and an empty list from the
capability that is supposed to show them. A working login that finds nothing.

So the capability now asks every view the cloud has and merges them by serial. The
standalone list still answers for chargers standing on their own; get_site_list
walks the systems and reads each one through get_scen_info, falling back to
get_system_running_info where that is silent — the power-service / HES split
charger-state already knows; and get_relate_and_bind_devices contributes model,
firmware and the Wi-Fi flag, and discovers anything in the A519 family that the
first two missed. Whichever way a charger was registered, one of the three has it.

The merge is first-writer-wins per field rather than last view overwriting: the
standalone record knows the name, the site record knows the live state, and neither
should blank what the other established. A view that fails is a warning on the
document instead of an error on the call, because one dead endpoint should not
cost the chargers the other two found. Only losing all three is a failure. When
nothing comes back at all the response says so in its own words and names the
remaining suspect — country picks the regional server, and the wrong one
authenticates happily and shows an empty account.

The other half of "not showing any chargers" was that neither client ever showed a
list. The serial was a text box, and the number is printed on a charger hanging on
a wall. Both apps now list what the account holds — name, serial, model, site,
state, an offline badge — and hand the serial to the OCPP control card instead of
asking anyone to go and read it. Where control is off the list still stands on its
own, as the answer to the first question an owner has after entering credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:13:36 +02:00
tajniak81andClaude Opus 5 e648634ce1 The plugin list, grouped by what a plugin actually is
Category has been in the plugin contract since it was written — apis-external,
drives-external, drives-local — and every builtin declared the same one, so it
grouped nothing. Two of the three talk to a wallbox and one talks to a car
manufacturer, and those are different questions an operator arrives with: the
Toyota card is where a driver's account gets linked, the Anker and Greencell
cards are where a charger's broker and credentials live. So vehicles and
chargers join the constants and the three builtins say which they are.

The panel groups on that field rather than on a list of names, which is what
keeps an external plugin from needing panel code. Tab order mirrors the
constants; a category with nothing in it gets no tab, and a single group hides
the bar entirely, so an install with one connector looks exactly as it did.
A category the panel does not recognise — or an empty one — falls to the
external-APIs tab rather than vanishing, because a plugin nobody can see is a
plugin nobody can disable. The selected tab falls back to the first group when
its own goes away, which is what removing the last external plugin does.

Registration still asks only for name, base URL and provider, so a plugin
registered at runtime lands under Other APIs until its manifest names a
category. That path already works and is the honest default: the panel is
guessing about a service it has never spoken to, and the service can say.

The header lockup is the other half. It was a copy of the Web App's mark rather
than the same mark, and copies drift — a 32px icon against 28, a 24px wordmark
against 21.6, "Driver" at text-strong instead of white, "Vault" a step lighter
than brand-400. The Web App's Logo.vue moves in verbatim, props included. The
one thing it cannot inherit is which variant to render: the Web App's rail is
always dark, while this panel flips with its own theme toggle, so on-dark is
bound to the theme and the hand-rolled bar fills that existed to survive that
flip are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 23:30:56 +02:00
tajniak81andClaude Opus 5 5e6b8b4b1c Anker Solix: the charger's mode, and the modes it can be moved into
The connector was written against anker-solix-api v3.7.0 and upstream is at
3.8.1 now. The reassuring half of the check first: nothing we depend on moved.
The passport/login ECDH exchange, the headers, and every endpoint path this
plugin calls are identical across v3.7.0...v3.8.1 — the only apitypes movement
touching an EV charger was get_device_rfid_cards being reordered within its own
dict. The 400 new lines in charger.py are the A2345 USB charger, which shares a
filename with our device and nothing else.

What did land for the V1 is two entries in the release notes, and both are MQTT:
3.8.0 gave standalone chargers the usage-mode entity they were missing, 3.8.1
added a switch that reads those modes as a plain on/off so EVCC and its like
have a binary to hold. We control chargers over OCPP, not MQTT, so the command
path is not ours to port. The reading of state underneath it is, and that half
does come over the cloud.

So charger-state. The status code arrives under two different names depending on
which system family a site belongs to — operating_state inside a scene's
charging_pile_list, evChargerStatus inside HES system running info — and
upstream's poller quietly renames both to ev_charger_status on ingest, which is
the tell that they are the same number. We ask both and merge, because a site
answering only one of them is the normal case rather than a fault; the call
fails only when neither view is there. chargerMode and chargerModeOptions then
follow ev_charger_mode_state and ev_charger_mode_options as written, including
the rule that a stopped charger is startable only from standby, and the binary
is the same one 3.8.1 chose: everything that is not stop_charge counts as on.

The gap worth naming is that the boost flag and the plug and start countdowns
reach upstream over MQTT and never over the cloud, so three of the six modes
cannot occur here. That is not a bug to be found later — chargerMode takes them
as parameters and the callers pass their zero values, so the day an MQTT source
exists the derivation is already correct and only its inputs change. The package
doc says so in the scope list beside the other limits.

Five endpoints upstream has had all along and we never exposed come with it,
all EV-charger-scoped: the site scene, energy_analysis under device_type
ev_charger, a charger's RFID cards, Anker's own OCPP endpoint list, and one
vehicle's details. charger-status takes the featuretype it was hardcoding at 1,
since upstream's exporter asks for both 1 and 2 and there was never a reason for
us to see only half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:39:30 +02:00
tajniak81andClaude Opus 5 ee4ac441be Plugins: drop the plugins.json migration, and the volume it needed
The project has no public installs, so there is nothing to migrate from.
MigrateLegacyFile, the file-backed Store it read through, PLUGINS_FILE and
the legacy path threaded through the Server all go. What is left is one
store, PocketBase, and a plugins package that touches no filesystem at all.

That was the last thing keeping api_data alive, so the volume goes too. All
four compose files now declare exactly one volume, pb_data, and the
standalone API Server compose declares none - it talks to an external
PocketBase and has nothing of its own to keep. Backing up the stack is
backing up one path again.

Both images get simpler for it. The API Server image loses VOLUME /data and
the su-exec entrypoint that existed only to fix a mounted volume's
ownership, so it goes back to a plain USER app; its working directory is
now /app and holds nothing. The AIO image loses its second volume and
chowns only /pb/pb_data.

One consequence worth stating plainly, because it is a small regression
rather than a no-op. The panel's Settings -> PocketBase and Settings -> Web
App screens write .env in the working directory, which is now ephemeral. In
the multi-container stack that changes nothing: compose sets all five of
those keys as container environment, and loadDotEnv only applies a key that
is not already set, so the file could never win a restart there anyway. In
the AIO image it did win for POCKETBASE_ADMIN_EMAIL/_PASSWORD, which are
not in that container's environment - so a service account fixed from the
panel now lasts only until the container is recreated. Both READMEs say so.
Moving those two screens into the app_settings singleton would close it
properly; the PocketBase URL and credentials cannot follow, since they are
how the database is reached in the first place.

go build, go vet and go test ./... pass; the compose files parse and each
resolves to a single pb_data volume. Not verified: no Docker CLI here, so
neither image was built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:41:02 +02:00
tajniak81andClaude Opus 5 9bd5c523c4 Plugins: the global layer moves into the database, beside the other two
The integration cascade stored its top layer differently from the two below
it: org (L2) and user (L3) plugin config lived in PocketBase, in a
pluginSettings field, while the global (L1) layer sat in a plugins.json
next to the binary. That split was accretion rather than design - the file
was the whole store in the v1 MVP, and the per-tenant layers were later
built on PocketBase and layered on top of it instead of replacing it.

It also cost something real. plugins.json was a second state store with
different durability from pb_data: its own volume, its own ownership, its
own backup. Losing pb_data is unmissable; losing api_data was silent, which
is how "every plugin comes back disabled after a redeploy" happened.

L1 now lives in the app_settings collection - one record keyed "global",
holding its settings in a pluginSettings field, the same mechanism and the
same field name the layers below use. The documents still differ in shape,
because only L1 carries enable state and the registration of external
plugins, but the storage is no longer a special case.

The Manager grows a Store seam (PocketBase in production, file for the
import, memory for tests) and, more importantly, a loaded gate. Settings in
a database mean the store can be unreachable at boot - a cold stack, or a
service account still to be set from the panel. That must not read as "no
plugins configured", or the first save would write emptiness over real
settings. So until a read succeeds the Manager stays unloaded, every
mutation is refused, /api/admin/plugins* answers 503, and a background
retry backs off to two minutes. The same gate covers a document that will
not parse: it is never replaced by one built from an empty map, which is a
stronger guarantee than the .corrupt backup it replaces.

Writing to a store also revealed a hole in the previous fix. Classifying a
save failure as errPersist was left to each Store, and a store that
returned a plain error would fall through to the "saved, but the plugin
failed to start" branch and be reported as a 200 - the same silent-success
bug through a different door. The Manager now classifies, whatever the
Store returns; a test pins it.

Upgrades are automatic: on the first boot that finds no settings in the
database, an existing plugins.json is imported and renamed to
plugins.json.migrated. The import is refused if the store is merely
unreachable, or if the file does not parse, so a stale or broken file can
never overwrite live settings. /data is still needed - the panel rewrites
.env there when it retargets PocketBase - but plugin settings no longer
depend on it.

21 tests in internal/plugins cover both stores, including the production
path against a fake PocketBase: create-then-update of the singleton,
round-trip across a restart, an outage that leaves settings intact, a
missing collection reading as not-ready rather than empty, and the import
running exactly once. go build, go vet and go test ./... pass. Schema
changes are mirrored into scripts/setup-pocketbase.mjs as that file
requires. Not verified: no Docker CLI here, so no image was built and the
bootstrap of app_settings against a real PocketBase is untested outside the
fake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:52:47 +02:00
tajniak81andClaude Opus 4.8 a1519f6e89 Add OCPP control for the Anker Solix EV charger (Own/Proxy CSMS)
The Anker Solix connector was read-only (cloud monitoring only). Add an
OCPP 1.6J control path with a per-user, cascading control mode:

  - off   monitoring only (default, unchanged behavior)
  - own   DriverVault is the charger's Central System (full control)
  - proxy DriverVault relays to Anker's cloud and injects commands

New internal/ocpp subsystem (stdlib-only, hand-rolled RFC 6455): a CSMS
with session management, inbound dispatch, and typed control commands
(RemoteStart/Stop, SetChargingProfile current limit, ChangeAvailability,
Reset, UnlockConnector, TriggerMessage, Get/ChangeConfiguration). Own- and
proxy-mode paths are verified end-to-end against a simulated charge point.

The charger connects to /ocpp/{serial}, authenticated with OCPP Basic auth
(serial + a per-charger control token) resolved to the owning user via an
in-memory token index. Control REST endpoints mirror the monitoring ones and
reuse the same cascade gate plus a live-session check. controlMode is a new
cascade field (global -> org -> user) advertised as a select on the plugin.

Frontend: control-mode select + provisioning card in Settings, and a real
Start/Stop/limit/reset control panel in Charging, gated on the active mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:40:22 +02:00
tajniak81andClaude Opus 4.8 0793b5ec8e Add a built-in Anker Solix V1 Smart EV Charger plugin
A Go re-implementation of the auth and read-only data flow from
thomluther/anker-solix-api, scoped to the V1 Smart EV Charger (A5191)
and adapted to DriverVault's plugin contract.

Login is a custom ECDH (P-256) + AES-256-CBC password exchange against
passport/login, yielding a ~7-day auth token plus gtoken = md5(user_id)
for subsequent requests; a fresh login covers expiry and 401/403. The
country code routes to the EU or global Anker server.

Exposes read-only capabilities (chargers, charger-status, charge-stats,
charge-orders, ocpp-info, devices, sites, vehicles) with a health check
that reports the bound-charger count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:12:45 +02:00