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>
130 lines
3.6 KiB
Go
130 lines
3.6 KiB
Go
package ocpp
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeUpstream is a stand-in for Anker's cloud CSMS: it upgrades, records every
|
|
// CALL it receives, and answers each with {status:"Accepted"} (plus boot fields).
|
|
func fakeUpstream(t *testing.T) (*httptest.Server, <-chan Message) {
|
|
t.Helper()
|
|
recv := make(chan Message, 64)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := Upgrade(w, r)
|
|
if err != nil {
|
|
t.Errorf("upstream upgrade: %v", err)
|
|
return
|
|
}
|
|
for {
|
|
data, err := conn.ReadMessage()
|
|
if err != nil {
|
|
return
|
|
}
|
|
msg, err := DecodeMessage(data)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if msg.Type != MessageTypeCall {
|
|
continue
|
|
}
|
|
select {
|
|
case recv <- msg:
|
|
default:
|
|
}
|
|
out, _ := EncodeCallResult(msg.ID, map[string]any{
|
|
"status": "Accepted", "currentTime": time.Now().UTC().Format(time.RFC3339), "interval": 300,
|
|
})
|
|
_ = conn.WriteMessage(out)
|
|
}
|
|
}))
|
|
return srv, recv
|
|
}
|
|
|
|
func TestProxyModeForwardsAndInjects(t *testing.T) {
|
|
upSrv, upRecv := fakeUpstream(t)
|
|
defer upSrv.Close()
|
|
upURL := wsURL(upSrv.URL, "/ocpp/CP-A5191")
|
|
|
|
csms := NewCSMS(nil)
|
|
sessCh := make(chan *Session, 1)
|
|
dvSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
cpConn, err := Upgrade(w, r)
|
|
if err != nil {
|
|
t.Errorf("dv upgrade: %v", err)
|
|
return
|
|
}
|
|
upConn, err := DialUpstream(context.Background(), upURL, BasicAuthHeader("CP-A5191", "secret"))
|
|
if err != nil {
|
|
t.Errorf("dial upstream: %v", err)
|
|
_ = cpConn.Close()
|
|
return
|
|
}
|
|
sess, err := csms.Accept("CP-A5191", ModeProxy, cpConn, upConn)
|
|
if err != nil {
|
|
t.Errorf("accept proxy: %v", err)
|
|
return
|
|
}
|
|
sessCh <- sess
|
|
}))
|
|
defer dvSrv.Close()
|
|
|
|
cp := dialSimCP(t, wsURL(dvSrv.URL, "/ocpp/CP-A5191"), nil)
|
|
defer cp.close()
|
|
|
|
// Charger→upstream: BootNotification is forwarded and the upstream's answer
|
|
// comes back to the charger.
|
|
boot := cp.call(t, "BootNotification", map[string]any{"chargePointModel": "A5191"})
|
|
assertStatus(t, boot, "Accepted")
|
|
if got := waitMsg(t, upRecv); got.Action != "BootNotification" {
|
|
t.Fatalf("upstream received %q, want BootNotification", got.Action)
|
|
}
|
|
|
|
var sess *Session
|
|
select {
|
|
case sess = <-sessCh:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("proxy session never registered")
|
|
}
|
|
|
|
// A forwarded StatusNotification is tapped for the snapshot.
|
|
cp.call(t, "StatusNotification", map[string]any{"connectorId": 1, "status": "Charging", "errorCode": "NoError"})
|
|
waitMsg(t, upRecv) // forwarded upstream
|
|
if s := sess.Snapshot(); s.ConnectorStatus != "Charging" {
|
|
t.Errorf("proxy snapshot status = %q, want Charging", s.ConnectorStatus)
|
|
}
|
|
|
|
// Injected control: DriverVault issues RemoteStart. The charger must receive
|
|
// it and answer, and it must NOT leak to the upstream CSMS.
|
|
status, err := sess.RemoteStartTransaction(context.Background(), "TAG", 1)
|
|
if err != nil {
|
|
t.Fatalf("inject RemoteStart: %v", err)
|
|
}
|
|
if status != "Accepted" {
|
|
t.Errorf("injected RemoteStart status = %q, want Accepted", status)
|
|
}
|
|
if got := cp.waitRecv(t); got.Action != "RemoteStartTransaction" {
|
|
t.Fatalf("charger received %q, want RemoteStartTransaction", got.Action)
|
|
}
|
|
select {
|
|
case leaked := <-upRecv:
|
|
t.Fatalf("injected call leaked to upstream: %q", leaked.Action)
|
|
case <-time.After(300 * time.Millisecond):
|
|
// good — nothing forwarded upstream
|
|
}
|
|
}
|
|
|
|
func waitMsg(t *testing.T, ch <-chan Message) Message {
|
|
t.Helper()
|
|
select {
|
|
case m := <-ch:
|
|
return m
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timeout awaiting message")
|
|
return Message{}
|
|
}
|
|
}
|