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>
143 lines
4.1 KiB
Go
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[:])
|
|
}
|