Add OCPP control for the Anker Solix EV charger (Own/Proxy CSMS)
The Anker Solix connector was read-only (cloud monitoring only). Add an
OCPP 1.6J control path with a per-user, cascading control mode:
- off monitoring only (default, unchanged behavior)
- own DriverVault is the charger's Central System (full control)
- proxy DriverVault relays to Anker's cloud and injects commands
New internal/ocpp subsystem (stdlib-only, hand-rolled RFC 6455): a CSMS
with session management, inbound dispatch, and typed control commands
(RemoteStart/Stop, SetChargingProfile current limit, ChangeAvailability,
Reset, UnlockConnector, TriggerMessage, Get/ChangeConfiguration). Own- and
proxy-mode paths are verified end-to-end against a simulated charge point.
The charger connects to /ocpp/{serial}, authenticated with OCPP Basic auth
(serial + a per-charger control token) resolved to the owning user via an
in-memory token index. Control REST endpoints mirror the monitoring ones and
reuse the same cascade gate plus a live-session check. controlMode is a new
cascade field (global -> org -> user) advertised as a select on the plugin.
Frontend: control-mode select + provisioning card in Settings, and a real
Start/Stop/limit/reset control panel in Charging, gated on the active mode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
367113f538
commit
a1519f6e89
22 files changed
+2850
-11
No files matched your search
@@ -0,0 +1,64 @@
|
||||
package ocpp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeCall(t *testing.T) {
|
||||
m, err := DecodeMessage([]byte(`[2,"abc","BootNotification",{"chargePointModel":"A5191"}]`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Type != MessageTypeCall || m.ID != "abc" || m.Action != "BootNotification" {
|
||||
t.Fatalf("bad decode: %+v", m)
|
||||
}
|
||||
var p struct {
|
||||
Model string `json:"chargePointModel"`
|
||||
}
|
||||
if err := json.Unmarshal(m.Payload, &p); err != nil || p.Model != "A5191" {
|
||||
t.Fatalf("payload = %s (%v)", m.Payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCallResultAndError(t *testing.T) {
|
||||
r, err := DecodeMessage([]byte(`[3,"abc",{"status":"Accepted"}]`))
|
||||
if err != nil || r.Type != MessageTypeCallResult {
|
||||
t.Fatalf("callresult decode: %+v %v", r, err)
|
||||
}
|
||||
e, err := DecodeMessage([]byte(`[4,"abc","NotImplemented","nope",{}]`))
|
||||
if err != nil || e.Type != MessageTypeCallError || e.ErrorCode != "NotImplemented" || e.ErrorDescription != "nope" {
|
||||
t.Fatalf("callerror decode: %+v %v", e, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeRoundTrip(t *testing.T) {
|
||||
b, err := EncodeCall("id1", "Reset", map[string]any{"type": "Soft"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := DecodeMessage(b)
|
||||
if err != nil || m.Action != "Reset" {
|
||||
t.Fatalf("round trip: %+v %v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeEmptyPayloadIsObject(t *testing.T) {
|
||||
// OCPP requires the payload be a JSON object, never null.
|
||||
b, err := EncodeCallResult("id1", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != `[3,"id1",{}]` {
|
||||
t.Fatalf("empty payload encoded as %s, want [3,\"id1\",{}]", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRejectsGarbage(t *testing.T) {
|
||||
if _, err := DecodeMessage([]byte(`{"not":"an array"}`)); err == nil {
|
||||
t.Fatal("expected error decoding non-array")
|
||||
}
|
||||
if _, err := DecodeMessage([]byte(`[2,"id"]`)); err == nil {
|
||||
t.Fatal("expected error decoding short CALL")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user