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:
co-authored by
Claude Opus 4.8
parent
367113f538
commit
a1519f6e89
@@ -0,0 +1,63 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"drivervault/apiserver/internal/config"
|
||||
"drivervault/apiserver/internal/ocpp"
|
||||
"drivervault/apiserver/internal/pb"
|
||||
)
|
||||
|
||||
// TestOCPPRouteAndControlAuth exercises the real HTTP stack (all middleware
|
||||
// included) to confirm the OCPP endpoint and the control REST routes are wired,
|
||||
// and that the charger-auth gate rejects an unknown token. PocketBase is left
|
||||
// unconfigured, so the successful (charger-connected) path is out of scope here —
|
||||
// that is covered by the internal/ocpp own/proxy tests.
|
||||
func TestOCPPRouteAndControlAuth(t *testing.T) {
|
||||
s := New(config.Config{UsersCollection: "users"}, pb.New("", "", ""))
|
||||
srv := httptest.NewServer(s.Handler())
|
||||
defer srv.Close()
|
||||
|
||||
wsBase := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
|
||||
// A charger connecting with no OCPP Basic auth is rejected (401).
|
||||
if _, err := ocpp.Dial(context.Background(), wsBase+"/ocpp/CP1", []string{"ocpp1.6"}, nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "401") {
|
||||
t.Fatalf("unauthenticated charger connect: want HTTP 401, got %v", err)
|
||||
}
|
||||
|
||||
// A bogus token is unknown to the index (PB unconfigured, so the rebuild is a
|
||||
// no-op) and is likewise rejected.
|
||||
h := http.Header{}
|
||||
h.Set("Authorization", ocpp.BasicAuthHeader("CP1", "bogus-token"))
|
||||
if _, err := ocpp.Dial(context.Background(), wsBase+"/ocpp/CP1", []string{"ocpp1.6"}, h); err == nil ||
|
||||
!strings.Contains(err.Error(), "401") {
|
||||
t.Fatalf("bogus-token charger connect: want HTTP 401, got %v", err)
|
||||
}
|
||||
|
||||
// The control REST routes are registered (401 for a missing bearer token, not
|
||||
// 404 for an unknown route) — this also proves the {sn}/control,
|
||||
// {sn}/control/token and {sn}/{action} patterns don't shadow each other.
|
||||
routes := []struct {
|
||||
method, path string
|
||||
}{
|
||||
{http.MethodGet, "/api/integrations/anker-solix/chargers/CP1/control"},
|
||||
{http.MethodPost, "/api/integrations/anker-solix/chargers/CP1/control/token"},
|
||||
{http.MethodPost, "/api/integrations/anker-solix/chargers/CP1/start"},
|
||||
}
|
||||
for _, rt := range routes {
|
||||
req, _ := http.NewRequest(rt.method, srv.URL+rt.path, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", rt.method, rt.path, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s: status %d, want 401 (route registered, bearer required)", rt.method, rt.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user