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,438 @@
|
||||
// Package ocpp implements the DriverVault OCPP 1.6J Central System (CSMS) that
|
||||
// lets the server control an EV charger — start/stop a session, cap the current,
|
||||
// change availability, reset, and so on — over the persistent WebSocket the
|
||||
// charger dials out to. It is a separate long-lived subsystem from the read-only
|
||||
// Anker Solix cloud plugin (internal/plugins/builtin/ankersolix), which can only
|
||||
// monitor.
|
||||
//
|
||||
// The whole API server is dependency-free (stdlib only), so the WebSocket layer
|
||||
// here is a hand-rolled RFC 6455 implementation rather than gorilla/websocket. It
|
||||
// is deliberately minimal: text-message oriented (OCPP frames are JSON text),
|
||||
// single reader / serialized writer, with ping/pong and close handled inline.
|
||||
package ocpp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// wsGUID is the RFC 6455 magic value appended to Sec-WebSocket-Key when deriving
|
||||
// the Sec-WebSocket-Accept response.
|
||||
const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
|
||||
// maxMessageBytes caps a single assembled message; OCPP payloads are small, so
|
||||
// this is a generous ceiling that also bounds a hostile peer.
|
||||
const maxMessageBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// WebSocket opcodes (RFC 6455 §5.2).
|
||||
const (
|
||||
opContinuation = 0x0
|
||||
opText = 0x1
|
||||
opBinary = 0x2
|
||||
opClose = 0x8
|
||||
opPing = 0x9
|
||||
opPong = 0xA
|
||||
)
|
||||
|
||||
// ErrClosed is returned by ReadMessage once the peer has sent a close frame.
|
||||
var ErrClosed = errors.New("ocpp: connection closed")
|
||||
|
||||
// Conn is a minimal RFC 6455 connection, usable as either the server (CSMS) or
|
||||
// client (proxy→upstream, and the test charge point) end. The only difference is
|
||||
// masking: per the spec a client masks the frames it sends, a server does not.
|
||||
type Conn struct {
|
||||
raw net.Conn
|
||||
br *bufio.Reader
|
||||
isServer bool
|
||||
|
||||
wmu sync.Mutex // serializes all writes (data + control frames)
|
||||
|
||||
closeOnce sync.Once
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
// Upgrade performs the server-side handshake on a hijackable ResponseWriter and
|
||||
// returns a ready Conn. On success the caller owns the connection and must not
|
||||
// touch w again. It negotiates the "ocpp1.6" subprotocol when offered.
|
||||
func Upgrade(w http.ResponseWriter, r *http.Request) (*Conn, error) {
|
||||
if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") ||
|
||||
!headerHasToken(r.Header.Get("Connection"), "upgrade") {
|
||||
return nil, errors.New("ocpp: not a websocket upgrade request")
|
||||
}
|
||||
key := r.Header.Get("Sec-WebSocket-Key")
|
||||
if key == "" {
|
||||
return nil, errors.New("ocpp: missing Sec-WebSocket-Key")
|
||||
}
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, errors.New("ocpp: response writer does not support hijacking")
|
||||
}
|
||||
conn, brw, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var subproto string
|
||||
for _, p := range splitTokens(r.Header.Get("Sec-WebSocket-Protocol")) {
|
||||
if strings.EqualFold(p, "ocpp1.6") {
|
||||
subproto = "ocpp1.6"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("HTTP/1.1 101 Switching Protocols\r\n")
|
||||
b.WriteString("Upgrade: websocket\r\n")
|
||||
b.WriteString("Connection: Upgrade\r\n")
|
||||
b.WriteString("Sec-WebSocket-Accept: " + acceptKey(key) + "\r\n")
|
||||
if subproto != "" {
|
||||
b.WriteString("Sec-WebSocket-Protocol: " + subproto + "\r\n")
|
||||
}
|
||||
b.WriteString("\r\n")
|
||||
if _, err := conn.Write([]byte(b.String())); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Conn{raw: conn, br: brw.Reader, isServer: true, closed: make(chan struct{})}, nil
|
||||
}
|
||||
|
||||
// Dial opens a client WebSocket to rawURL (ws:// or wss://), offering the given
|
||||
// subprotocols and any extra request headers (e.g. Authorization for OCPP
|
||||
// security profile 1). It is used for proxy→upstream links and by tests.
|
||||
func Dial(ctx context.Context, rawURL string, subprotocols []string, header http.Header) (*Conn, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ocpp: bad url: %w", err)
|
||||
}
|
||||
var (
|
||||
secure bool
|
||||
port = u.Port()
|
||||
)
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "ws", "http":
|
||||
if port == "" {
|
||||
port = "80"
|
||||
}
|
||||
case "wss", "https":
|
||||
secure, port = true, orDefault(port, "443")
|
||||
default:
|
||||
return nil, fmt.Errorf("ocpp: unsupported scheme %q", u.Scheme)
|
||||
}
|
||||
|
||||
d := &net.Dialer{}
|
||||
raw, err := d.DialContext(ctx, "tcp", net.JoinHostPort(u.Hostname(), port))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if secure {
|
||||
tconn := tls.Client(raw, &tls.Config{ServerName: u.Hostname()})
|
||||
if err := tconn.HandshakeContext(ctx); err != nil {
|
||||
_ = raw.Close()
|
||||
return nil, err
|
||||
}
|
||||
raw = tconn
|
||||
}
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
_ = raw.SetDeadline(dl)
|
||||
}
|
||||
|
||||
var keyBytes [16]byte
|
||||
if _, err := rand.Read(keyBytes[:]); err != nil {
|
||||
_ = raw.Close()
|
||||
return nil, err
|
||||
}
|
||||
key := base64.StdEncoding.EncodeToString(keyBytes[:])
|
||||
|
||||
reqPath := u.RequestURI()
|
||||
if reqPath == "" {
|
||||
reqPath = "/"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("GET " + reqPath + " HTTP/1.1\r\n")
|
||||
b.WriteString("Host: " + u.Host + "\r\n")
|
||||
b.WriteString("Upgrade: websocket\r\n")
|
||||
b.WriteString("Connection: Upgrade\r\n")
|
||||
b.WriteString("Sec-WebSocket-Version: 13\r\n")
|
||||
b.WriteString("Sec-WebSocket-Key: " + key + "\r\n")
|
||||
if len(subprotocols) > 0 {
|
||||
b.WriteString("Sec-WebSocket-Protocol: " + strings.Join(subprotocols, ", ") + "\r\n")
|
||||
}
|
||||
for k, vs := range header {
|
||||
for _, v := range vs {
|
||||
b.WriteString(k + ": " + v + "\r\n")
|
||||
}
|
||||
}
|
||||
b.WriteString("\r\n")
|
||||
if _, err := raw.Write([]byte(b.String())); err != nil {
|
||||
_ = raw.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
br := bufio.NewReader(raw)
|
||||
resp, err := http.ReadResponse(br, &http.Request{Method: http.MethodGet})
|
||||
if err != nil {
|
||||
_ = raw.Close()
|
||||
return nil, err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
_ = raw.Close()
|
||||
return nil, fmt.Errorf("ocpp: upstream refused upgrade (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Sec-WebSocket-Accept"); got != acceptKey(key) {
|
||||
_ = raw.Close()
|
||||
return nil, errors.New("ocpp: bad Sec-WebSocket-Accept from upstream")
|
||||
}
|
||||
// Clear the dial deadline; per-operation deadlines are set explicitly later.
|
||||
_ = raw.SetDeadline(time.Time{})
|
||||
return &Conn{raw: raw, br: br, isServer: false, closed: make(chan struct{})}, nil
|
||||
}
|
||||
|
||||
// ReadMessage returns the next complete text/binary message, transparently
|
||||
// answering ping frames and honoring a close frame (after which it returns
|
||||
// ErrClosed). Control frames never surface to the caller.
|
||||
func (c *Conn) ReadMessage() ([]byte, error) {
|
||||
var (
|
||||
buf []byte
|
||||
started bool
|
||||
)
|
||||
for {
|
||||
f, err := c.readFrame()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch f.opcode {
|
||||
case opPing:
|
||||
_ = c.writeFrame(opPong, f.data)
|
||||
continue
|
||||
case opPong:
|
||||
continue
|
||||
case opClose:
|
||||
_ = c.writeFrame(opClose, f.data)
|
||||
c.markClosed()
|
||||
return nil, ErrClosed
|
||||
case opText, opBinary:
|
||||
if started {
|
||||
return nil, errors.New("ocpp: new data frame before previous finished")
|
||||
}
|
||||
started = true
|
||||
buf = append(buf, f.data...)
|
||||
case opContinuation:
|
||||
if !started {
|
||||
return nil, errors.New("ocpp: continuation frame with no start")
|
||||
}
|
||||
buf = append(buf, f.data...)
|
||||
default:
|
||||
return nil, fmt.Errorf("ocpp: unexpected opcode 0x%x", f.opcode)
|
||||
}
|
||||
if len(buf) > maxMessageBytes {
|
||||
return nil, errors.New("ocpp: message too large")
|
||||
}
|
||||
if f.fin {
|
||||
return buf, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WriteMessage sends one OCPP text frame.
|
||||
func (c *Conn) WriteMessage(data []byte) error {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return ErrClosed
|
||||
default:
|
||||
}
|
||||
return c.writeFrame(opText, data)
|
||||
}
|
||||
|
||||
// SetReadDeadline bounds the next read (used to detect a dead peer between
|
||||
// heartbeats). A zero time clears it.
|
||||
func (c *Conn) SetReadDeadline(t time.Time) error { return c.raw.SetReadDeadline(t) }
|
||||
|
||||
// Close sends a close frame (best effort) and shuts the underlying connection.
|
||||
func (c *Conn) Close() error {
|
||||
var err error
|
||||
c.closeOnce.Do(func() {
|
||||
_ = c.writeFrame(opClose, nil)
|
||||
close(c.closed)
|
||||
err = c.raw.Close()
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// markClosed records that the peer initiated close without double-sending.
|
||||
func (c *Conn) markClosed() {
|
||||
c.closeOnce.Do(func() {
|
||||
close(c.closed)
|
||||
_ = c.raw.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// ---- framing -----------------------------------------------------------------
|
||||
|
||||
type frame struct {
|
||||
fin bool
|
||||
opcode byte
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (c *Conn) readFrame() (frame, error) {
|
||||
var h [2]byte
|
||||
if _, err := io.ReadFull(c.br, h[:]); err != nil {
|
||||
return frame{}, err
|
||||
}
|
||||
fin := h[0]&0x80 != 0
|
||||
if h[0]&0x70 != 0 {
|
||||
return frame{}, errors.New("ocpp: reserved bits set (no extensions supported)")
|
||||
}
|
||||
opcode := h[0] & 0x0f
|
||||
masked := h[1]&0x80 != 0
|
||||
length := uint64(h[1] & 0x7f)
|
||||
switch length {
|
||||
case 126:
|
||||
var ext [2]byte
|
||||
if _, err := io.ReadFull(c.br, ext[:]); err != nil {
|
||||
return frame{}, err
|
||||
}
|
||||
length = uint64(binary.BigEndian.Uint16(ext[:]))
|
||||
case 127:
|
||||
var ext [8]byte
|
||||
if _, err := io.ReadFull(c.br, ext[:]); err != nil {
|
||||
return frame{}, err
|
||||
}
|
||||
length = binary.BigEndian.Uint64(ext[:])
|
||||
}
|
||||
if length > maxMessageBytes {
|
||||
return frame{}, errors.New("ocpp: frame too large")
|
||||
}
|
||||
// RFC 6455: the server must receive masked frames; a client must receive
|
||||
// unmasked ones.
|
||||
if c.isServer && !masked {
|
||||
return frame{}, errors.New("ocpp: unmasked frame from client")
|
||||
}
|
||||
if !c.isServer && masked {
|
||||
return frame{}, errors.New("ocpp: masked frame from server")
|
||||
}
|
||||
var maskKey [4]byte
|
||||
if masked {
|
||||
if _, err := io.ReadFull(c.br, maskKey[:]); err != nil {
|
||||
return frame{}, err
|
||||
}
|
||||
}
|
||||
data := make([]byte, length)
|
||||
if _, err := io.ReadFull(c.br, data); err != nil {
|
||||
return frame{}, err
|
||||
}
|
||||
if masked {
|
||||
for i := range data {
|
||||
data[i] ^= maskKey[i%4]
|
||||
}
|
||||
}
|
||||
return frame{fin: fin, opcode: opcode, data: data}, nil
|
||||
}
|
||||
|
||||
func (c *Conn) writeFrame(opcode byte, data []byte) error {
|
||||
c.wmu.Lock()
|
||||
defer c.wmu.Unlock()
|
||||
|
||||
mask := !c.isServer // clients mask their frames
|
||||
var head [14]byte
|
||||
head[0] = 0x80 | opcode // FIN + opcode (we never fragment outgoing messages)
|
||||
n := 2
|
||||
l := len(data)
|
||||
switch {
|
||||
case l <= 125:
|
||||
head[1] = byte(l)
|
||||
case l <= 0xFFFF:
|
||||
head[1] = 126
|
||||
binary.BigEndian.PutUint16(head[2:4], uint16(l))
|
||||
n = 4
|
||||
default:
|
||||
head[1] = 127
|
||||
binary.BigEndian.PutUint64(head[2:10], uint64(l))
|
||||
n = 10
|
||||
}
|
||||
|
||||
var maskKey [4]byte
|
||||
if mask {
|
||||
head[1] |= 0x80
|
||||
if _, err := rand.Read(maskKey[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
copy(head[n:n+4], maskKey[:])
|
||||
n += 4
|
||||
}
|
||||
|
||||
_ = c.raw.SetWriteDeadline(time.Now().Add(30 * time.Second))
|
||||
defer c.raw.SetWriteDeadline(time.Time{})
|
||||
|
||||
if _, err := c.raw.Write(head[:n]); err != nil {
|
||||
return err
|
||||
}
|
||||
if l == 0 {
|
||||
return nil
|
||||
}
|
||||
if mask {
|
||||
masked := make([]byte, l)
|
||||
for i := 0; i < l; i++ {
|
||||
masked[i] = data[i] ^ maskKey[i%4]
|
||||
}
|
||||
_, err := c.raw.Write(masked)
|
||||
return err
|
||||
}
|
||||
_, err := c.raw.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- small helpers -----------------------------------------------------------
|
||||
|
||||
func acceptKey(key string) string {
|
||||
h := sha1.New()
|
||||
_, _ = io.WriteString(h, key+wsGUID)
|
||||
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// headerHasToken reports whether a comma-separated header value contains token
|
||||
// (case-insensitive) — e.g. Connection: keep-alive, Upgrade.
|
||||
func headerHasToken(value, token string) bool {
|
||||
for _, t := range splitTokens(value) {
|
||||
if strings.EqualFold(t, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitTokens(value string) []string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func orDefault(v, def string) string {
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
Reference in new issue
Block a user