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
@@ -28,10 +28,14 @@ const (
ankerPlugin = "anker-solix"
ankerSecretMask = "••••••••"
// OCPP control modes for the Anker Solix charger (see internal/ocpp).
// 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.
@@ -377,7 +377,26 @@ func (s *Server) handleAnkerControlStatus(w http.ResponseWriter, r *http.Request
"endpoint": s.ocppEndpoint(r, sn),
"hasToken": binding.TokenHash != "",
"tokenHint": binding.TokenHint, // last-4 only; the token is shown once at generation
"modbusHost": binding.ModbusHost,
"modbusPort": binding.ModbusPort,
}
// In Modbus mode "connected" is something we find out by asking, not by
// having been dialled: the charger holds no session with us between commands.
if res.eff.ControlMode == ankerControlModbus {
snap, ok := s.ankerModbusSnapshot(r.Context(), binding)
body["connected"] = ok
if ok {
body["status"] = snap
} else if strings.TrimSpace(binding.ModbusHost) == "" {
body["detail"] = "No local address saved yet. Enable Modbus TCP in the Anker app under Settings > Integrations, then save the address it shows."
} else {
body["detail"] = "The charger did not answer on " + ankerModbusConfig(binding).Address() + ". Check that it is powered on, on this network, and that Modbus TCP is still enabled in the Anker app."
}
writeJSON(w, http.StatusOK, body)
return
}
if sess, ok := s.ocpp.SessionFor(sn); ok {
body["connected"] = true
body["status"] = sess.Snapshot()
@@ -470,24 +489,10 @@ func (s *Server) handleAnkerControlRevoke(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, map[string]any{"serial": sn, "revoked": true})
}
// handleAnkerControlAction issues one OCPP command to a connected charger.
func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request) {
sess, ok := s.ankerControlSession(w, r)
if !ok {
return
}
who := caller(r)
sn := r.PathValue("sn")
action := r.PathValue("action")
// Rate limit per user+charger so a valid session can't hammer the actuator.
if !s.ctlRL.allow(who.ID + "|" + sn) {
s.auditControl(who, sn, action, nil, "rate-limited", nil)
writeError(w, http.StatusTooManyRequests, "too many control commands; please slow down")
return
}
var body struct {
// ankerControlBody is the union of everything a control action may be given.
// Which fields matter depends on the action, and on the transport: the OCPP
// actions take connector ids and transaction ids, the Modbus ones do not.
type ankerControlBody struct {
IdTag string `json:"idTag"`
ConnectorID int `json:"connectorId"`
TransactionID int `json:"transactionId"`
@@ -499,7 +504,29 @@ func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request
Value string `json:"value"`
Confirm bool `json:"confirm"`
Password string `json:"password"`
On *bool `json:"on"` // boost
PhaseMode *int `json:"phase"` // 0 automatic, 1 single, 2 three
Seconds int `json:"seconds"` // Modbus control timeout
}
// handleAnkerControlAction issues one command to a charger, over whichever
// transport the resolved control mode selects.
func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request) {
who, res, binding, ok := s.ankerControlGate(w, r)
if !ok {
return
}
sn := r.PathValue("sn")
action := r.PathValue("action")
// Rate limit per user+charger so a valid session can't hammer the actuator.
if !s.ctlRL.allow(who.ID + "|" + sn) {
s.auditControl(who, sn, action, nil, "rate-limited", nil)
writeError(w, http.StatusTooManyRequests, "too many control commands; please slow down")
return
}
var body ankerControlBody
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&body)
}
@@ -521,6 +548,17 @@ func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request
}
}
if res.eff.ControlMode == ankerControlModbus {
s.ankerModbusAction(w, r, who, binding, sn, action, body)
return
}
sess, ok := s.ocpp.SessionFor(sn)
if !ok {
writeError(w, http.StatusConflict, "charger is not connected to the control backend")
return
}
ctx := r.Context()
var (
status string
@@ -623,40 +661,67 @@ func controlAuditParams(action string, connectorID int, amps float64, hard bool,
return p
}
// ankerControlSession applies the full gate (cascade + control mode + a live
// session the caller actually owns) and returns the charger's session.
func (s *Server) ankerControlSession(w http.ResponseWriter, r *http.Request) (*ocpp.Session, bool) {
// ankerControlGate applies everything a control request must satisfy before any
// transport is chosen: the cascade, a control mode that is not off, and a
// charger the caller actually owns. It answers the request itself on refusal.
//
// What "owns" means depends on the mode, because the two transports bind a
// charger differently: OCPP by the control token the charger authenticates
// with, Modbus by the local address we dial. Requiring a token in Modbus mode
// would demand a credential that path never uses.
func (s *Server) ankerControlGate(w http.ResponseWriter, r *http.Request) (*callerIdentity, ankerResolution, ankerChargerBinding, bool) {
var (
res ankerResolution
binding ankerChargerBinding
)
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return nil, false
return nil, res, binding, false
}
sn := r.PathValue("sn")
userRaw := s.userPluginSettings(r.Context(), who.ID)
res := s.resolveAnker(r.Context(), who, userRaw)
res = s.resolveAnker(r.Context(), who, userRaw)
switch {
case !res.available:
writeError(w, http.StatusForbidden, "the Anker Solix integration is disabled by the administrator")
return nil, false
return nil, res, binding, false
case !res.orgEnabled:
writeError(w, http.StatusForbidden, "the Anker Solix integration is disabled for your organization")
return nil, false
return nil, res, binding, false
case !res.enabled:
writeError(w, http.StatusForbidden, "enable the Anker Solix integration in Settings first")
return nil, false
return nil, res, binding, false
}
if res.eff.ControlMode == ankerControlOff {
writeError(w, http.StatusConflict, "control mode is off; choose Own or Proxy CSMS to send commands")
return nil, false
writeError(w, http.StatusConflict, "control mode is off; choose Modbus TCP or a CSMS mode to send commands")
return nil, res, binding, false
}
// The caller must own this charger (have a token bound to it), so a serial
// alone can't be used to reach someone else's charger.
if ankerBindingFor(userRaw, sn).TokenHash == "" {
binding = ankerBindingFor(userRaw, sn)
if res.eff.ControlMode == ankerControlModbus {
if strings.TrimSpace(binding.ModbusHost) == "" {
writeError(w, http.StatusNotFound, "no local address for this charger; enable Modbus TCP in the Anker app and save the address it shows")
return nil, res, binding, false
}
return who, res, binding, true
}
// A serial alone can't be used to reach someone else's charger.
if binding.TokenHash == "" {
writeError(w, http.StatusNotFound, "no control token for this charger; generate one first")
return nil, res, binding, false
}
return who, res, binding, true
}
// ankerControlSession applies the gate and returns the charger's live OCPP
// session.
func (s *Server) ankerControlSession(w http.ResponseWriter, r *http.Request) (*ocpp.Session, bool) {
if _, _, _, ok := s.ankerControlGate(w, r); !ok {
return nil, false
}
sess, ok := s.ocpp.SessionFor(sn)
sess, ok := s.ocpp.SessionFor(r.PathValue("sn"))
if !ok {
writeError(w, http.StatusConflict, "charger is not connected to the control backend")
return nil, false
@@ -0,0 +1,242 @@
package api
// The local half of the Anker Solix control plane. Where the OCPP path in
// integrations_ankersolix_control.go waits for the charger to dial in to us,
// this one dials the charger: Modbus TCP on its own network, using the register
// map Anker publishes for the V1 (see the plugin's modbus.go). That inversion is
// the whole point — a charger behind a customer's router can be controlled this
// way without any inbound reachability, a public endpoint or a TLS certificate.
//
// The trade-off is that the server must share a network with the charger, so
// this mode is selectable per user rather than assumed.
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"drivervault/apiserver/internal/modbus"
"drivervault/apiserver/internal/plugins/builtin/ankersolix"
)
// modbusActionTimeout bounds one dial-plus-exchange. The charger is on the LAN,
// so this is generous; it exists so an unreachable address fails the request
// rather than holding it open.
const modbusActionTimeout = 10 * time.Second
// ankerModbusConfig reads a charger's local address out of its stored binding.
func ankerModbusConfig(b ankerChargerBinding) ankersolix.ModbusConfig {
return ankersolix.ModbusConfig{Host: strings.TrimSpace(b.ModbusHost), Port: b.ModbusPort}
}
// ankerModbusAction issues one control command over Modbus TCP. The gate,
// rate limit, destructive-action confirmation and audit have already run in
// handleAnkerControlAction; this decides what to write and reports the outcome.
func (s *Server) ankerModbusAction(w http.ResponseWriter, r *http.Request, who *callerIdentity,
binding ankerChargerBinding, sn, action string, body ankerControlBody) {
// Actions the register map has no equivalent for. Saying which transport is
// missing them beats a bare "unknown action" the caller cannot act on.
switch action {
case "reset", "unlock", "availability", "trigger", "config":
writeError(w, http.StatusBadRequest,
"\""+action+"\" is an OCPP command; the local Modbus connection cannot send it. Switch the control mode to a CSMS mode to use it.")
return
case "clear-limit":
// The register takes an explicit ceiling and the charger clamps anything
// above its rating, so "no limit" would mean writing a value we would have
// to guess. Asking for the real one is better than guessing wrong.
writeError(w, http.StatusBadRequest,
"the local connection has no \"clear limit\" command; send \"limit\" with the amps you want instead")
return
}
ctx, cancel := context.WithTimeout(r.Context(), modbusActionTimeout)
defer cancel()
client, err := ankersolix.ModbusDial(ctx, ankerModbusConfig(binding))
if err != nil {
s.auditControl(who, sn, action, nil, "error", err)
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
defer client.Close()
var (
result any
params = map[string]any{"transport": "modbus"}
)
switch action {
case "start":
err = ankersolix.ModbusStartCharging(ctx, client)
case "stop":
err = ankersolix.ModbusStopCharging(ctx, client)
case "limit":
params["amps"] = body.Amps
err = ankersolix.ModbusSetMaxCurrent(ctx, client, body.Amps)
case "boost":
on := body.On == nil || *body.On
params["on"] = on
err = ankersolix.ModbusSetBoost(ctx, client, on)
case "phase":
if body.PhaseMode == nil {
writeError(w, http.StatusBadRequest, "phase requires \"phase\": 0 (automatic), 1 (single) or 2 (three)")
return
}
params["phase"] = *body.PhaseMode
err = ankersolix.ModbusSetPhaseMode(ctx, client, *body.PhaseMode)
case "timeout":
params["seconds"] = body.Seconds
err = ankersolix.ModbusSetTimeout(ctx, client, body.Seconds)
case "status":
var snap ankersolix.ModbusSnapshot
snap, err = ankersolix.ModbusRead(ctx, client, true)
result = snap
default:
writeError(w, http.StatusBadRequest, "unknown control action: "+action)
return
}
outcome := "accepted"
if err != nil {
outcome = "error"
}
s.auditControl(who, sn, action, params, outcome, err)
if err != nil {
// A register the charger refused is a bad request, not a bad gateway: the
// link worked and the charger said no.
status := http.StatusBadGateway
var mbErr *modbus.Error
if errors.As(err, &mbErr) {
status = http.StatusBadRequest
}
writeJSON(w, status, map[string]any{"error": err.Error()})
return
}
resp := map[string]any{"status": outcome}
if result != nil {
resp["result"] = result
}
writeJSON(w, http.StatusOK, resp)
}
// handleAnkerControlAddress saves (or clears, on DELETE) the local address of one
// charger's Modbus TCP server — the address the Anker app shows once Modbus is
// enabled on the device.
func (s *Server) handleAnkerControlAddress(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
if !s.pb.Configured() {
writeError(w, http.StatusServiceUnavailable, "integration settings not configured on the server")
return
}
sn := strings.TrimSpace(r.PathValue("sn"))
if sn == "" {
writeError(w, http.StatusBadRequest, "missing charger serial")
return
}
var host string
var port int
if r.Method != http.MethodDelete {
var body struct {
Host string `json:"host"`
Port int `json:"port"`
}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&body)
}
host = strings.TrimSpace(body.Host)
port = body.Port
if err := validateModbusAddress(host, port); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
newDoc := mergeAnker(userRaw, func(as *ankerStored) {
if as.ControlChargers == nil {
as.ControlChargers = map[string]ankerChargerBinding{}
}
// Keep whatever else the binding holds: a charger may carry both an OCPP
// token and a local address, and switching control mode must not discard
// the other one.
b := as.ControlChargers[sn]
b.ModbusHost, b.ModbusPort = host, port
if b.AddedAt == "" {
b.AddedAt = time.Now().UTC().Format(time.RFC3339)
}
as.ControlChargers[sn] = b
})
if err := s.pb.Update(r.Context(), s.usersCollection(), who.ID,
map[string]any{"pluginSettings": newDoc}, nil); err != nil {
writePBError(w, err)
return
}
action := "address.set"
if host == "" {
action = "address.clear"
}
s.auditControl(who, sn, action, map[string]any{"host": host, "port": port}, "ok", nil)
writeJSON(w, http.StatusOK, map[string]any{"serial": sn, "modbusHost": host, "modbusPort": port})
}
// validateModbusAddress checks a user-supplied charger address. Loopback is
// refused because this address is one the server dials on the caller's behalf,
// and 127.0.0.1 would point it at services running on the server itself rather
// than at a charger. Other addresses are allowed: the charger is expected on a
// private LAN, but a routed or VPN deployment is legitimate too.
func validateModbusAddress(host string, port int) error {
if host == "" {
return errors.New("the charger's local address is required")
}
if port < 0 || port > 65535 {
return fmt.Errorf("%d is not a valid port", port)
}
if strings.ContainsAny(host, " /\\?#@") {
return errors.New("the address should be just a hostname or IP, with no scheme or path")
}
if ip := net.ParseIP(host); ip != nil {
if ip.IsLoopback() || ip.IsUnspecified() {
return errors.New("that address points at the server itself, not at a charger")
}
} else if strings.EqualFold(host, "localhost") {
return errors.New("that address points at the server itself, not at a charger")
}
return nil
}
// ankerModbusSnapshot reads a charger's live state for the status endpoint. It
// is best effort: a charger that is switched off or on another network simply
// has no snapshot, which is not an error in a status report.
func (s *Server) ankerModbusSnapshot(ctx context.Context, binding ankerChargerBinding) (ankersolix.ModbusSnapshot, bool) {
if strings.TrimSpace(binding.ModbusHost) == "" {
return ankersolix.ModbusSnapshot{}, false
}
ctx, cancel := context.WithTimeout(ctx, modbusActionTimeout)
defer cancel()
client, err := ankersolix.ModbusDial(ctx, ankerModbusConfig(binding))
if err != nil {
return ankersolix.ModbusSnapshot{}, false
}
defer client.Close()
snap, err := ankersolix.ModbusRead(ctx, client, true)
if err != nil {
return ankersolix.ModbusSnapshot{}, false
}
return snap, true
}
@@ -0,0 +1,71 @@
package api
import (
"strings"
"testing"
)
func TestValidateModbusAddressAcceptsLANAddresses(t *testing.T) {
for _, tc := range []struct {
host string
port int
}{
{"10.2.1.55", 502},
{"192.168.1.40", 0}, // 0 means the standard port
{"charger.local", 502},
{"fd00::1", 502},
} {
if err := validateModbusAddress(tc.host, tc.port); err != nil {
t.Errorf("validateModbusAddress(%q, %d) = %v, want nil", tc.host, tc.port, err)
}
}
}
// The server dials this address on the caller's behalf, so an address that
// resolves back to the server would point it at its own services.
func TestValidateModbusAddressRefusesLoopback(t *testing.T) {
for _, host := range []string{"127.0.0.1", "::1", "localhost", "LocalHost", "0.0.0.0"} {
err := validateModbusAddress(host, 502)
if err == nil {
t.Errorf("validateModbusAddress(%q) was accepted", host)
continue
}
if !strings.Contains(err.Error(), "server itself") {
t.Errorf("validateModbusAddress(%q) = %v, want it to say why", host, err)
}
}
}
func TestValidateModbusAddressRejectsMalformed(t *testing.T) {
cases := []struct {
name string
host string
port int
}{
{"empty host", "", 502},
{"a URL rather than a host", "http://10.2.1.55/", 502},
{"host with a path", "10.2.1.55/modbus", 502},
{"port above the range", "10.2.1.55", 70000},
{"negative port", "10.2.1.55", -1},
}
for _, tc := range cases {
if err := validateModbusAddress(tc.host, tc.port); err == nil {
t.Errorf("%s was accepted", tc.name)
}
}
}
func TestAnkerModbusConfigTrimsAndDefaults(t *testing.T) {
cfg := ankerModbusConfig(ankerChargerBinding{ModbusHost: " 10.2.1.55 "})
if cfg.Host != "10.2.1.55" {
t.Errorf("Host = %q, want it trimmed", cfg.Host)
}
if got := cfg.Address(); got != "10.2.1.55:502" {
t.Errorf("Address = %q, want the standard port filled in", got)
}
cfg = ankerModbusConfig(ankerChargerBinding{ModbusHost: "10.2.1.55", ModbusPort: 1502})
if got := cfg.Address(); got != "10.2.1.55:1502" {
t.Errorf("Address = %q, want 10.2.1.55:1502", got)
}
}
@@ -15,8 +15,10 @@ func TestNormalizeControlMode(t *testing.T) {
"off": "off",
"own": "own",
"proxy": "proxy",
"modbus": "modbus",
"OWN": "own",
" Proxy": "proxy",
"Modbus ": "modbus",
"": "", // unset — cascade continues to the next layer
"bogus": "", // unknown — treated as unset
}
+2
View File
@@ -451,6 +451,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/control", s.handleAnkerControlStatus)
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/control/token", s.handleAnkerControlToken)
mux.HandleFunc("DELETE /api/integrations/anker-solix/chargers/{sn}/control/token", s.handleAnkerControlRevoke)
mux.HandleFunc("PUT /api/integrations/anker-solix/chargers/{sn}/control/address", s.handleAnkerControlAddress)
mux.HandleFunc("DELETE /api/integrations/anker-solix/chargers/{sn}/control/address", s.handleAnkerControlAddress)
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/{action}", s.handleAnkerControlAction)
// OCPP WebSocket endpoint the charger dials out to (own/proxy modes). It sits
+284
View File
@@ -0,0 +1,284 @@
// Package modbus is a minimal Modbus TCP client, written against the
// MODBUS Application Protocol Specification V1.1b3 and the MODBUS Messaging on
// TCP/IP Implementation Guide V1.0b rather than pulled in as a dependency: the
// whole API server is stdlib-only, so this sits beside internal/mqtt's
// hand-rolled MQTT and internal/ocpp's hand-rolled RFC 6455 WebSocket for the
// same reason.
//
// It is deliberately scoped to what the Anker SOLIX V1 EV charger needs
// (internal/plugins/builtin/ankersolix): read a contiguous block of holding
// registers, and write one register to issue a command. Concretely that means:
//
// - Function codes 0x03 (Read Holding Registers) and 0x06 (Write Single
// Register) only. The charger exposes its whole map — read-only and
// read-write alike — in one 2xxxx address space, which is the holding
// register convention.
// - One request in flight at a time. Modbus TCP allows pipelining by
// transaction identifier; nothing here needs it, and a strictly synchronous
// exchange means a desynchronised peer cannot silently mismatch replies.
// - No reconnect and no pooling. The charger accepts at most two simultaneous
// clients (Anker's Modbus spec, §"Activate Modbus TCP"), one of which an
// operator may well be using for a debugging tool, so a Client is meant to
// be opened for one exchange and closed again rather than held open.
//
// A Client is safe for concurrent use; requests are serialized.
package modbus
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"sync"
"time"
)
// Defaults applied by Options.normalize when a field is left zero.
const (
defaultUnitID = 1
defaultTimeout = 5 * time.Second
defaultConnectTimeout = 5 * time.Second
)
// ErrClosed is returned once the connection is gone.
var ErrClosed = errors.New("modbus: connection closed")
// Protocol constants. The MBAP header is seven bytes — transaction id (2),
// protocol id (2, always zero for Modbus), length (2), unit id (1) — and is
// followed by the PDU, whose first byte is the function code.
const (
mbapLen = 7
protocolID = 0
fcReadHold = 0x03
fcWriteReg = 0x06
excMask = 0x80 // set on the echoed function code when the reply is an exception
maxReadRegs = 125 // §6.3: the largest quantity one FC03 request may ask for
)
// maxPDU bounds what this client will read back from a peer. The spec's own
// ceiling is 253 bytes of PDU, so anything larger is a broken or hostile peer
// and the stream can no longer be framed.
const maxPDU = 253
// Options configures a connection.
type Options struct {
// Address is the charger's host:port. Required. Anker's Modbus TCP server
// listens on port 502.
Address string
// UnitID addresses a device behind a gateway. The charger answers directly,
// so this is 1 unless a deployment puts a bridge in the way.
UnitID byte
// Timeout bounds one request/response exchange.
Timeout time.Duration
// ConnectTimeout bounds the TCP dial.
ConnectTimeout time.Duration
}
func (o *Options) normalize() {
if o.UnitID == 0 {
o.UnitID = defaultUnitID
}
if o.Timeout <= 0 {
o.Timeout = defaultTimeout
}
if o.ConnectTimeout <= 0 {
o.ConnectTimeout = defaultConnectTimeout
}
}
// Error is a Modbus exception response: the server understood the frame and
// refused it. It is distinct from a transport failure, because it says something
// about the request (a bad address, an out-of-range value) rather than the link.
type Error struct {
Function byte
Code byte
}
func (e *Error) Error() string {
return fmt.Sprintf("modbus: server rejected function 0x%02x: %s", e.Function, exceptionText(e.Code))
}
// exceptionText names the exception codes in §7 that this client can provoke.
func exceptionText(code byte) string {
switch code {
case 0x01:
return "illegal function"
case 0x02:
return "illegal data address"
case 0x03:
return "illegal data value"
case 0x04:
return "server device failure"
case 0x05:
return "acknowledge (request accepted, still processing)"
case 0x06:
return "server device busy"
case 0x0B:
return "gateway target device failed to respond"
default:
return fmt.Sprintf("exception code 0x%02x", code)
}
}
// Client is a connected Modbus TCP session.
type Client struct {
conn net.Conn
unitID byte
timeout time.Duration
mu sync.Mutex // serializes exchanges; one request is in flight at a time
nextTx uint16
closed bool
}
// Connect dials the server. The returned Client owns the connection; call Close
// when done.
func Connect(ctx context.Context, opt Options) (*Client, error) {
opt.normalize()
if opt.Address == "" {
return nil, errors.New("modbus: server address is required")
}
d := net.Dialer{Timeout: opt.ConnectTimeout}
conn, err := d.DialContext(ctx, "tcp", opt.Address)
if err != nil {
return nil, fmt.Errorf("modbus: cannot reach %s: %w", opt.Address, err)
}
return &Client{conn: conn, unitID: opt.UnitID, timeout: opt.Timeout}, nil
}
// Close releases the connection. It is safe to call more than once, which
// matters because callers defer it and may also close on an error path.
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return nil
}
c.closed = true
return c.conn.Close()
}
// ReadHolding reads count consecutive holding registers starting at addr
// (function code 0x03). Register values are returned in the order read.
func (c *Client) ReadHolding(ctx context.Context, addr, count uint16) ([]uint16, error) {
if count == 0 || count > maxReadRegs {
return nil, fmt.Errorf("modbus: cannot read %d registers in one request (1-%d)", count, maxReadRegs)
}
req := make([]byte, 5)
req[0] = fcReadHold
binary.BigEndian.PutUint16(req[1:], addr)
binary.BigEndian.PutUint16(req[3:], count)
pdu, err := c.exchange(ctx, req)
if err != nil {
return nil, err
}
// Reply PDU: function code, byte count, then two bytes per register.
if len(pdu) < 2 {
return nil, errors.New("modbus: truncated read reply")
}
n := int(pdu[1])
if n != int(count)*2 || len(pdu) < 2+n {
return nil, fmt.Errorf("modbus: read reply carries %d bytes, want %d", len(pdu)-2, int(count)*2)
}
out := make([]uint16, count)
for i := range out {
out[i] = binary.BigEndian.Uint16(pdu[2+i*2:])
}
return out, nil
}
// WriteSingle writes one holding register (function code 0x06). The server
// echoes the address and value it applied, which is verified here: a charger
// that clamps a value reports the clamp rather than silently diverging.
func (c *Client) WriteSingle(ctx context.Context, addr, value uint16) error {
req := make([]byte, 5)
req[0] = fcWriteReg
binary.BigEndian.PutUint16(req[1:], addr)
binary.BigEndian.PutUint16(req[3:], value)
pdu, err := c.exchange(ctx, req)
if err != nil {
return err
}
if len(pdu) < 5 {
return errors.New("modbus: truncated write reply")
}
if got := binary.BigEndian.Uint16(pdu[1:]); got != addr {
return fmt.Errorf("modbus: write reply echoes register %d, want %d", got, addr)
}
if got := binary.BigEndian.Uint16(pdu[3:]); got != value {
return fmt.Errorf("modbus: register %d accepted %d, not the %d requested", addr, got, value)
}
return nil
}
// exchange sends one PDU and returns the reply PDU, having checked the MBAP
// header, the transaction identifier and the exception bit.
func (c *Client) exchange(ctx context.Context, pdu []byte) ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return nil, ErrClosed
}
// A deadline drawn from both the context and the configured timeout, so a
// cancelled caller and a mute peer are both bounded.
deadline := time.Now().Add(c.timeout)
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
deadline = d
}
if err := c.conn.SetDeadline(deadline); err != nil {
return nil, err
}
c.nextTx++
tx := c.nextTx
frame := make([]byte, mbapLen+len(pdu))
binary.BigEndian.PutUint16(frame[0:], tx)
binary.BigEndian.PutUint16(frame[2:], protocolID)
// Length counts the unit id plus the PDU, per the messaging guide §3.1.3.
binary.BigEndian.PutUint16(frame[4:], uint16(len(pdu)+1))
frame[6] = c.unitID
copy(frame[mbapLen:], pdu)
if _, err := c.conn.Write(frame); err != nil {
return nil, fmt.Errorf("modbus: sending request: %w", err)
}
var header [mbapLen]byte
if _, err := io.ReadFull(c.conn, header[:]); err != nil {
return nil, fmt.Errorf("modbus: reading reply header: %w", err)
}
if got := binary.BigEndian.Uint16(header[2:]); got != protocolID {
return nil, fmt.Errorf("modbus: reply has protocol id %d, want 0", got)
}
length := int(binary.BigEndian.Uint16(header[4:]))
if length < 2 || length-1 > maxPDU {
return nil, fmt.Errorf("modbus: reply announces %d bytes, which is not a valid PDU length", length)
}
body := make([]byte, length-1) // less the unit id, already in the header
if _, err := io.ReadFull(c.conn, body); err != nil {
return nil, fmt.Errorf("modbus: reading reply: %w", err)
}
if got := binary.BigEndian.Uint16(header[0:]); got != tx {
// Synchronous exchange, so this is a desynchronised stream rather than a
// late reply to something else; the connection is no longer trustworthy.
return nil, fmt.Errorf("modbus: reply is for transaction %d, want %d", got, tx)
}
fc := body[0]
if fc&excMask != 0 {
if len(body) < 2 {
return nil, errors.New("modbus: exception reply carries no code")
}
return nil, &Error{Function: fc &^ excMask, Code: body[1]}
}
if fc != pdu[0] {
return nil, fmt.Errorf("modbus: reply is for function 0x%02x, want 0x%02x", fc, pdu[0])
}
return body, nil
}
+284
View File
@@ -0,0 +1,284 @@
package modbus
import (
"context"
"encoding/binary"
"errors"
"io"
"net"
"testing"
)
// serve runs a one-connection fake Modbus server. handle receives the request
// PDU and returns the reply PDU; returning nil closes the connection instead,
// which is how a mute or dying peer is simulated. The request frames the server
// saw are sent back on the returned channel once the connection ends.
func serve(t *testing.T, handle func(pdu []byte) []byte) (addr string, seen <-chan [][]byte) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
frames := make(chan [][]byte, 1)
go func() {
var got [][]byte
defer func() { frames <- got }()
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
for {
var header [mbapLen]byte
if _, err := io.ReadFull(conn, header[:]); err != nil {
return
}
length := int(binary.BigEndian.Uint16(header[4:]))
body := make([]byte, length-1)
if _, err := io.ReadFull(conn, body); err != nil {
return
}
got = append(got, append(append([]byte{}, header[:]...), body...))
reply := handle(body)
if reply == nil {
return
}
out := make([]byte, mbapLen+len(reply))
copy(out[0:], header[0:2]) // echo the transaction id
binary.BigEndian.PutUint16(out[2:], protocolID)
binary.BigEndian.PutUint16(out[4:], uint16(len(reply)+1))
out[6] = header[6]
copy(out[mbapLen:], reply)
if _, err := conn.Write(out); err != nil {
return
}
}
}()
return ln.Addr().String(), frames
}
func dial(t *testing.T, addr string) *Client {
t.Helper()
c, err := Connect(context.Background(), Options{Address: addr})
if err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
return c
}
// readReply builds a well-formed FC03 reply carrying the given register values.
func readReply(values ...uint16) []byte {
out := []byte{fcReadHold, byte(len(values) * 2)}
for _, v := range values {
out = binary.BigEndian.AppendUint16(out, v)
}
return out
}
func TestReadHoldingDecodesRegisters(t *testing.T) {
addr, seen := serve(t, func(pdu []byte) []byte {
return readReply(0x0102, 0x0304, 0xFFFF)
})
c := dial(t, addr)
got, err := c.ReadHolding(context.Background(), 20053, 3)
if err != nil {
t.Fatalf("ReadHolding: %v", err)
}
want := []uint16{0x0102, 0x0304, 0xFFFF}
if len(got) != len(want) {
t.Fatalf("got %d registers, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("register %d = 0x%04x, want 0x%04x", i, got[i], want[i])
}
}
_ = c.Close()
frames := <-seen
if len(frames) != 1 {
t.Fatalf("server saw %d frames, want 1", len(frames))
}
f := frames[0]
if pid := binary.BigEndian.Uint16(f[2:]); pid != protocolID {
t.Errorf("protocol id = %d, want 0", pid)
}
// Length counts the unit id plus the five-byte PDU.
if l := binary.BigEndian.Uint16(f[4:]); l != 6 {
t.Errorf("length field = %d, want 6", l)
}
if f[6] != defaultUnitID {
t.Errorf("unit id = %d, want %d", f[6], defaultUnitID)
}
if f[mbapLen] != fcReadHold {
t.Errorf("function code = 0x%02x, want 0x%02x", f[mbapLen], fcReadHold)
}
if a := binary.BigEndian.Uint16(f[mbapLen+1:]); a != 20053 {
t.Errorf("address = %d, want 20053", a)
}
if n := binary.BigEndian.Uint16(f[mbapLen+3:]); n != 3 {
t.Errorf("quantity = %d, want 3", n)
}
}
func TestReadHoldingRejectsBadCounts(t *testing.T) {
addr, _ := serve(t, func(pdu []byte) []byte { return readReply(0) })
c := dial(t, addr)
for _, count := range []uint16{0, maxReadRegs + 1} {
if _, err := c.ReadHolding(context.Background(), 20000, count); err == nil {
t.Errorf("count %d was accepted, want a refusal before the wire", count)
}
}
}
func TestReadHoldingRejectsShortPayload(t *testing.T) {
// The server claims three registers but sends two.
addr, _ := serve(t, func(pdu []byte) []byte {
return []byte{fcReadHold, 6, 0x00, 0x01, 0x00, 0x02}
})
c := dial(t, addr)
if _, err := c.ReadHolding(context.Background(), 20000, 3); err == nil {
t.Fatal("a truncated payload was accepted")
}
}
func TestWriteSingleEchoesAddressAndValue(t *testing.T) {
addr, seen := serve(t, func(pdu []byte) []byte {
return append([]byte{fcWriteReg}, pdu[1:5]...) // echo address + value
})
c := dial(t, addr)
if err := c.WriteSingle(context.Background(), 21000, 1); err != nil {
t.Fatalf("WriteSingle: %v", err)
}
_ = c.Close()
frames := <-seen
if len(frames) != 1 {
t.Fatalf("server saw %d frames, want 1", len(frames))
}
f := frames[0]
if f[mbapLen] != fcWriteReg {
t.Errorf("function code = 0x%02x, want 0x%02x", f[mbapLen], fcWriteReg)
}
if a := binary.BigEndian.Uint16(f[mbapLen+1:]); a != 21000 {
t.Errorf("address = %d, want 21000", a)
}
if v := binary.BigEndian.Uint16(f[mbapLen+3:]); v != 1 {
t.Errorf("value = %d, want 1", v)
}
}
// A charger that clamps a written value reports the clamp. That must surface as
// an error rather than be mistaken for the value having been applied.
func TestWriteSingleRejectsClampedValue(t *testing.T) {
addr, _ := serve(t, func(pdu []byte) []byte {
reply := append([]byte{fcWriteReg}, pdu[1:5]...)
binary.BigEndian.PutUint16(reply[3:], 160) // clamped to 16.0 A
return reply
})
c := dial(t, addr)
err := c.WriteSingle(context.Background(), 21001, 320)
if err == nil {
t.Fatal("a clamped write was reported as successful")
}
}
func TestWriteSingleRejectsWrongAddressEcho(t *testing.T) {
addr, _ := serve(t, func(pdu []byte) []byte {
reply := append([]byte{fcWriteReg}, pdu[1:5]...)
binary.BigEndian.PutUint16(reply[1:], 29999)
return reply
})
c := dial(t, addr)
if err := c.WriteSingle(context.Background(), 21000, 1); err == nil {
t.Fatal("a write echoing the wrong register was accepted")
}
}
func TestExceptionReplyBecomesError(t *testing.T) {
addr, _ := serve(t, func(pdu []byte) []byte {
return []byte{fcReadHold | excMask, 0x02} // illegal data address
})
c := dial(t, addr)
_, err := c.ReadHolding(context.Background(), 29999, 1)
var mbErr *Error
if !errors.As(err, &mbErr) {
t.Fatalf("error = %v, want a *modbus.Error", err)
}
if mbErr.Function != fcReadHold {
t.Errorf("Function = 0x%02x, want 0x%02x", mbErr.Function, fcReadHold)
}
if mbErr.Code != 0x02 {
t.Errorf("Code = 0x%02x, want 0x02", mbErr.Code)
}
}
// A reply carrying someone else's transaction id means the stream is out of
// step, so it must not be handed back as this request's answer.
func TestMismatchedTransactionIDIsRejected(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
var header [mbapLen]byte
if _, err := io.ReadFull(conn, header[:]); err != nil {
return
}
body := make([]byte, int(binary.BigEndian.Uint16(header[4:]))-1)
if _, err := io.ReadFull(conn, body); err != nil {
return
}
reply := readReply(1)
out := make([]byte, mbapLen+len(reply))
binary.BigEndian.PutUint16(out[0:], binary.BigEndian.Uint16(header[0:])+7) // wrong
binary.BigEndian.PutUint16(out[2:], protocolID)
binary.BigEndian.PutUint16(out[4:], uint16(len(reply)+1))
out[6] = header[6]
copy(out[mbapLen:], reply)
_, _ = conn.Write(out)
}()
c := dial(t, ln.Addr().String())
if _, err := c.ReadHolding(context.Background(), 20000, 1); err == nil {
t.Fatal("a reply for another transaction was accepted")
}
}
func TestClosedClientRefusesRequests(t *testing.T) {
addr, _ := serve(t, func(pdu []byte) []byte { return readReply(0) })
c := dial(t, addr)
if err := c.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if _, err := c.ReadHolding(context.Background(), 20000, 1); !errors.Is(err, ErrClosed) {
t.Fatalf("error = %v, want ErrClosed", err)
}
// Close is deferred by callers and may also run on an error path.
if err := c.Close(); err != nil {
t.Fatalf("second Close: %v", err)
}
}
func TestConnectRequiresAddress(t *testing.T) {
if _, err := Connect(context.Background(), Options{}); err == nil {
t.Fatal("an empty address was accepted")
}
}
@@ -209,9 +209,10 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
// package (internal/ocpp) — but it is advertised here so a superadmin can
// set/lock it at the global layer, and it cascades like the other fields.
{Key: "controlMode", Label: "Control mode", Type: "select", Default: "off",
Help: "How DriverVault controls the charger over OCPP. Off = monitoring only (default). Own CSMS = the charger connects directly to DriverVault. Proxy CSMS = DriverVault relays to Anker's cloud and can inject commands.",
Help: "How DriverVault controls the charger. Off = monitoring only (default). Modbus TCP = DriverVault connects to the charger on the local network (enable it in the Anker app under Settings > Integrations); this is the only mode that works when the charger cannot reach the server. The two OCPP modes need the charger to connect in to DriverVault: Own CSMS directly, Proxy CSMS relayed to Anker's cloud.",
Options: []plugins.SelectOption{
{Value: "off", Label: "Off (monitoring only)"},
{Value: "modbus", Label: "Modbus TCP (local network)"},
{Value: "own", Label: "Own CSMS (full control)"},
{Value: "proxy", Label: "Proxy CSMS (relay + control)"},
}},
@@ -0,0 +1,397 @@
package ankersolix
// Local control over Modbus TCP, the one path Anker documents publicly for this
// charger ("Anker SOLIX V1 Smart EV Charger Modbus Protocol", V1.0.0,
// 30-11-2025). It is the counterpart to the read-only cloud plugin in
// ankersolix.go: same device, same status enum, but reached over the LAN with no
// account, no cloud round-trip and no inbound connection for the charger to make
// — which is what rules OCPP out wherever the charger cannot dial us.
//
// The owner enables it in the Anker app under Settings > Integrations > Modbus
// TCP; the app then shows the charger's local IP and port. Two caveats from the
// spec that shape the code here:
//
// - At most two clients may be connected at once, and an operator's debugging
// tool may well be one of them. Connections are therefore opened per
// operation and closed again, never pooled.
// - Charging pauses on its own whenever the current is set below 6 A, so a
// limit under that floor is a pause, not a slow charge. SetMaxCurrent says so
// rather than letting a caller discover it.
//
// The spec tabulates addresses, types and gains but does not name the function
// codes; the whole map lives in one 2xxxx space with RO and RW entries side by
// side, which is the holding-register convention, so FC03/FC06 is what this uses.
import (
"context"
"encoding/binary"
"fmt"
"strings"
"time"
"drivervault/apiserver/internal/modbus"
)
// Register addresses from the protocol spec. Identity and configuration sit in
// 20000-20040, live measurements and state in 20041-20100, and the writable
// controls in 21000-21005.
const (
regProductNumber = 20000 // UINT16
regModelName = 20001 // STRING, 10 registers
regSerialNumber = 20011 // STRING, 12 registers
regSoftwareVersion = 20023 // STRING, 6 registers
regHardwareVersion = 20029 // STRING, 6 registers
regRatedPower = 20035 // INT32, W
regMinOutCurrent = 20037 // INT32
regMaxOutCurrent = 20039 // INT32
regAlarm1 = 20041 // UINT16 x12, each bit an alarm
regVoltageL1N = 20053 // UINT16, gain 10
regCurrentL1 = 20059 // UINT16, gain 100
regPowerL1 = 20062 // UINT32, W
regPowerTotal = 20068 // UINT32, W
regSessionSec = 20082 // UINT32, s
regSessionWh = 20084 // UINT32, Wh
regPWMEnabled = 20086
regPhaseMode = 20087
regChargingMode = 20088 // 0 solar+grid, 1 solar only
regLoadBalancing = 20089
regSolarBalancing = 20090
regCPVoltage = 20091
regCPSignal = 20092
regRelay1Temp = 20093 // INT16, degC
regRelay2Temp = 20094 // INT16, degC
regBoostMode = 20095
regLEDBrightness = 20096 // %
regChargingStatus = 20097 // same 0-8 enum as the cloud's operating_state
regOCPPStatus = 20099 // 0 not connected, 1 connecting, 2 connected
regMQTTStatus = 20100 // 0 not connected, 1 connected
regChargingCommand = 21000 // 1 start, 2 stop
regMaxCurrentSet = 21001 // gain 10, i.e. deciamps
regBoostSet = 21002 // 1 on, for the current session only
regTimeoutSet = 21003 // seconds, must exceed 5
regPhaseCountSet = 21005 // 0 automatic, 1 fixed single, 2 fixed three
)
// The two blocks read in one request each. Both are well inside FC03's limit of
// 125 registers, and splitting them keeps the hot path (live state) small.
const (
identityStart = regProductNumber
identityCount = 41 // 20000-20040
liveStart = regAlarm1
liveCount = 60 // 20041-20100
)
// Charging command values for regChargingCommand.
const (
cmdStartCharging = 1
cmdStopCharging = 2
)
// currentPauseFloor is the current below which the charger stops drawing power
// on its own, per the spec's note on the maximum-current register.
const currentPauseFloor = 6.0
// cpSignalNames decodes regCPSignal — the control-pilot state, which says what
// the vehicle side of the cable is doing independently of the charger's own
// status.
var cpSignalNames = map[int]string{
0: "A (12V, not connected)", 3: "B1 (9V)", 4: "B2 (9V)",
5: "C1 (6V, charging)", 6: "C2 (6V, charging)", 7: "error",
8: "D1 (3V)", 9: "D2 (3V)", 10: "E (0V)", 11: "F (-12V)",
}
// The two connection-status registers do not share an encoding: OCPP has a
// three-state one (and matches SolixOcppConnectionStatus in the cloud plugin),
// while MQTT is a plain boolean. Decoding both through one table would report a
// connected broker as "connecting".
var (
ocppStatusNames = map[int]string{0: "disconnected", 1: "connecting", 2: "connected"}
mqttStatusNames = map[int]string{0: "disconnected", 1: "connected"}
)
// ModbusConfig addresses one charger's local Modbus TCP server.
type ModbusConfig struct {
Host string
Port int
UnitID byte
}
// Address is the host:port this charger is dialled at, defaulting to Modbus's
// standard port when none was saved.
func (c ModbusConfig) Address() string {
port := c.Port
if port == 0 {
port = 502
}
return fmt.Sprintf("%s:%d", strings.TrimSpace(c.Host), port)
}
// ModbusSnapshot is the charger's live state as the register map reports it. A
// field the charger did not supply stays nil rather than zero, matching how
// accountCharger treats a value no cloud view knew.
type ModbusSnapshot struct {
// Identity, present only when the identity block was read too.
Model string `json:"model,omitempty"`
Serial string `json:"serial,omitempty"`
Firmware string `json:"firmware,omitempty"`
Hardware string `json:"hardware,omitempty"`
Status *int `json:"status,omitempty"`
StatusDesc string `json:"statusDesc,omitempty"`
VoltageL1 *float64 `json:"voltageL1,omitempty"`
VoltageL2 *float64 `json:"voltageL2,omitempty"`
VoltageL3 *float64 `json:"voltageL3,omitempty"`
CurrentL1 *float64 `json:"currentL1,omitempty"`
CurrentL2 *float64 `json:"currentL2,omitempty"`
CurrentL3 *float64 `json:"currentL3,omitempty"`
PowerL1 *uint32 `json:"powerL1,omitempty"`
PowerL2 *uint32 `json:"powerL2,omitempty"`
PowerL3 *uint32 `json:"powerL3,omitempty"`
PowerTotal *uint32 `json:"powerTotal,omitempty"`
SessionSeconds *uint32 `json:"sessionSeconds,omitempty"`
SessionWh *uint32 `json:"sessionWh,omitempty"`
PhaseMode *int `json:"phaseMode,omitempty"`
ChargingMode *int `json:"chargingMode,omitempty"`
LoadBalancing *bool `json:"loadBalancing,omitempty"`
SolarBalancing *bool `json:"solarBalancing,omitempty"`
BoostMode *bool `json:"boostMode,omitempty"`
LEDBrightness *int `json:"ledBrightness,omitempty"`
CPSignal *int `json:"cpSignal,omitempty"`
CPSignalDesc string `json:"cpSignalDesc,omitempty"`
Relay1TempC *int `json:"relay1TempC,omitempty"`
Relay2TempC *int `json:"relay2TempC,omitempty"`
OcppStatus *int `json:"ocppStatus,omitempty"`
OcppStatusDesc string `json:"ocppStatusDesc,omitempty"`
MqttStatus *int `json:"mqttStatus,omitempty"`
MqttStatusDesc string `json:"mqttStatusDesc,omitempty"`
// Alarms holds the twelve alarm words verbatim. The spec defers the bit
// meanings to a separate alarm list, so they are surfaced undecoded rather
// than guessed at; Alarm reports whether any bit is set at all.
Alarms []uint16 `json:"alarms,omitempty"`
Alarm bool `json:"alarm"`
}
// ModbusDial opens a connection to one charger. Callers must Close it; the
// charger only tolerates two clients at a time.
func ModbusDial(ctx context.Context, cfg ModbusConfig) (*modbus.Client, error) {
if strings.TrimSpace(cfg.Host) == "" {
return nil, fmt.Errorf("anker-solix: the charger's local IP address is required for Modbus control")
}
return modbus.Connect(ctx, modbus.Options{
Address: cfg.Address(),
UnitID: cfg.UnitID,
Timeout: 5 * time.Second,
ConnectTimeout: 5 * time.Second,
})
}
// ModbusRead returns the charger's live state. withIdentity also reads the
// slower-moving identity block (model, serial, firmware), which is worth a
// second request on a first poll but not on every one.
func ModbusRead(ctx context.Context, c *modbus.Client, withIdentity bool) (ModbusSnapshot, error) {
var snap ModbusSnapshot
live, err := c.ReadHolding(ctx, liveStart, liveCount)
if err != nil {
return snap, err
}
decodeLive(&snap, live)
if withIdentity {
ident, err := c.ReadHolding(ctx, identityStart, identityCount)
if err != nil {
// Identity is a nicety; live state is the point. Report what we have.
return snap, nil
}
decodeIdentity(&snap, ident)
}
return snap, nil
}
// decodeLive fills the snapshot from the 20041-20100 block.
func decodeLive(snap *ModbusSnapshot, regs []uint16) {
at := func(addr int) (uint16, bool) {
i := addr - liveStart
if i < 0 || i >= len(regs) {
return 0, false
}
return regs[i], true
}
u32 := func(addr int) (uint32, bool) {
hi, ok1 := at(addr)
lo, ok2 := at(addr + 1)
if !ok1 || !ok2 {
return 0, false
}
return uint32(hi)<<16 | uint32(lo), true
}
scaled := func(addr int, gain float64) *float64 {
v, ok := at(addr)
if !ok {
return nil
}
f := float64(v) / gain
return &f
}
word := func(addr int) *uint32 {
v, ok := u32(addr)
if !ok {
return nil
}
return &v
}
num := func(addr int) *int {
v, ok := at(addr)
if !ok {
return nil
}
n := int(v)
return &n
}
flag := func(addr int) *bool {
v, ok := at(addr)
if !ok {
return nil
}
b := v != 0
return &b
}
degrees := func(addr int) *int {
v, ok := at(addr)
if !ok {
return nil
}
n := int(int16(v)) // signed: the relays can read below zero
return &n
}
snap.VoltageL1 = scaled(regVoltageL1N, 10)
snap.VoltageL2 = scaled(regVoltageL1N+1, 10)
snap.VoltageL3 = scaled(regVoltageL1N+2, 10)
snap.CurrentL1 = scaled(regCurrentL1, 100)
snap.CurrentL2 = scaled(regCurrentL1+1, 100)
snap.CurrentL3 = scaled(regCurrentL1+2, 100)
snap.PowerL1 = word(regPowerL1)
snap.PowerL2 = word(regPowerL1 + 2)
snap.PowerL3 = word(regPowerL1 + 4)
snap.PowerTotal = word(regPowerTotal)
snap.SessionSeconds = word(regSessionSec)
snap.SessionWh = word(regSessionWh)
snap.PhaseMode = num(regPhaseMode)
snap.ChargingMode = num(regChargingMode)
snap.LoadBalancing = flag(regLoadBalancing)
snap.SolarBalancing = flag(regSolarBalancing)
snap.BoostMode = flag(regBoostMode)
snap.LEDBrightness = num(regLEDBrightness)
snap.Relay1TempC = degrees(regRelay1Temp)
snap.Relay2TempC = degrees(regRelay2Temp)
if v := num(regCPSignal); v != nil {
snap.CPSignal, snap.CPSignalDesc = v, cpSignalNames[*v]
}
if v := num(regChargingStatus); v != nil {
snap.Status, snap.StatusDesc = v, statusName(*v)
}
if v := num(regOCPPStatus); v != nil {
snap.OcppStatus, snap.OcppStatusDesc = v, ocppStatusNames[*v]
}
if v := num(regMQTTStatus); v != nil {
snap.MqttStatus, snap.MqttStatusDesc = v, mqttStatusNames[*v]
}
for addr := regAlarm1; addr < regAlarm1+12; addr++ {
v, ok := at(addr)
if !ok {
break
}
snap.Alarms = append(snap.Alarms, v)
if v != 0 {
snap.Alarm = true
}
}
}
// decodeIdentity fills the snapshot from the 20000-20040 block.
func decodeIdentity(snap *ModbusSnapshot, regs []uint16) {
text := func(addr, count int) string {
i := addr - identityStart
if i < 0 || i+count > len(regs) {
return ""
}
b := make([]byte, 0, count*2)
for _, r := range regs[i : i+count] {
b = binary.BigEndian.AppendUint16(b, r)
}
return strings.TrimSpace(strings.TrimRight(string(b), "\x00"))
}
snap.Model = text(regModelName, 10)
snap.Serial = text(regSerialNumber, 12)
snap.Firmware = text(regSoftwareVersion, 6)
snap.Hardware = text(regHardwareVersion, 6)
}
// ModbusStartCharging asks the charger to begin a session.
func ModbusStartCharging(ctx context.Context, c *modbus.Client) error {
return c.WriteSingle(ctx, regChargingCommand, cmdStartCharging)
}
// ModbusStopCharging asks the charger to end the current session.
func ModbusStopCharging(ctx context.Context, c *modbus.Client) error {
return c.WriteSingle(ctx, regChargingCommand, cmdStopCharging)
}
// ModbusSetMaxCurrent sets the charging current ceiling, in amps. The register
// carries deciamps, and anything below currentPauseFloor stops the charge
// outright rather than slowing it — so that case is refused here, and a caller
// that means to pause is asked to say so.
func ModbusSetMaxCurrent(ctx context.Context, c *modbus.Client, amps float64) error {
if amps > 0 && amps < currentPauseFloor {
return fmt.Errorf("anker-solix: %.1f A is below the charger's %.0f A floor, which pauses charging; stop the session instead", amps, currentPauseFloor)
}
if amps < 0 || amps > 32 {
return fmt.Errorf("anker-solix: %.1f A is outside the charger's range (%.0f-32 A)", amps, currentPauseFloor)
}
return c.WriteSingle(ctx, regMaxCurrentSet, uint16(amps*10))
}
// ModbusSetBoost turns boost on for the current session; the charger clears it
// again when the session ends.
func ModbusSetBoost(ctx context.Context, c *modbus.Client, on bool) error {
var v uint16
if on {
v = 1
}
return c.WriteSingle(ctx, regBoostSet, v)
}
// ModbusSetPhaseMode fixes the charger to single- or three-phase, or hands the
// choice back to it. 0 automatic, 1 fixed single-phase, 2 fixed three-phase.
func ModbusSetPhaseMode(ctx context.Context, c *modbus.Client, mode int) error {
if mode < 0 || mode > 2 {
return fmt.Errorf("anker-solix: phase mode %d is not one of 0 (automatic), 1 (single) or 2 (three)", mode)
}
return c.WriteSingle(ctx, regPhaseCountSet, uint16(mode))
}
// ModbusSetTimeout sets the control timeout: if no Modbus client writes within
// it, the charger falls back to its own strategy. The spec requires more than
// five seconds.
func ModbusSetTimeout(ctx context.Context, c *modbus.Client, seconds int) error {
if seconds <= 5 {
return fmt.Errorf("anker-solix: the Modbus timeout must be more than 5 seconds, not %d", seconds)
}
return c.WriteSingle(ctx, regTimeoutSet, uint16(seconds))
}
@@ -0,0 +1,237 @@
package ankersolix
import (
"context"
"encoding/binary"
"strings"
"testing"
)
// liveBlock builds a 20041-20100 register block with every register zero, ready
// for a test to set the ones it cares about by address.
type liveBlock []uint16
func newLiveBlock() liveBlock { return make(liveBlock, liveCount) }
func (b liveBlock) set(addr int, v uint16) { b[addr-liveStart] = v }
func (b liveBlock) set32(addr int, v uint32) {
b[addr-liveStart] = uint16(v >> 16)
b[addr-liveStart+1] = uint16(v)
}
func TestDecodeLiveScalesMeasurements(t *testing.T) {
b := newLiveBlock()
b.set(regVoltageL1N, 2301) // 230.1 V, gain 10
b.set(regVoltageL1N+1, 2312) // 231.2 V
b.set(regVoltageL1N+2, 2298) // 229.8 V
b.set(regCurrentL1, 1600) // 16.00 A, gain 100
b.set(regCurrentL1+1, 1598) // 15.98 A
b.set(regCurrentL1+2, 0)
b.set32(regPowerL1, 3680)
b.set32(regPowerTotal, 11040)
b.set32(regSessionSec, 3725)
b.set32(regSessionWh, 12500)
var snap ModbusSnapshot
decodeLive(&snap, b)
if snap.VoltageL1 == nil || *snap.VoltageL1 != 230.1 {
t.Errorf("VoltageL1 = %v, want 230.1", snap.VoltageL1)
}
if snap.VoltageL3 == nil || *snap.VoltageL3 != 229.8 {
t.Errorf("VoltageL3 = %v, want 229.8", snap.VoltageL3)
}
if snap.CurrentL1 == nil || *snap.CurrentL1 != 16.0 {
t.Errorf("CurrentL1 = %v, want 16.0", snap.CurrentL1)
}
if snap.CurrentL2 == nil || *snap.CurrentL2 != 15.98 {
t.Errorf("CurrentL2 = %v, want 15.98", snap.CurrentL2)
}
// The 32-bit values straddle two registers; a swapped pair would read as a
// wildly different number rather than a near miss.
if snap.PowerL1 == nil || *snap.PowerL1 != 3680 {
t.Errorf("PowerL1 = %v, want 3680", snap.PowerL1)
}
if snap.PowerTotal == nil || *snap.PowerTotal != 11040 {
t.Errorf("PowerTotal = %v, want 11040", snap.PowerTotal)
}
if snap.SessionSeconds == nil || *snap.SessionSeconds != 3725 {
t.Errorf("SessionSeconds = %v, want 3725", snap.SessionSeconds)
}
if snap.SessionWh == nil || *snap.SessionWh != 12500 {
t.Errorf("SessionWh = %v, want 12500", snap.SessionWh)
}
}
func TestDecodeLiveNamesStates(t *testing.T) {
b := newLiveBlock()
b.set(regChargingStatus, 2) // charging
b.set(regCPSignal, 5) // C1, vehicle drawing
b.set(regOCPPStatus, 2) // connected
b.set(regMQTTStatus, 1) // connected
b.set(regBoostMode, 1)
b.set(regLoadBalancing, 0)
b.set(regLEDBrightness, 70)
var snap ModbusSnapshot
decodeLive(&snap, b)
// The Modbus status enum is the same one the cloud reports, so it must decode
// to the same names the rest of the plugin already uses.
if snap.Status == nil || *snap.Status != 2 || snap.StatusDesc != stateCharging {
t.Errorf("status = %v/%q, want 2/%q", snap.Status, snap.StatusDesc, stateCharging)
}
if snap.CPSignalDesc != cpSignalNames[5] {
t.Errorf("CPSignalDesc = %q, want %q", snap.CPSignalDesc, cpSignalNames[5])
}
if snap.OcppStatusDesc != "connected" {
t.Errorf("OcppStatusDesc = %q, want connected", snap.OcppStatusDesc)
}
// Register value 1 means "connected" for MQTT but only "connecting" for OCPP;
// the two registers must not be decoded through one table.
if snap.MqttStatusDesc != "connected" {
t.Errorf("MqttStatusDesc = %q, want connected", snap.MqttStatusDesc)
}
if snap.BoostMode == nil || !*snap.BoostMode {
t.Errorf("BoostMode = %v, want true", snap.BoostMode)
}
if snap.LoadBalancing == nil || *snap.LoadBalancing {
t.Errorf("LoadBalancing = %v, want false", snap.LoadBalancing)
}
if snap.LEDBrightness == nil || *snap.LEDBrightness != 70 {
t.Errorf("LEDBrightness = %v, want 70", snap.LEDBrightness)
}
}
func TestDecodeLiveReadsNegativeTemperatures(t *testing.T) {
b := newLiveBlock()
below := int16(-7)
b.set(regRelay1Temp, uint16(below))
b.set(regRelay2Temp, 41)
var snap ModbusSnapshot
decodeLive(&snap, b)
if snap.Relay1TempC == nil || *snap.Relay1TempC != -7 {
t.Errorf("Relay1TempC = %v, want -7", snap.Relay1TempC)
}
if snap.Relay2TempC == nil || *snap.Relay2TempC != 41 {
t.Errorf("Relay2TempC = %v, want 41", snap.Relay2TempC)
}
}
func TestDecodeLiveFlagsAlarms(t *testing.T) {
quiet := newLiveBlock()
var snap ModbusSnapshot
decodeLive(&snap, quiet)
if snap.Alarm {
t.Error("Alarm set with every alarm word zero")
}
if len(snap.Alarms) != 12 {
t.Errorf("got %d alarm words, want 12", len(snap.Alarms))
}
noisy := newLiveBlock()
noisy.set(regAlarm1+7, 0x0040)
var snap2 ModbusSnapshot
decodeLive(&snap2, noisy)
if !snap2.Alarm {
t.Error("Alarm not set although an alarm bit is on")
}
if snap2.Alarms[7] != 0x0040 {
t.Errorf("alarm word 8 = 0x%04x, want 0x0040", snap2.Alarms[7])
}
}
// A short block must leave fields absent rather than decode whatever follows.
func TestDecodeLiveToleratesShortBlock(t *testing.T) {
var snap ModbusSnapshot
decodeLive(&snap, []uint16{1, 2, 3})
if snap.Status != nil {
t.Errorf("Status = %v, want nil from a truncated block", snap.Status)
}
if snap.PowerTotal != nil {
t.Errorf("PowerTotal = %v, want nil from a truncated block", snap.PowerTotal)
}
}
func TestDecodeIdentityReadsStrings(t *testing.T) {
regs := make([]uint16, identityCount)
put := func(addr int, s string, count int) {
b := []byte(s)
for len(b) < count*2 {
b = append(b, 0)
}
for i := 0; i < count; i++ {
regs[addr-identityStart+i] = binary.BigEndian.Uint16(b[i*2:])
}
}
put(regModelName, "A5191", 10)
put(regSerialNumber, "V1SN0123456789AB", 12)
put(regSoftwareVersion, "1.2.3", 6)
put(regHardwareVersion, "1.0", 6)
var snap ModbusSnapshot
decodeIdentity(&snap, regs)
if snap.Model != "A5191" {
t.Errorf("Model = %q, want A5191", snap.Model)
}
if snap.Serial != "V1SN0123456789AB" {
t.Errorf("Serial = %q, want V1SN0123456789AB", snap.Serial)
}
if snap.Firmware != "1.2.3" {
t.Errorf("Firmware = %q, want 1.2.3", snap.Firmware)
}
if snap.Hardware != "1.0" {
t.Errorf("Hardware = %q, want 1.0", snap.Hardware)
}
}
// Below 6 A the charger stops instead of charging slowly, so a limit in that
// range has to be refused rather than quietly turned into a pause.
func TestModbusSetMaxCurrentRefusesBelowFloor(t *testing.T) {
err := ModbusSetMaxCurrent(context.Background(), nil, 4)
if err == nil {
t.Fatal("4 A was accepted")
}
if !strings.Contains(err.Error(), "pauses charging") {
t.Errorf("error = %q, want it to explain the pause", err)
}
}
func TestModbusSetMaxCurrentRefusesOutOfRange(t *testing.T) {
for _, amps := range []float64{-1, 40} {
if err := ModbusSetMaxCurrent(context.Background(), nil, amps); err == nil {
t.Errorf("%.0f A was accepted", amps)
}
}
}
func TestModbusSetPhaseModeRejectsUnknownMode(t *testing.T) {
if err := ModbusSetPhaseMode(context.Background(), nil, 3); err == nil {
t.Fatal("phase mode 3 was accepted")
}
}
func TestModbusSetTimeoutEnforcesFloor(t *testing.T) {
if err := ModbusSetTimeout(context.Background(), nil, 5); err == nil {
t.Fatal("a 5 second timeout was accepted, but the spec requires more than 5")
}
}
func TestModbusConfigDefaultsToPort502(t *testing.T) {
if got := (ModbusConfig{Host: "10.2.1.55"}).Address(); got != "10.2.1.55:502" {
t.Errorf("address = %q, want 10.2.1.55:502", got)
}
if got := (ModbusConfig{Host: "10.2.1.55", Port: 1502}).Address(); got != "10.2.1.55:1502" {
t.Errorf("address = %q, want 10.2.1.55:1502", got)
}
}
func TestModbusDialRequiresHost(t *testing.T) {
if _, err := ModbusDial(context.Background(), ModbusConfig{}); err == nil {
t.Fatal("an empty host was accepted")
}
}
+4 -2
View File
@@ -200,10 +200,12 @@ const integrationsApi = [
{ method: "PUT", path: "/api/integrations/anker-solix", desc: "Save the caller's own layer (user or org scope)" },
{ method: "POST", path: "/api/integrations/anker-solix/health", desc: "Live probe with the resolved credentials" },
{ method: "GET", path: "/api/integrations/anker-solix/chargers", desc: "EV chargers on the linked Anker account" },
{ method: "GET", path: "/api/integrations/anker-solix/chargers/{sn}/control", desc: "Control mode, token state and live CSMS session for one charger" },
{ method: "GET", path: "/api/integrations/anker-solix/chargers/{sn}/control", desc: "Control mode, token state and live session or Modbus snapshot for one charger" },
{ method: "POST", path: "/api/integrations/anker-solix/chargers/{sn}/control/token", desc: "(Re)issue the charger's control token" },
{ method: "DELETE", path: "/api/integrations/anker-solix/chargers/{sn}/control/token", desc: "Revoke the control token" },
{ method: "POST", path: "/api/integrations/anker-solix/chargers/{sn}/{action}", desc: "One OCPP command: start, stop, limit, clear-limit, availability, reset, unlock, trigger, config" },
{ method: "PUT", path: "/api/integrations/anker-solix/chargers/{sn}/control/address", desc: "Save the charger's local Modbus TCP address" },
{ method: "DELETE", path: "/api/integrations/anker-solix/chargers/{sn}/control/address", desc: "Forget the charger's local Modbus TCP address" },
{ method: "POST", path: "/api/integrations/anker-solix/chargers/{sn}/{action}", desc: "One control command. Over OCPP: start, stop, limit, clear-limit, availability, reset, unlock, trigger, config. Over Modbus TCP: start, stop, limit, boost, phase, timeout, status" },
{ method: "GET", path: "/api/integrations/greencell", desc: "Resolved Greencell settings (secrets masked)" },
{ method: "PUT", path: "/api/integrations/greencell", desc: "Save the caller's own layer (user or org scope)" },
{ method: "POST", path: "/api/integrations/greencell/health", desc: "Live probe: connect to the broker and broadcast for devices" },