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:
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
|
||||
|
||||
Reference in new issue
Block a user