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>
97 lines
3.1 KiB
Go
97 lines
3.1 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func TestNormalizeControlMode(t *testing.T) {
|
|
cases := map[string]string{
|
|
"off": "off",
|
|
"own": "own",
|
|
"proxy": "proxy",
|
|
"OWN": "own",
|
|
" Proxy": "proxy",
|
|
"": "", // unset — cascade continues to the next layer
|
|
"bogus": "", // unknown — treated as unset
|
|
}
|
|
for in, want := range cases {
|
|
if got := normalizeControlMode(in); got != want {
|
|
t.Errorf("normalizeControlMode(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestControlIndexSetUserInvalidatesOldToken(t *testing.T) {
|
|
ci := newControlIndex()
|
|
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "tok-old"}})
|
|
if _, ok := ci.lookup("tok-old"); !ok {
|
|
t.Fatal("tok-old should resolve after first set")
|
|
}
|
|
// Regenerate: same charger, new token. The old token must stop resolving.
|
|
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "tok-new"}})
|
|
if _, ok := ci.lookup("tok-old"); ok {
|
|
t.Error("tok-old should be invalidated after regeneration")
|
|
}
|
|
b, ok := ci.lookup("tok-new")
|
|
if !ok || b.UserID != "u1" || b.Serial != "SN1" {
|
|
t.Errorf("tok-new resolves to %+v (ok=%v), want u1/SN1", b, ok)
|
|
}
|
|
}
|
|
|
|
func TestControlIndexIsolatesUsers(t *testing.T) {
|
|
ci := newControlIndex()
|
|
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "a"}})
|
|
ci.setUser("u2", map[string]ankerChargerBinding{"SN2": {Token: "b"}})
|
|
// Re-setting u1 must not touch u2's token.
|
|
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "a2"}})
|
|
if _, ok := ci.lookup("b"); !ok {
|
|
t.Error("u2's token should be untouched when u1 is updated")
|
|
}
|
|
}
|
|
|
|
func TestAnkerBindingFor(t *testing.T) {
|
|
raw := json.RawMessage(`{"ankerSolix":{"controlChargers":{"SN1":{"token":"xyz"}}}}`)
|
|
if got := ankerBindingFor(raw, "SN1").Token; got != "xyz" {
|
|
t.Errorf("binding token = %q, want xyz", got)
|
|
}
|
|
if got := ankerBindingFor(raw, "other").Token; got != "" {
|
|
t.Errorf("unknown charger should have empty token, got %q", got)
|
|
}
|
|
if got := ankerBindingFor(nil, "SN1").Token; got != "" {
|
|
t.Errorf("nil settings should have empty token, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractWSURL(t *testing.T) {
|
|
cases := map[string]string{
|
|
`{"data":{"ocppUrl":"wss://ocpp.anker.com/CP1"}}`: "wss://ocpp.anker.com/CP1",
|
|
`{"a":{"b":[{"endpoint":"ws://x/y"}]}}`: "ws://x/y",
|
|
`{"data":{"note":"https://not-a-ws-url"}}`: "",
|
|
`{"empty":true}`: "",
|
|
}
|
|
for in, want := range cases {
|
|
if got := extractWSURL(json.RawMessage(in)); got != want {
|
|
t.Errorf("extractWSURL(%s) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOCPPEndpoint(t *testing.T) {
|
|
s := &Server{}
|
|
// Plain HTTP request → ws://
|
|
r := httptest.NewRequest("GET", "http://host.example/x", nil)
|
|
r.Host = "host.example"
|
|
if got := s.ocppEndpoint(r, "SN 1"); got != "ws://host.example/ocpp/SN%201" {
|
|
t.Errorf("endpoint = %q", got)
|
|
}
|
|
// Behind a TLS-terminating proxy → wss://
|
|
r2 := httptest.NewRequest("GET", "http://host.example/x", nil)
|
|
r2.Host = "host.example"
|
|
r2.Header.Set("X-Forwarded-Proto", "https")
|
|
if got := s.ocppEndpoint(r2, "SN1"); got != "wss://host.example/ocpp/SN1" {
|
|
t.Errorf("tls endpoint = %q", got)
|
|
}
|
|
}
|