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>
439 lines
11 KiB
Go
439 lines
11 KiB
Go
// 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
|
|
}
|