Reach the charger where it is, instead of waiting for it to call

OCPP asks the charger to dial us: a public endpoint, a TLS certificate, and a
route in through the customer's router. Our own handler then demanded two more
things the V1 does not offer — TLS on a charger that connects over ws://, and
Basic auth credentials the Anker app has no field for — so every connection was
turned away before the upgrade.

Anker publishes a Modbus TCP register map for this charger, and it inverts the
problem: we dial the charger, on its own network, with no inbound reachability
to arrange. That works for a charger behind a router that OCPP cannot reach at
all.

internal/modbus is the protocol, hand-rolled against the spec like the MQTT and
WebSocket clients beside it. The plugin's modbus.go is the V1's map: the same
0-8 status enum the cloud already reports, per-phase measurements, and the
writable registers behind start, stop, current limit, boost and phase mode. A
new "modbus" control mode routes the existing control endpoints down it, so the
REST surface, the rate limit, the confirmation step and the audit trail are the
ones already there.

The commands the register map has no equivalent for say so by name rather than
failing as unknown, and a current below the charger's 6 A floor is refused
because it pauses the charge rather than slowing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-01 17:03:27 +02:00
co-authored by Claude Opus 5
parent e9a82a1cca
commit f7472bada3
13 changed files with 1658 additions and 47 deletions
+18 -9
View File
@@ -31,6 +31,7 @@ internal/
├── config/config.go # env + .env load, .env write-back
├── models/models.go # domain types + derived-field computation
├── mqtt/ # hand-rolled MQTT 3.1.1 client (Greencell EVSE telemetry)
├── modbus/ # Modbus TCP client (Anker Solix local charging control)
├── ocpp/ # OCPP 1.6J Central System (Anker Solix charging control)
├── pb/client.go # PocketBase superuser client (runtime-retargetable)
└── plugins/ # plugin system — see plugins/README.md
@@ -120,7 +121,7 @@ other users `read` or `write` access. Every car/service/part handler is gated by
| `car_shares` | grants another user access to a car | car, user, `permission` (read \| write) |
| `organizations` | tenants | name (unique) |
| `home_chargers` | the chargers a user owns, imported from a connected charger service | name, serial, vendor, model, site_name, power_kw, connector, `provider`, `provider_charger_id`, owner |
| `control_audit` | OCPP control-command audit trail | user, charger, action, result, timestamp |
| `control_audit` | charger control-command audit trail | user, charger, action, result, timestamp |
| `users` | login + profile (built-in auth collection) | name, email, avatar, `role` (user \| admin \| superadmin), `organization`, bio, theme, locale, date_format, currency, font_size, deletion_requested_at |
Every record collection except `car_shares` / `organizations` / `control_audit`
@@ -184,10 +185,12 @@ GET /api/integrations/greencell PUT /api/integrations/greencell POST /a
GET /api/integrations/greencell/chargers
GET /api/integrations/greencell/chargers/{sn}/state
# Anker Solix OCPP charging control (own/proxy mode + a live CSMS session)
# Anker Solix charging control (Modbus TCP locally, or OCPP own/proxy mode)
GET /api/integrations/anker-solix/chargers/{sn}/control
POST /api/integrations/anker-solix/chargers/{sn}/control/token
DELETE /api/integrations/anker-solix/chargers/{sn}/control/token
PUT /api/integrations/anker-solix/chargers/{sn}/control/address # local Modbus address
DELETE /api/integrations/anker-solix/chargers/{sn}/control/address
POST /api/integrations/anker-solix/chargers/{sn}/{action}
GET /ocpp/{serial} # charger dials in here (OCPP Basic auth, not bearer)
@@ -314,13 +317,19 @@ can be registered at runtime with no rebuild.
Beyond the superadmin plugin registry, the built-in connectors are exposed
per-user through `/api/integrations/*` under a **superadmin → org admin → user**
cascade (each layer supplies defaults the next can override). For Anker Solix
chargers the server additionally runs an **OCPP 1.6J Central System**
(`internal/ocpp`): when the owner sets a control mode of own/proxy, the charger
dials back in at `GET /ocpp/{serial}` (authenticated with OCPP Basic auth using a
per-charger control token, not a bearer token) and the owner can start/stop and
set charge limits, with every command rate-limited and written to a
`control_audit` trail.
cascade (each layer supplies defaults the next can override).
Anker Solix chargers can be controlled two ways, chosen per user with the control
mode. **Modbus TCP** (`internal/modbus`) dials the charger on the local network
using the register map Anker publishes for the V1; the owner enables it in the
Anker app under Settings > Integrations and saves the address it shows. It needs
no inbound connectivity, which is what makes it the workable path for a charger
behind a customer's router. The two **OCPP 1.6J** modes instead run a Central
System (`internal/ocpp`) that the charger dials back into at `GET /ocpp/{serial}`
(authenticated with OCPP Basic auth using a per-charger control token, not a
bearer token), which requires the charger to be able to reach this server. Either
way the owner can start/stop and set charge limits, with every command
rate-limited and written to a `control_audit` trail.
**Greencell** takes the other route. The HabuDen wallbox has no cloud API: it is
commissioned over Bluetooth in the Greencell GC app, pointed at an MQTT broker