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>
148 lines
5.0 KiB
Go
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)
|
|
}
|
|
}
|