Files
tajniak81andClaude Opus 4.8 a1519f6e89 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>
2026-07-18 16:40:22 +02:00

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{}
}
}