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>
165 lines
5.4 KiB
Go
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
|
|
}
|