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

@@ -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,13 +489,33 @@ 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.
// 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"`
Amps float64 `json:"amps"`
Operative *bool `json:"operative"`
Hard bool `json:"hard"`
RequestedMessage string `json:"requestedMessage"`
Key string `json:"key"`
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) {
sess, ok := s.ankerControlSession(w, r)
who, res, binding, ok := s.ankerControlGate(w, r)
if !ok {
return
}
who := caller(r)
sn := r.PathValue("sn")
action := r.PathValue("action")
@@ -487,19 +526,7 @@ func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request
return
}
var body struct {
IdTag string `json:"idTag"`
ConnectorID int `json:"connectorId"`
TransactionID int `json:"transactionId"`
Amps float64 `json:"amps"`
Operative *bool `json:"operative"`
Hard bool `json:"hard"`
RequestedMessage string `json:"requestedMessage"`
Key string `json:"key"`
Value string `json:"value"`
Confirm bool `json:"confirm"`
Password string `json:"password"`
}
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