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,147 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user