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,370 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user