Files
DriverVault/API Server/internal/ocpp/commands.go
T
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

165 lines
5.4 KiB
Go

package ocpp
import (
"context"
"encoding/json"
"errors"
)
// This file is the outbound control surface: typed helpers that build an OCPP
// CALL, send it to the charger via Session.Call, and parse the reply. They work
// identically in own and proxy modes (both inject over the same socket).
// StatusResponse is the common {status: "..."} reply most control calls return
// (e.g. "Accepted", "Rejected", "Scheduled", "NotSupported").
type StatusResponse struct {
Status string `json:"status"`
}
// statusCall issues a control CALL that replies with a {status} object.
func (s *Session) statusCall(ctx context.Context, action string, payload any) (string, error) {
raw, err := s.Call(ctx, action, payload)
if err != nil {
return "", err
}
var r StatusResponse
if err := json.Unmarshal(raw, &r); err != nil {
return "", err
}
return r.Status, nil
}
// RemoteStartTransaction asks the charger to begin a charging session. A blank
// idTag is defaulted; connectorID <= 0 lets the charger choose.
func (s *Session) RemoteStartTransaction(ctx context.Context, idTag string, connectorID int) (string, error) {
if idTag == "" {
idTag = "DriverVault"
}
payload := map[string]any{"idTag": idTag}
if connectorID > 0 {
payload["connectorId"] = connectorID
}
return s.statusCall(ctx, "RemoteStartTransaction", payload)
}
// RemoteStopTransaction stops a session. When transactionID <= 0 the id from the
// live status snapshot (own mode) is used.
func (s *Session) RemoteStopTransaction(ctx context.Context, transactionID int) (string, error) {
if transactionID <= 0 {
transactionID = s.Snapshot().TransactionID
}
if transactionID <= 0 {
return "", errors.New("ocpp: no active transaction to stop")
}
return s.statusCall(ctx, "RemoteStopTransaction", map[string]any{"transactionId": transactionID})
}
// SetCurrentLimit caps the charge current (Amperes) via a TxDefaultProfile. A
// connectorID <= 0 defaults to connector 1.
func (s *Session) SetCurrentLimit(ctx context.Context, connectorID int, amps float64) (string, error) {
if connectorID <= 0 {
connectorID = 1
}
profile := map[string]any{
"chargingProfileId": 1,
"stackLevel": 0,
"chargingProfilePurpose": "TxDefaultProfile",
"chargingProfileKind": "Relative",
"chargingSchedule": map[string]any{
"chargingRateUnit": "A",
"chargingSchedulePeriod": []any{
map[string]any{"startPeriod": 0, "limit": amps},
},
},
}
return s.statusCall(ctx, "SetChargingProfile", map[string]any{
"connectorId": connectorID,
"csChargingProfiles": profile,
})
}
// ClearChargingProfile removes charging profiles (lifting a current limit). A
// connectorID <= 0 clears all connectors.
func (s *Session) ClearChargingProfile(ctx context.Context, connectorID int) (string, error) {
payload := map[string]any{}
if connectorID > 0 {
payload["connectorId"] = connectorID
}
return s.statusCall(ctx, "ClearChargingProfile", payload)
}
// ChangeAvailability sets a connector Operative/Inoperative. connectorID 0
// targets the whole charge point.
func (s *Session) ChangeAvailability(ctx context.Context, connectorID int, operative bool) (string, error) {
typ := "Inoperative"
if operative {
typ = "Operative"
}
return s.statusCall(ctx, "ChangeAvailability", map[string]any{
"connectorId": connectorID,
"type": typ,
})
}
// Reset reboots the charger (Soft or Hard).
func (s *Session) Reset(ctx context.Context, hard bool) (string, error) {
typ := "Soft"
if hard {
typ = "Hard"
}
return s.statusCall(ctx, "Reset", map[string]any{"type": typ})
}
// UnlockConnector releases the cable lock on a connector.
func (s *Session) UnlockConnector(ctx context.Context, connectorID int) (string, error) {
if connectorID <= 0 {
connectorID = 1
}
return s.statusCall(ctx, "UnlockConnector", map[string]any{"connectorId": connectorID})
}
// TriggerMessage asks the charger to proactively send a message (e.g.
// "StatusNotification", "MeterValues", "BootNotification", "Heartbeat").
func (s *Session) TriggerMessage(ctx context.Context, requestedMessage string, connectorID int) (string, error) {
payload := map[string]any{"requestedMessage": requestedMessage}
if connectorID > 0 {
payload["connectorId"] = connectorID
}
return s.statusCall(ctx, "TriggerMessage", payload)
}
// ChangeConfiguration sets a charger configuration key.
func (s *Session) ChangeConfiguration(ctx context.Context, key, value string) (string, error) {
return s.statusCall(ctx, "ChangeConfiguration", map[string]any{"key": key, "value": value})
}
// ConfigKey is one entry in a GetConfiguration reply.
type ConfigKey struct {
Key string `json:"key"`
Readonly bool `json:"readonly"`
Value string `json:"value,omitempty"`
}
// GetConfigurationResult is the GetConfiguration reply.
type GetConfigurationResult struct {
ConfigurationKey []ConfigKey `json:"configurationKey"`
UnknownKey []string `json:"unknownKey,omitempty"`
}
// GetConfiguration reads charger configuration keys; nil/empty keys returns all.
func (s *Session) GetConfiguration(ctx context.Context, keys []string) (GetConfigurationResult, error) {
payload := map[string]any{}
if len(keys) > 0 {
payload["key"] = keys
}
raw, err := s.Call(ctx, "GetConfiguration", payload)
if err != nil {
return GetConfigurationResult{}, err
}
var r GetConfigurationResult
if err := json.Unmarshal(raw, &r); err != nil {
return GetConfigurationResult{}, err
}
return r, nil
}