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:
tajniak81andClaude Opus 5 committed 2026-09-01 17:03:27 +02:00
1 parent e9a82a1cca
commit f7472bada3
13 files changed
+1658 -47

No files matched your search

@@ -28,10 +28,14 @@ const (
ankerPlugin = "anker-solix"
ankerSecretMask = "••••••••"
// OCPP control modes for the Anker Solix charger (see internal/ocpp).
ankerControlOff = "off" // monitoring only (default)
ankerControlOwn = "own" // DriverVault is the charger's Central System
ankerControlProxy = "proxy" // DriverVault relays to Anker's cloud and injects
// Control modes for the Anker Solix charger. The first three are OCPP paths
// (see internal/ocpp) and need the charger to dial in to us; modbus is the
// local path (see the ankersolix plugin's modbus.go), where we dial the
// charger instead — the only one that works when the charger cannot reach us.
ankerControlOff = "off" // monitoring only (default)
ankerControlOwn = "own" // DriverVault is the charger's Central System
ankerControlProxy = "proxy" // DriverVault relays to Anker's cloud and injects
ankerControlModbus = "modbus" // DriverVault talks Modbus TCP to the charger on the LAN
)
// normalizeControlMode maps a raw control-mode value to a recognized mode, or ""
@@ -43,6 +47,8 @@ func normalizeControlMode(v string) string {
return ankerControlOwn
case ankerControlProxy:
return ankerControlProxy
case ankerControlModbus:
return ankerControlModbus
case ankerControlOff:
return ankerControlOff
default:
@@ -60,7 +66,8 @@ type ankerConfig struct {
ControlMode string `json:"controlMode"`
}
// ankerChargerBinding records a per-charger OCPP control token. The operator
// ankerChargerBinding is what we know about one charger the caller controls:
// its OCPP control token, its local Modbus address, or both. The operator
// installs the token into the charger (as its OCPP Basic-auth password); we keep
// only its SHA-256 hash and a short hint, never the plaintext — the token is
// shown to the owner exactly once, at generation. User-layer only, not a cascade
@@ -69,6 +76,14 @@ type ankerChargerBinding struct {
TokenHash string `json:"tokenHash,omitempty"` // sha256(token), lowercase hex
TokenHint string `json:"tokenHint,omitempty"` // last 4 chars, for the UI
AddedAt string `json:"addedAt,omitempty"`
// ModbusHost and ModbusPort address the charger's local Modbus TCP server,
// which the owner enables in the Anker app (Settings > Integrations > Modbus
// TCP; the app then shows this address). They are per charger rather than per
// account because they are a LAN address, not a credential — and unlike the
// OCPP token they are not a secret, so they are stored and shown verbatim.
ModbusHost string `json:"modbusHost,omitempty"`
ModbusPort int `json:"modbusPort,omitempty"` // 0 means the standard 502
}
// ankerStored is what we persist per user/org under pluginSettings.ankerSolix.