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

143 lines
4.1 KiB
Go

package ocpp
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
)
// OCPP-J message types (OCPP 1.6 §4.2). Every frame is a JSON array whose first
// element is one of these.
const (
MessageTypeCall = 2 // [2, id, action, payload]
MessageTypeCallResult = 3 // [3, id, payload]
MessageTypeCallError = 4 // [4, id, errorCode, errorDescription, errorDetails]
)
// Standard OCPP-J error codes used when rejecting a CALL.
const (
ErrNotImplemented = "NotImplemented"
ErrNotSupported = "NotSupported"
ErrInternalError = "InternalError"
ErrProtocolError = "ProtocolError"
ErrSecurityError = "SecurityError"
ErrFormationViolation = "FormationViolation"
ErrPropertyConstraintViolation = "PropertyConstraintViolation"
ErrGenericError = "GenericError"
)
// Message is a decoded OCPP-J frame; only the fields relevant to Type are set.
type Message struct {
Type int
ID string
Action string // CALL only
Payload json.RawMessage // CALL and CALLRESULT
ErrorCode string // CALLERROR only
ErrorDescription string // CALLERROR only
ErrorDetails json.RawMessage // CALLERROR only
}
// DecodeMessage parses one OCPP-J frame.
func DecodeMessage(b []byte) (Message, error) {
var arr []json.RawMessage
if err := json.Unmarshal(b, &arr); err != nil {
return Message{}, fmt.Errorf("ocpp: not a JSON array: %w", err)
}
if len(arr) < 3 {
return Message{}, errors.New("ocpp: message array too short")
}
var typ int
if err := json.Unmarshal(arr[0], &typ); err != nil {
return Message{}, fmt.Errorf("ocpp: bad message type: %w", err)
}
var id string
if err := json.Unmarshal(arr[1], &id); err != nil {
return Message{}, fmt.Errorf("ocpp: bad message id: %w", err)
}
m := Message{Type: typ, ID: id}
switch typ {
case MessageTypeCall:
if len(arr) != 4 {
return Message{}, errors.New("ocpp: CALL must have 4 elements")
}
if err := json.Unmarshal(arr[2], &m.Action); err != nil {
return Message{}, fmt.Errorf("ocpp: bad action: %w", err)
}
m.Payload = arr[3]
case MessageTypeCallResult:
m.Payload = arr[2]
case MessageTypeCallError:
if len(arr) != 5 {
return Message{}, errors.New("ocpp: CALLERROR must have 5 elements")
}
_ = json.Unmarshal(arr[2], &m.ErrorCode)
_ = json.Unmarshal(arr[3], &m.ErrorDescription)
m.ErrorDetails = arr[4]
default:
return Message{}, fmt.Errorf("ocpp: unknown message type %d", typ)
}
return m, nil
}
// EncodeCall builds a CALL frame. A nil/empty payload is encoded as {} because
// OCPP requires the payload to be a JSON object.
func EncodeCall(id, action string, payload any) ([]byte, error) {
p, err := payloadObject(payload)
if err != nil {
return nil, err
}
return json.Marshal([]any{MessageTypeCall, id, action, p})
}
// EncodeCallResult builds a CALLRESULT frame answering the CALL with id.
func EncodeCallResult(id string, payload any) ([]byte, error) {
p, err := payloadObject(payload)
if err != nil {
return nil, err
}
return json.Marshal([]any{MessageTypeCallResult, id, p})
}
// EncodeCallError builds a CALLERROR frame rejecting the CALL with id.
func EncodeCallError(id, code, description string, details any) ([]byte, error) {
d, err := payloadObject(details)
if err != nil {
return nil, err
}
return json.Marshal([]any{MessageTypeCallError, id, code, description, d})
}
// payloadObject normalizes any payload to a JSON object RawMessage, mapping
// nil/null to the empty object {}.
func payloadObject(payload any) (json.RawMessage, error) {
switch v := payload.(type) {
case nil:
return json.RawMessage("{}"), nil
case json.RawMessage:
if len(v) == 0 || string(v) == "null" {
return json.RawMessage("{}"), nil
}
return v, nil
default:
b, err := json.Marshal(payload)
if err != nil {
return nil, err
}
if len(b) == 0 || string(b) == "null" {
return json.RawMessage("{}"), nil
}
return b, nil
}
}
// newMessageID returns a fresh unique id for an outbound CALL.
func newMessageID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}