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,125 @@
|
||||
package ocpp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// wsURL turns an httptest http:// base URL into a ws:// URL with a path.
|
||||
func wsURL(base, path string) string {
|
||||
return "ws" + strings.TrimPrefix(base, "http") + path
|
||||
}
|
||||
|
||||
// simCP is a simulated OCPP 1.6J charge point used by the CSMS and proxy tests.
|
||||
// It auto-answers inbound control CALLs with "Accepted" (recording them for
|
||||
// assertions) and can itself send CALLs (BootNotification, StatusNotification, …)
|
||||
// and await their result.
|
||||
type simCP struct {
|
||||
conn *Conn
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string]chan Message
|
||||
|
||||
recv chan Message // inbound CALLs (CSMS → CP), for assertions
|
||||
}
|
||||
|
||||
func dialSimCP(t *testing.T, url string, header http.Header) *simCP {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
conn, err := Dial(ctx, url, []string{"ocpp1.6"}, header)
|
||||
if err != nil {
|
||||
t.Fatalf("dial sim charge point: %v", err)
|
||||
}
|
||||
cp := &simCP{conn: conn, pending: map[string]chan Message{}, recv: make(chan Message, 64)}
|
||||
go cp.loop()
|
||||
return cp
|
||||
}
|
||||
|
||||
func (cp *simCP) loop() {
|
||||
for {
|
||||
data, err := cp.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
msg, err := DecodeMessage(data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch msg.Type {
|
||||
case MessageTypeCall:
|
||||
select {
|
||||
case cp.recv <- msg:
|
||||
default:
|
||||
}
|
||||
out, _ := EncodeCallResult(msg.ID, cp.responseFor(msg.Action))
|
||||
_ = cp.conn.WriteMessage(out)
|
||||
case MessageTypeCallResult, MessageTypeCallError:
|
||||
cp.mu.Lock()
|
||||
ch := cp.pending[msg.ID]
|
||||
delete(cp.pending, msg.ID)
|
||||
cp.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// responseFor returns the payload the sim answers an inbound control CALL with.
|
||||
func (cp *simCP) responseFor(action string) any {
|
||||
switch action {
|
||||
case "GetConfiguration":
|
||||
return map[string]any{
|
||||
"configurationKey": []any{
|
||||
map[string]any{"key": "HeartbeatInterval", "readonly": false, "value": "300"},
|
||||
},
|
||||
}
|
||||
default:
|
||||
// RemoteStart/Stop, Reset, ChangeAvailability, UnlockConnector,
|
||||
// TriggerMessage, SetChargingProfile, ClearChargingProfile, ChangeConfiguration.
|
||||
return map[string]any{"status": "Accepted"}
|
||||
}
|
||||
}
|
||||
|
||||
// call sends a CALL from the charge point and waits for the reply.
|
||||
func (cp *simCP) call(t *testing.T, action string, payload any) Message {
|
||||
t.Helper()
|
||||
id := newMessageID()
|
||||
frame, err := EncodeCall(id, action, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("encode %s: %v", action, err)
|
||||
}
|
||||
ch := make(chan Message, 1)
|
||||
cp.mu.Lock()
|
||||
cp.pending[id] = ch
|
||||
cp.mu.Unlock()
|
||||
if err := cp.conn.WriteMessage(frame); err != nil {
|
||||
t.Fatalf("write %s: %v", action, err)
|
||||
}
|
||||
select {
|
||||
case m := <-ch:
|
||||
return m
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("timeout awaiting %s reply", action)
|
||||
return Message{}
|
||||
}
|
||||
}
|
||||
|
||||
// waitRecv returns the next inbound control CALL the sim received, or fails.
|
||||
func (cp *simCP) waitRecv(t *testing.T) Message {
|
||||
t.Helper()
|
||||
select {
|
||||
case m := <-cp.recv:
|
||||
return m
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timeout awaiting inbound control call")
|
||||
return Message{}
|
||||
}
|
||||
}
|
||||
|
||||
func (cp *simCP) close() { _ = cp.conn.Close() }
|
||||
Reference in new issue
Block a user