Files
DriverVault/API Server/internal/ocpp/websocket.go
T
tajniak81andClaude Opus 4.8 a1519f6e89 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>
2026-07-18 16:40:22 +02:00

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
}