Files
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

147 lines
4.0 KiB
Go

package ocpp
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
)
// CSMS is the Central System: it holds the live charge-point sessions keyed by
// serial and answers the inbound OCPP calls a charger makes in own mode.
type CSMS struct {
mu sync.RWMutex
sessions map[string]*Session
logf func(string, ...any)
}
// NewCSMS builds an empty Central System. logf may be nil.
func NewCSMS(logf func(string, ...any)) *CSMS {
if logf == nil {
logf = func(string, ...any) {}
}
return &CSMS{sessions: map[string]*Session{}, logf: logf}
}
// Accept registers a newly-connected charger. In own mode cp is the charger
// connection and up must be nil; in proxy mode up is the (already-dialed)
// upstream CSMS connection. Any prior session for the same serial is closed. The
// returned Session is where control commands are issued.
func (c *CSMS) Accept(serial, mode string, cp, up *Conn) (*Session, error) {
if serial == "" {
return nil, errors.New("ocpp: empty serial")
}
switch mode {
case ModeOwn:
if up != nil {
_ = up.Close()
up = nil
}
case ModeProxy:
if up == nil {
return nil, errors.New("ocpp: proxy mode requires an upstream connection")
}
default:
return nil, fmt.Errorf("ocpp: cannot accept in mode %q", mode)
}
// Evict any existing session for this serial (a reconnect) before inserting.
c.mu.Lock()
old := c.sessions[serial]
delete(c.sessions, serial)
c.mu.Unlock()
if old != nil {
old.close(nil)
}
sess := newSession(serial, mode, cp, up, c.handleCall, c.deregister, c.logf)
c.mu.Lock()
c.sessions[serial] = sess
c.mu.Unlock()
sess.start()
return sess, nil
}
// SessionFor returns the live session for a serial, if the charger is connected.
func (c *CSMS) SessionFor(serial string) (*Session, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
s, ok := c.sessions[serial]
return s, ok
}
// Statuses returns a snapshot of every connected charger.
func (c *CSMS) Statuses() []Status {
c.mu.RLock()
defer c.mu.RUnlock()
out := make([]Status, 0, len(c.sessions))
for _, s := range c.sessions {
out = append(out, s.Snapshot())
}
return out
}
// Shutdown closes every session.
func (c *CSMS) Shutdown(context.Context) {
c.mu.Lock()
sessions := make([]*Session, 0, len(c.sessions))
for _, s := range c.sessions {
sessions = append(sessions, s)
}
c.sessions = map[string]*Session{}
c.mu.Unlock()
for _, s := range sessions {
s.close(nil)
}
}
func (c *CSMS) deregister(s *Session) {
c.mu.Lock()
if c.sessions[s.serial] == s {
delete(c.sessions, s.serial)
}
c.mu.Unlock()
}
// handleCall answers an inbound CALL from a charger in own mode. It implements
// the CSMS side of the OCPP 1.6 core profile: enough for a charger to boot,
// heartbeat, report status/meter values and open/close transactions against us.
func (c *CSMS) handleCall(s *Session, action string, payload json.RawMessage) (any, string, string) {
now := time.Now().UTC().Format(time.RFC3339)
switch action {
case "BootNotification":
return map[string]any{"status": "Accepted", "currentTime": now, "interval": 300}, "", ""
case "Heartbeat":
return map[string]any{"currentTime": now}, "", ""
case "StatusNotification", "MeterValues",
"FirmwareStatusNotification", "DiagnosticsStatusNotification":
return map[string]any{}, "", ""
case "Authorize":
return map[string]any{"idTagInfo": map[string]any{"status": "Accepted"}}, "", ""
case "StartTransaction":
return map[string]any{
"transactionId": s.assignTxn(),
"idTagInfo": map[string]any{"status": "Accepted"},
}, "", ""
case "StopTransaction":
return map[string]any{"idTagInfo": map[string]any{"status": "Accepted"}}, "", ""
case "DataTransfer":
return map[string]any{"status": "Accepted"}, "", ""
default:
return nil, ErrNotImplemented, "action not supported by DriverVault CSMS"
}
}
// assignTxn allocates a transaction id in own mode and records it in the status.
func (s *Session) assignTxn() int {
s.mu.Lock()
s.nextTxn++
txn := s.nextTxn
s.status.TransactionID = txn
s.status.LastUpdated = time.Now()
s.mu.Unlock()
return txn
}