Files
DriverVault/API Server/internal/ocpp/csms_test.go
T
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

148 lines
5.0 KiB
Go

package ocpp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path"
"testing"
"time"
)
// ownModeServer stands up a CSMS behind an httptest server that upgrades every
// /ocpp/{serial} connection and registers it in own mode.
func ownModeServer(t *testing.T) (*CSMS, *httptest.Server, <-chan *Session) {
t.Helper()
csms := NewCSMS(nil)
sessCh := make(chan *Session, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := Upgrade(w, r)
if err != nil {
t.Errorf("upgrade: %v", err)
return
}
sess, err := csms.Accept(path.Base(r.URL.Path), ModeOwn, conn, nil)
if err != nil {
t.Errorf("accept: %v", err)
_ = conn.Close()
return
}
sessCh <- sess
}))
return csms, srv, sessCh
}
func TestOwnModeEndToEnd(t *testing.T) {
csms, srv, sessCh := ownModeServer(t)
defer srv.Close()
cp := dialSimCP(t, wsURL(srv.URL, "/ocpp/CP-A5191"), nil)
defer cp.close()
ctx := context.Background()
// Boot: the CSMS accepts and the charger appears connected.
boot := cp.call(t, "BootNotification", map[string]any{
"chargePointVendor": "Anker", "chargePointModel": "A5191", "firmwareVersion": "1.2.3",
})
assertStatus(t, boot, "Accepted")
var sess *Session
select {
case sess = <-sessCh:
case <-time.After(2 * time.Second):
t.Fatal("CSMS never registered the session")
}
if _, ok := csms.SessionFor("CP-A5191"); !ok {
t.Fatal("SessionFor should find the connected charger")
}
// StatusNotification + MeterValues update the tapped snapshot.
cp.call(t, "StatusNotification", map[string]any{"connectorId": 1, "status": "Charging", "errorCode": "NoError"})
cp.call(t, "MeterValues", map[string]any{
"connectorId": 1,
"meterValue": []any{map[string]any{
"timestamp": time.Now().UTC().Format(time.RFC3339),
"sampledValue": []any{map[string]any{"value": "1500", "measurand": "Energy.Active.Import.Register", "unit": "Wh"}},
}},
})
snap := sess.Snapshot()
if snap.Vendor != "Anker" || snap.Firmware != "1.2.3" {
t.Errorf("boot fields not tapped: %+v", snap)
}
if snap.ConnectorStatus != "Charging" {
t.Errorf("connector status = %q, want Charging", snap.ConnectorStatus)
}
if snap.MeterWh != 1500 {
t.Errorf("meter = %d Wh, want 1500", snap.MeterWh)
}
// StartTransaction: the CSMS assigns a transaction id.
st := cp.call(t, "StartTransaction", map[string]any{"connectorId": 1, "idTag": "TAG", "meterStart": 0, "timestamp": time.Now().UTC().Format(time.RFC3339)})
var startResp struct {
TransactionID int `json:"transactionId"`
}
_ = json.Unmarshal(st.Payload, &startResp)
if startResp.TransactionID == 0 {
t.Fatal("StartTransaction should return a non-zero transaction id")
}
// Now drive every control command and confirm the charger sees it + Accepts.
cases := []struct {
name string
run func() (string, error)
action string
}{
{"RemoteStart", func() (string, error) { return sess.RemoteStartTransaction(ctx, "TAG", 1) }, "RemoteStartTransaction"},
{"SetCurrentLimit", func() (string, error) { return sess.SetCurrentLimit(ctx, 1, 16) }, "SetChargingProfile"},
{"ClearChargingProfile", func() (string, error) { return sess.ClearChargingProfile(ctx, 1) }, "ClearChargingProfile"},
{"ChangeAvailability", func() (string, error) { return sess.ChangeAvailability(ctx, 0, false) }, "ChangeAvailability"},
{"UnlockConnector", func() (string, error) { return sess.UnlockConnector(ctx, 1) }, "UnlockConnector"},
{"TriggerMessage", func() (string, error) { return sess.TriggerMessage(ctx, "StatusNotification", 1) }, "TriggerMessage"},
{"ChangeConfiguration", func() (string, error) { return sess.ChangeConfiguration(ctx, "HeartbeatInterval", "60") }, "ChangeConfiguration"},
{"Reset", func() (string, error) { return sess.Reset(ctx, false) }, "Reset"},
{"RemoteStop", func() (string, error) { return sess.RemoteStopTransaction(ctx, startResp.TransactionID) }, "RemoteStopTransaction"},
}
for _, c := range cases {
status, err := c.run()
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
if status != "Accepted" {
t.Errorf("%s: status = %q, want Accepted", c.name, status)
}
got := cp.waitRecv(t)
if got.Action != c.action {
t.Errorf("%s: charger received %q, want %q", c.name, got.Action, c.action)
}
}
// GetConfiguration returns the charger's keys.
conf, err := sess.GetConfiguration(ctx, nil)
if err != nil {
t.Fatalf("GetConfiguration: %v", err)
}
cp.waitRecv(t)
if len(conf.ConfigurationKey) == 0 || conf.ConfigurationKey[0].Key != "HeartbeatInterval" {
t.Errorf("GetConfiguration = %+v", conf)
}
}
func TestSessionForUnknownCharger(t *testing.T) {
csms := NewCSMS(nil)
if _, ok := csms.SessionFor("nope"); ok {
t.Fatal("SessionFor should be false for an unconnected charger")
}
}
func assertStatus(t *testing.T, m Message, want string) {
t.Helper()
var r StatusResponse
if err := json.Unmarshal(m.Payload, &r); err != nil {
t.Fatalf("decode status: %v", err)
}
if r.Status != want {
t.Fatalf("status = %q, want %q", r.Status, want)
}
}