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,142 @@
|
||||
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[:])
|
||||
}
|
||||
Reference in new issue
Block a user