Files
DriverVault/API Server/internal/ocpp/session.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

371 lines
9.7 KiB
Go

package ocpp
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
)
// Control modes. These mirror the cascade field the operator picks in Settings
// (see internal/api/integrations_ankersolix.go).
const (
ModeOff = "off" // monitoring only; no CSMS (charger keeps its normal backend)
ModeOwn = "own" // DriverVault is the charger's Central System
ModeProxy = "proxy" // DriverVault forwards to Anker's cloud and taps/injects
)
// injectedPrefix namespaces the message ids of CALLs DriverVault injects toward
// the charger, so that in proxy mode a reply to one of our calls is never
// confused with a reply destined for the upstream CSMS.
const injectedPrefix = "dv-"
// callTimeout bounds how long a control command waits for the charger to answer.
const callTimeout = 30 * time.Second
// Status is a point-in-time snapshot of one charger's OCPP state, safe to return
// to clients.
type Status struct {
Serial string `json:"serial"`
Mode string `json:"mode"`
Connected bool `json:"connected"`
Vendor string `json:"vendor,omitempty"`
Model string `json:"model,omitempty"`
Firmware string `json:"firmware,omitempty"`
BootedAt time.Time `json:"bootedAt,omitempty"`
LastHeartbeat time.Time `json:"lastHeartbeat,omitempty"`
ConnectorStatus string `json:"connectorStatus,omitempty"` // Available, Charging, Faulted, …
ErrorCode string `json:"errorCode,omitempty"`
MeterWh int64 `json:"meterWh,omitempty"`
TransactionID int `json:"transactionId,omitempty"`
LastUpdated time.Time `json:"lastUpdated,omitempty"`
}
// CallError is returned by Session.Call when the charger answers with a CALLERROR.
type CallError struct {
Code string
Description string
}
func (e *CallError) Error() string {
return fmt.Sprintf("ocpp charger rejected call: %s: %s", e.Code, e.Description)
}
// callHandler answers an inbound CALL from the charger (own mode only). It
// returns either a result payload, or a non-empty errCode/errDesc to send a
// CALLERROR.
type callHandler func(s *Session, action string, payload json.RawMessage) (result any, errCode, errDesc string)
// Session is one live charge-point connection. In own mode it also holds the
// local dispatch handler; in proxy mode it additionally holds the upstream
// connection and pumps frames between the two while tapping the stream.
type Session struct {
serial string
mode string
cp *Conn // charge-point (downstream) connection
up *Conn // upstream CSMS connection (proxy mode only)
handler callHandler
onClose func(*Session)
logf func(string, ...any)
mu sync.Mutex
pending map[string]chan Message // our injected call id -> result channel
status Status
closed bool
done chan struct{}
nextTxn int // own mode: assigns transaction ids
}
// newSession builds a session. Call start once it is registered to begin its
// read loop(s). In proxy mode up must be non-nil; in own mode handler must be
// non-nil.
func newSession(serial, mode string, cp, up *Conn, handler callHandler, onClose func(*Session), logf func(string, ...any)) *Session {
if logf == nil {
logf = func(string, ...any) {}
}
return &Session{
serial: serial,
mode: mode,
cp: cp,
up: up,
handler: handler,
onClose: onClose,
logf: logf,
pending: map[string]chan Message{},
done: make(chan struct{}),
status: Status{Serial: serial, Mode: mode, Connected: true},
}
}
// start launches the read loop(s). Separated from newSession so the CSMS can
// register the session before any inbound frame (or an immediate disconnect) can
// fire the onClose deregister callback.
func (s *Session) start() {
go s.cpLoop()
if s.mode == ModeProxy && s.up != nil {
go s.upLoop()
}
}
// Serial returns the charger serial this session serves.
func (s *Session) Serial() string { return s.serial }
// Mode returns the control mode (own|proxy).
func (s *Session) Mode() string { return s.mode }
// Snapshot returns the current status.
func (s *Session) Snapshot() Status {
s.mu.Lock()
defer s.mu.Unlock()
st := s.status
st.Serial = s.serial
st.Mode = s.mode
return st
}
// Call injects a CALL toward the charger and waits for its reply. It is how every
// control command (commands.go) reaches the charger, in both own and proxy modes.
func (s *Session) Call(ctx context.Context, action string, payload any) (json.RawMessage, error) {
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
id := injectedPrefix + newMessageID()
frame, err := EncodeCall(id, action, payload)
if err != nil {
return nil, err
}
ch := make(chan Message, 1)
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil, ErrClosed
}
s.pending[id] = ch
s.mu.Unlock()
defer func() {
s.mu.Lock()
delete(s.pending, id)
s.mu.Unlock()
}()
if err := s.cp.WriteMessage(frame); err != nil {
return nil, err
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-s.done:
return nil, ErrClosed
case m := <-ch:
if m.Type == MessageTypeCallError {
return nil, &CallError{Code: m.ErrorCode, Description: m.ErrorDescription}
}
return m.Payload, nil
}
}
// ---- read loops --------------------------------------------------------------
func (s *Session) cpLoop() {
defer s.close(nil)
for {
data, err := s.cp.ReadMessage()
if err != nil {
s.close(err)
return
}
msg, err := DecodeMessage(data)
if err != nil {
s.logf("ocpp: bad frame from charger %s: %v", s.serial, err)
continue
}
s.tap(msg)
if s.mode == ModeOwn {
s.handleLocal(msg)
continue
}
// Proxy: a reply to one of OUR injected calls is consumed locally and not
// forwarded upstream; everything else is relayed to the upstream CSMS.
if msg.Type != MessageTypeCall && s.isPending(msg.ID) {
s.deliver(msg)
continue
}
if s.up == nil || s.up.WriteMessage(data) != nil {
s.close(fmt.Errorf("ocpp: upstream write failed for %s", s.serial))
return
}
}
}
func (s *Session) upLoop() {
for {
data, err := s.up.ReadMessage()
if err != nil {
s.close(err)
return
}
if msg, derr := DecodeMessage(data); derr == nil {
s.tap(msg)
}
if err := s.cp.WriteMessage(data); err != nil {
s.close(err)
return
}
}
}
// handleLocal answers an inbound frame in own mode.
func (s *Session) handleLocal(msg Message) {
switch msg.Type {
case MessageTypeCall:
result, code, desc := s.handler(s, msg.Action, msg.Payload)
var (
out []byte
err error
)
if code != "" {
out, err = EncodeCallError(msg.ID, code, desc, nil)
} else {
out, err = EncodeCallResult(msg.ID, result)
}
if err != nil {
s.logf("ocpp: encode response for %s/%s: %v", s.serial, msg.Action, err)
return
}
_ = s.cp.WriteMessage(out)
case MessageTypeCallResult, MessageTypeCallError:
s.deliver(msg)
}
}
func (s *Session) isPending(id string) bool {
s.mu.Lock()
_, ok := s.pending[id]
s.mu.Unlock()
return ok
}
func (s *Session) deliver(msg Message) {
s.mu.Lock()
ch := s.pending[msg.ID]
delete(s.pending, msg.ID)
s.mu.Unlock()
if ch != nil {
ch <- msg
}
}
// tap updates the status snapshot from charger-originated notifications, so both
// own and proxy modes keep a live view without special-casing the dispatch path.
func (s *Session) tap(msg Message) {
if msg.Type != MessageTypeCall {
return
}
switch msg.Action {
case "BootNotification":
var p struct {
Vendor string `json:"chargePointVendor"`
Model string `json:"chargePointModel"`
Firmware string `json:"firmwareVersion"`
SerialCP string `json:"chargePointSerialNumber"`
SerialBox string `json:"chargeBoxSerialNumber"`
}
_ = json.Unmarshal(msg.Payload, &p)
s.mutate(func(st *Status) {
st.Vendor, st.Model, st.Firmware = p.Vendor, p.Model, p.Firmware
st.BootedAt = time.Now()
})
case "Heartbeat":
s.mutate(func(st *Status) { st.LastHeartbeat = time.Now() })
case "StatusNotification":
var p struct {
Status string `json:"status"`
ErrorCode string `json:"errorCode"`
}
_ = json.Unmarshal(msg.Payload, &p)
s.mutate(func(st *Status) {
st.ConnectorStatus = p.Status
if p.ErrorCode != "" && p.ErrorCode != "NoError" {
st.ErrorCode = p.ErrorCode
} else {
st.ErrorCode = ""
}
})
case "MeterValues":
if wh, ok := meterWh(msg.Payload); ok {
s.mutate(func(st *Status) { st.MeterWh = wh })
}
case "StopTransaction":
s.mutate(func(st *Status) { st.TransactionID = 0 })
}
}
func (s *Session) mutate(f func(*Status)) {
s.mu.Lock()
f(&s.status)
s.status.LastUpdated = time.Now()
s.mu.Unlock()
}
func (s *Session) close(err error) {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
s.status.Connected = false
close(s.done)
s.mu.Unlock()
_ = s.cp.Close()
if s.up != nil {
_ = s.up.Close()
}
if s.onClose != nil {
s.onClose(s)
}
if err != nil && err != ErrClosed {
s.logf("ocpp: session %s closed: %v", s.serial, err)
}
}
// meterWh best-effort extracts an Energy.Active.Import.Register reading (in Wh)
// from a MeterValues payload.
func meterWh(payload json.RawMessage) (int64, bool) {
var p struct {
MeterValue []struct {
SampledValue []struct {
Value string `json:"value"`
Measurand string `json:"measurand"`
Unit string `json:"unit"`
} `json:"sampledValue"`
} `json:"meterValue"`
}
if err := json.Unmarshal(payload, &p); err != nil {
return 0, false
}
for _, mv := range p.MeterValue {
for _, sv := range mv.SampledValue {
// Default measurand per spec is Energy.Active.Import.Register.
if sv.Measurand != "" && sv.Measurand != "Energy.Active.Import.Register" {
continue
}
var f float64
if _, err := fmt.Sscanf(sv.Value, "%g", &f); err != nil {
continue
}
if sv.Unit == "kWh" {
f *= 1000
}
return int64(f), true
}
}
return 0, false
}