Anker Solix: the charger's mode, and the modes it can be moved into
The connector was written against anker-solix-api v3.7.0 and upstream is at 3.8.1 now. The reassuring half of the check first: nothing we depend on moved. The passport/login ECDH exchange, the headers, and every endpoint path this plugin calls are identical across v3.7.0...v3.8.1 — the only apitypes movement touching an EV charger was get_device_rfid_cards being reordered within its own dict. The 400 new lines in charger.py are the A2345 USB charger, which shares a filename with our device and nothing else. What did land for the V1 is two entries in the release notes, and both are MQTT: 3.8.0 gave standalone chargers the usage-mode entity they were missing, 3.8.1 added a switch that reads those modes as a plain on/off so EVCC and its like have a binary to hold. We control chargers over OCPP, not MQTT, so the command path is not ours to port. The reading of state underneath it is, and that half does come over the cloud. So charger-state. The status code arrives under two different names depending on which system family a site belongs to — operating_state inside a scene's charging_pile_list, evChargerStatus inside HES system running info — and upstream's poller quietly renames both to ev_charger_status on ingest, which is the tell that they are the same number. We ask both and merge, because a site answering only one of them is the normal case rather than a fault; the call fails only when neither view is there. chargerMode and chargerModeOptions then follow ev_charger_mode_state and ev_charger_mode_options as written, including the rule that a stopped charger is startable only from standby, and the binary is the same one 3.8.1 chose: everything that is not stop_charge counts as on. The gap worth naming is that the boost flag and the plug and start countdowns reach upstream over MQTT and never over the cloud, so three of the six modes cannot occur here. That is not a bug to be found later — chargerMode takes them as parameters and the callers pass their zero values, so the day an MQTT source exists the derivation is already correct and only its inputs change. The package doc says so in the scope list beside the other limits. Five endpoints upstream has had all along and we never exposed come with it, all EV-charger-scoped: the site scene, energy_analysis under device_type ev_charger, a charger's RFID cards, Anker's own OCPP endpoint list, and one vehicle's details. charger-status takes the featuretype it was hardcoding at 1, since upstream's exporter asks for both 1 and 2 and there was never a reason for us to see only half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fd75833707
commit
5e6b8b4b1c
@@ -2,11 +2,18 @@
|
||||
// scoped to the Anker Solix V1 Smart EV Charger (product A5191). It is a Go
|
||||
// re-implementation of the authentication and read-only data flow from the
|
||||
// anker-solix-api project (https://github.com/thomluther/anker-solix-api),
|
||||
// adapted to DriverVault's plugin contract.
|
||||
// adapted to DriverVault's plugin contract. It tracks upstream v3.8.1
|
||||
// (ha-anker-solix 3.8.1); the login exchange and every endpoint used here are
|
||||
// unchanged since v3.7.0.
|
||||
//
|
||||
// Scope & limitations:
|
||||
// - Read-only. Only EV-charger information is retrieved; no charge start/stop
|
||||
// or configuration commands are implemented.
|
||||
// or configuration commands are implemented. Control is a separate concern
|
||||
// and runs over OCPP (see internal/ocpp), not the cloud API.
|
||||
// - Cloud only. Upstream reads a charger's live state over both the cloud and
|
||||
// MQTT; we take the cloud half. Signals that exist only in MQTT — the boost
|
||||
// flag and the plug/start countdowns — are therefore never set, which the
|
||||
// derived state accounts for.
|
||||
// - Single device family. Capabilities target the V1 Smart EV Charger; other
|
||||
// Anker Power devices (solarbanks, power stations, HES) are out of scope.
|
||||
// - Unofficial. This talks to Anker's private mobile-app cloud API with the
|
||||
@@ -41,6 +48,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -68,14 +76,57 @@ const (
|
||||
|
||||
epStandaloneChargers = "charging_hes_svc/get_user_bind_and_not_in_station_evchargers" // list account EV chargers not assigned to a station
|
||||
epStationInfo = "charging_hes_svc/get_evcharger_station_info" // per-charger station/status info
|
||||
epSystemRunInfo = "charging_hes_svc/get_system_running_info" // system runtime info; carries evChargerInfos[].evChargerStatus
|
||||
epSceneInfo = "power_service/v1/site/get_scen_info" // site home view; carries charging_pile_info (note upstream's "scen" typo)
|
||||
epEnergyAnalysis = "power_service/v1/site/energy_analysis" // interval energy for a site, device_type "ev_charger"
|
||||
epChargeStats = "power_service/v1/app/order/get_charge_order_stats" // charging totals for a charger
|
||||
epChargeStatsList = "power_service/v1/app/order/get_charge_order_stats_list" // per-order charging history
|
||||
epOcppInfo = "power_service/v1/app/get_ocpp_info" // OCPP endpoint source info for a charger
|
||||
epOcppEndpoints = "power_service/v1/app/get_ocpp_endpoint_list" // the OCPP endpoints Anker itself uses, with their source numbers
|
||||
epRfidCards = "power_service/v1/rfid/get_device_cards" // RFID cards authorised on a charger
|
||||
epBindDevices = "power_service/v1/app/get_relate_and_bind_devices" // bound devices incl. firmware version
|
||||
epSiteList = "power_service/v1/site/get_site_list" // sites (systems) on the account
|
||||
epUserVehicles = "power_service/v1/app/vehicle/get_vehicle_list" // vehicles registered for smart charging
|
||||
epVehicleDetail = "power_service/v1/app/vehicle/get_vehicle_detail" // details for one registered vehicle
|
||||
)
|
||||
|
||||
// EV-charger operating states, mirroring anker-solix-api's SolixEvChargerStatus.
|
||||
// The cloud reports the same code under two names: operating_state in a site's
|
||||
// charging_pile_list, evChargerStatus in HES system running info.
|
||||
const (
|
||||
stateStandby = "standby"
|
||||
statePreparing = "preparing"
|
||||
stateCharging = "charging"
|
||||
stateChargerPaused = "charger_paused"
|
||||
stateVehiclePaused = "vehicle_paused"
|
||||
stateCompleted = "completed"
|
||||
stateReserving = "reserving"
|
||||
stateDisabled = "disabled"
|
||||
stateError = "error"
|
||||
stateUnknown = "unknown"
|
||||
)
|
||||
|
||||
var evChargerStatus = map[int]string{
|
||||
0: stateStandby, 1: statePreparing, 2: stateCharging, 3: stateChargerPaused,
|
||||
4: stateVehiclePaused, 5: stateCompleted, 6: stateReserving, 7: stateDisabled,
|
||||
8: stateError,
|
||||
}
|
||||
|
||||
// Operational modes, mirroring SolixEvChargerMode. start/stop/skip/boost are the
|
||||
// four real control values; wait_plug and wait_start are virtual states the
|
||||
// charger passes through and cannot be commanded into.
|
||||
const (
|
||||
modeStartCharge = "start_charge"
|
||||
modeStopCharge = "stop_charge"
|
||||
modeSkipDelay = "skip_delay"
|
||||
modeBoostCharge = "boost_charge"
|
||||
modeWaitStart = "wait_start"
|
||||
modeWaitPlug = "wait_plug"
|
||||
)
|
||||
|
||||
// ocppConnStatus decodes ocpp_connect_status (SolixOcppConnectionStatus).
|
||||
var ocppConnStatus = map[int]string{0: "disconnected", 1: "connecting", 2: "connected"}
|
||||
|
||||
// tokenExpiryMargin is subtracted from the reported token lifetime so a request
|
||||
// never fires with a token that expires mid-flight.
|
||||
const tokenExpiryMargin = 5 * time.Minute
|
||||
@@ -118,19 +169,25 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
return plugins.Descriptor{
|
||||
Name: "anker-solix",
|
||||
Provider: "Anker Solix (V1 Smart EV Charger)",
|
||||
Version: "0.1.0",
|
||||
Version: "0.2.0",
|
||||
Kind: plugins.KindBuiltin,
|
||||
Category: plugins.CategoryAPIsExternal,
|
||||
AuthType: plugins.AuthBasic,
|
||||
Capabilities: []plugins.Capability{
|
||||
{ID: "chargers", Method: "POST", Endpoint: epStandaloneChargers, Description: "List EV chargers bound to the account."},
|
||||
{ID: "charger-status", Method: "POST", Endpoint: epStationInfo, Description: "Live station/status info for one charger (needs sn)."},
|
||||
{ID: "charger-status", Method: "POST", Endpoint: epStationInfo, Description: "Live station/status info for one charger (needs sn; optional featuretype 1 or 2)."},
|
||||
{ID: "charger-state", Method: "POST", Endpoint: epSceneInfo, Description: "Normalized live state of a site's EV chargers: status, operational mode and the modes it can be switched to (needs siteId; optional sn)."},
|
||||
{ID: "site-status", Method: "POST", Endpoint: epSceneInfo, Description: "Live site view; EV chargers appear under charging_pile_info (needs siteId)."},
|
||||
{ID: "charge-stats", Method: "POST", Endpoint: epChargeStats, Description: "Cumulative charging statistics for one charger (needs sn)."},
|
||||
{ID: "charge-orders", Method: "POST", Endpoint: epChargeStatsList, Description: "Per-session charging history for one charger (needs sn)."},
|
||||
{ID: "charge-energy", Method: "POST", Endpoint: epEnergyAnalysis, Description: "Interval charging energy for a site's EV charger (needs siteId; optional sn, range, startDate, endDate)."},
|
||||
{ID: "rfid-cards", Method: "POST", Endpoint: epRfidCards, Description: "RFID cards authorised on one charger (needs sn)."},
|
||||
{ID: "ocpp-info", Method: "POST", Endpoint: epOcppInfo, Description: "OCPP endpoint source info for one charger (needs sn)."},
|
||||
{ID: "ocpp-endpoints", Method: "POST", Endpoint: epOcppEndpoints, Description: "The OCPP endpoints Anker itself uses, with their source numbers."},
|
||||
{ID: "devices", Method: "POST", Endpoint: epBindDevices, Description: "Bound devices on the account, incl. firmware version."},
|
||||
{ID: "sites", Method: "POST", Endpoint: epSiteList, Description: "Sites (systems) registered to the account."},
|
||||
{ID: "vehicles", Method: "POST", Endpoint: epUserVehicles, Description: "Vehicles registered for smart charging."},
|
||||
{ID: "vehicle", Method: "POST", Endpoint: epVehicleDetail, Description: "Details for one registered vehicle (needs vehicleId)."},
|
||||
},
|
||||
ConfigFields: []plugins.ConfigField{
|
||||
// Credentials are intentionally NOT required at the global (panel) layer,
|
||||
@@ -196,24 +253,47 @@ func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
|
||||
return plugins.Health{Status: plugins.StatusOK, LatencyMs: lat, Detail: detail}
|
||||
}
|
||||
|
||||
// Invoke runs a named read-only capability. Per-charger actions require an "sn"
|
||||
// (the charger serial) in params. The upstream response body is returned
|
||||
// verbatim.
|
||||
// invokeParams is the union of everything an action may be given. Per-charger
|
||||
// actions take "sn" (the charger serial); per-site actions take "siteId", which
|
||||
// comes from the "sites" capability.
|
||||
type invokeParams struct {
|
||||
SN string `json:"sn"`
|
||||
SiteID string `json:"siteId"`
|
||||
VehicleID string `json:"vehicleId"`
|
||||
FeatureType int `json:"featuretype"`
|
||||
Range string `json:"range"` // day | week | month | year
|
||||
StartDate string `json:"startDate"` // YYYY-MM-DD, or YYYY-MM / YYYY for month / year
|
||||
EndDate string `json:"endDate"`
|
||||
}
|
||||
|
||||
// Invoke runs a named read-only capability. The upstream response body is
|
||||
// returned verbatim, except for "charger-state", which is derived (see
|
||||
// chargerState).
|
||||
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
||||
var pp struct {
|
||||
SN string `json:"sn"`
|
||||
}
|
||||
var pp invokeParams
|
||||
if len(params) > 0 {
|
||||
if err := json.Unmarshal(params, &pp); err != nil {
|
||||
return nil, fmt.Errorf("anker-solix: invalid params: %w", err)
|
||||
}
|
||||
}
|
||||
pp.SN = strings.TrimSpace(pp.SN)
|
||||
pp.SiteID = strings.TrimSpace(pp.SiteID)
|
||||
pp.VehicleID = strings.TrimSpace(pp.VehicleID)
|
||||
|
||||
// charger-state fans out over two endpoints and returns a derived document,
|
||||
// so it does not fit the single-endpoint dispatch below.
|
||||
if action == "charger-state" {
|
||||
if pp.SiteID == "" {
|
||||
return nil, fmt.Errorf("anker-solix: action %q requires a siteId", action)
|
||||
}
|
||||
return p.chargerState(ctx, pp.SiteID, pp.SN)
|
||||
}
|
||||
|
||||
var (
|
||||
endpoint string
|
||||
payload map[string]any
|
||||
needSN bool
|
||||
needSite bool
|
||||
)
|
||||
switch action {
|
||||
case "chargers":
|
||||
@@ -224,9 +304,17 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
|
||||
endpoint, payload = epSiteList, map[string]any{}
|
||||
case "vehicles":
|
||||
endpoint, payload = epUserVehicles, map[string]any{}
|
||||
case "ocpp-endpoints":
|
||||
endpoint, payload = epOcppEndpoints, map[string]any{}
|
||||
case "charger-status":
|
||||
// featuretype selects which slice of the station record is returned;
|
||||
// Anker accepts 1 and 2, and 1 is what the app asks for by default.
|
||||
ft := pp.FeatureType
|
||||
if ft != 1 && ft != 2 {
|
||||
ft = 1
|
||||
}
|
||||
endpoint, needSN = epStationInfo, true
|
||||
payload = map[string]any{"evChargerSn": pp.SN, "featuretype": 1}
|
||||
payload = map[string]any{"evChargerSn": pp.SN, "featuretype": ft}
|
||||
case "charge-stats":
|
||||
endpoint, needSN = epChargeStats, true
|
||||
payload = map[string]any{"device_sn": pp.SN, "date_type": "all", "start_date": "", "end_date": ""}
|
||||
@@ -234,15 +322,38 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
|
||||
endpoint, needSN = epChargeStatsList, true
|
||||
payload = map[string]any{"device_sn": pp.SN, "order_status": 1, "date_type": "all",
|
||||
"start_date": "", "end_date": "", "page": 0, "page_size": 10}
|
||||
case "rfid-cards":
|
||||
endpoint, needSN = epRfidCards, true
|
||||
payload = map[string]any{"device_sn": pp.SN}
|
||||
case "ocpp-info":
|
||||
endpoint, needSN = epOcppInfo, true
|
||||
payload = map[string]any{"device_sn": pp.SN}
|
||||
case "site-status":
|
||||
endpoint, needSite = epSceneInfo, true
|
||||
payload = map[string]any{"site_id": pp.SiteID}
|
||||
case "charge-energy":
|
||||
// Anker's energy_analysis is site-scoped; an empty device_sn returns the
|
||||
// site's EV-charger totals, a serial narrows it to one charger.
|
||||
endpoint, needSite = epEnergyAnalysis, true
|
||||
payload = map[string]any{
|
||||
"site_id": pp.SiteID, "device_sn": pp.SN, "device_type": "ev_charger",
|
||||
"type": energyRange(pp.Range), "start_time": pp.StartDate, "end_time": pp.EndDate,
|
||||
}
|
||||
case "vehicle":
|
||||
if pp.VehicleID == "" {
|
||||
return nil, fmt.Errorf("anker-solix: action %q requires a vehicleId", action)
|
||||
}
|
||||
endpoint = epVehicleDetail
|
||||
payload = map[string]any{"vehicle_id": pp.VehicleID}
|
||||
default:
|
||||
return nil, fmt.Errorf("anker-solix: unknown action %q", action)
|
||||
}
|
||||
if needSN && pp.SN == "" {
|
||||
return nil, fmt.Errorf("anker-solix: action %q requires an sn (EV charger serial)", action)
|
||||
}
|
||||
if needSite && pp.SiteID == "" {
|
||||
return nil, fmt.Errorf("anker-solix: action %q requires a siteId", action)
|
||||
}
|
||||
|
||||
body, err := p.apiRequest(ctx, endpoint, payload)
|
||||
if err != nil {
|
||||
@@ -251,6 +362,17 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
|
||||
return json.RawMessage(body), nil
|
||||
}
|
||||
|
||||
// energyRange normalizes the energy_analysis period, defaulting to a week like
|
||||
// the reference implementation's EV-charger poll.
|
||||
func energyRange(r string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(r)) {
|
||||
case "day", "week", "month", "year":
|
||||
return strings.ToLower(strings.TrimSpace(r))
|
||||
default:
|
||||
return "week"
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown releases pooled connections.
|
||||
func (p *Plugin) Shutdown(context.Context) error {
|
||||
p.mu.Lock()
|
||||
@@ -261,6 +383,226 @@ func (p *Plugin) Shutdown(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- derived EV-charger state ------------------------------------------------
|
||||
|
||||
// evChargerState is one charger's live state, normalized out of whichever cloud
|
||||
// view reported it. Status and mode names match anker-solix-api so a consumer
|
||||
// can read them against the reference implementation.
|
||||
type evChargerState struct {
|
||||
SN string `json:"sn"`
|
||||
Name string `json:"name,omitempty"`
|
||||
SiteID string `json:"siteId,omitempty"`
|
||||
Source string `json:"source"` // scene | hes — which view the record came from
|
||||
|
||||
Status *int `json:"status,omitempty"` // raw operating_state / evChargerStatus
|
||||
StatusDesc string `json:"statusDesc"`
|
||||
|
||||
// Mode is the operational mode the charger is effectively in; ModeOptions are
|
||||
// the modes it can be switched to from there. Charging is the binary reading
|
||||
// of Mode — everything but stop_charge counts as on.
|
||||
Mode string `json:"mode,omitempty"`
|
||||
ModeOptions []string `json:"modeOptions"`
|
||||
Charging *bool `json:"charging,omitempty"`
|
||||
|
||||
Power string `json:"power,omitempty"` // charge power as reported, unit per upstream
|
||||
OcppStatus *int `json:"ocppStatus,omitempty"`
|
||||
OcppStatusDesc string `json:"ocppStatusDesc,omitempty"`
|
||||
}
|
||||
|
||||
// chargerState fetches a site's EV chargers from both cloud views and returns
|
||||
// their normalized state. The two views cover different system families — the
|
||||
// power_service scene for site systems, the HES service for HES systems — so a
|
||||
// site answering only one of them is normal; the call fails only when neither
|
||||
// view is reachable. An sn, when given, filters to that charger.
|
||||
func (p *Plugin) chargerState(ctx context.Context, siteID, sn string) (json.RawMessage, error) {
|
||||
var (
|
||||
out []evChargerState
|
||||
sceneErr error
|
||||
hesErr error
|
||||
)
|
||||
|
||||
if body, err := p.apiRequest(ctx, epSceneInfo, map[string]any{"site_id": siteID}); err != nil {
|
||||
sceneErr = err
|
||||
} else {
|
||||
out = append(out, parseScenePiles(body, siteID)...)
|
||||
}
|
||||
if body, err := p.apiRequest(ctx, epSystemRunInfo, map[string]any{"siteId": siteID}); err != nil {
|
||||
hesErr = err
|
||||
} else {
|
||||
out = append(out, parseHesChargers(body, siteID)...)
|
||||
}
|
||||
if sceneErr != nil && hesErr != nil {
|
||||
return nil, fmt.Errorf("anker-solix: charger-state: no EV-charger view for site %s: %v; %v",
|
||||
siteID, sceneErr, hesErr)
|
||||
}
|
||||
|
||||
// A charger reported by both views is one charger. The scene record wins: it
|
||||
// is the richer of the two, carrying charge power and OCPP status.
|
||||
seen := make(map[string]bool, len(out))
|
||||
kept := out[:0]
|
||||
for _, c := range out {
|
||||
if seen[c.SN] || (sn != "" && c.SN != sn) {
|
||||
continue
|
||||
}
|
||||
seen[c.SN] = true
|
||||
kept = append(kept, c)
|
||||
}
|
||||
out = kept
|
||||
if out == nil {
|
||||
out = []evChargerState{}
|
||||
}
|
||||
return json.Marshal(map[string]any{"siteId": siteID, "chargers": out})
|
||||
}
|
||||
|
||||
// parseScenePiles reads the EV chargers out of a get_scen_info response. The
|
||||
// scene calls the state operating_state and the charge power just power; the
|
||||
// reference implementation renames both on ingest, and so do we.
|
||||
func parseScenePiles(body []byte, siteID string) []evChargerState {
|
||||
var env struct {
|
||||
Data struct {
|
||||
ChargingPileInfo struct {
|
||||
ChargingPileList []struct {
|
||||
DeviceSN string `json:"device_sn"`
|
||||
DeviceName string `json:"device_name"`
|
||||
OperatingState *int `json:"operating_state"`
|
||||
Power json.Number `json:"power"`
|
||||
OcppConnect *int `json:"ocpp_connect_status"`
|
||||
} `json:"charging_pile_list"`
|
||||
} `json:"charging_pile_info"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return nil
|
||||
}
|
||||
list := env.Data.ChargingPileInfo.ChargingPileList
|
||||
out := make([]evChargerState, 0, len(list))
|
||||
for _, cp := range list {
|
||||
if cp.DeviceSN == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, newChargerState(cp.DeviceSN, cp.DeviceName, siteID, "scene",
|
||||
cp.OperatingState, cp.OcppConnect, cp.Power.String()))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseHesChargers reads the EV chargers out of a get_system_running_info
|
||||
// response, where they arrive as evChargerInfos under their own field names.
|
||||
func parseHesChargers(body []byte, siteID string) []evChargerState {
|
||||
var env struct {
|
||||
Data struct {
|
||||
EvChargerInfos []struct {
|
||||
SN string `json:"evChargerSn"`
|
||||
Name string `json:"evChargerName"`
|
||||
Status *int `json:"evChargerStatus"`
|
||||
} `json:"evChargerInfos"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]evChargerState, 0, len(env.Data.EvChargerInfos))
|
||||
for _, ev := range env.Data.EvChargerInfos {
|
||||
if ev.SN == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, newChargerState(ev.SN, ev.Name, siteID, "hes", ev.Status, nil, ""))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// newChargerState decorates a raw status code with its name, mode and options.
|
||||
func newChargerState(sn, name, siteID, source string, status, ocpp *int, power string) evChargerState {
|
||||
c := evChargerState{
|
||||
SN: sn, Name: name, SiteID: siteID, Source: source,
|
||||
Status: status, StatusDesc: stateUnknown, Power: power,
|
||||
ModeOptions: []string{},
|
||||
}
|
||||
if status != nil {
|
||||
c.StatusDesc = statusName(*status)
|
||||
// The cloud views carry no boost flag and no countdowns — those reach the
|
||||
// reference implementation over MQTT only — so the mode derives from the
|
||||
// status alone and a charger never reads as boosting or waiting here.
|
||||
c.Mode = chargerMode(c.StatusDesc, false, 0, 0)
|
||||
c.ModeOptions = chargerModeOptions(c.Mode, c.StatusDesc)
|
||||
on := c.Mode != modeStopCharge
|
||||
c.Charging = &on
|
||||
}
|
||||
if ocpp != nil {
|
||||
c.OcppStatus = ocpp
|
||||
desc, ok := ocppConnStatus[*ocpp]
|
||||
if !ok {
|
||||
desc = stateUnknown
|
||||
}
|
||||
c.OcppStatusDesc = desc
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// statusName maps an operating-state code to its name.
|
||||
func statusName(code int) string {
|
||||
if name, ok := evChargerStatus[code]; ok {
|
||||
return name
|
||||
}
|
||||
return stateUnknown
|
||||
}
|
||||
|
||||
// chargerMode derives the operational mode from the charger's status, mirroring
|
||||
// anker-solix-api's ev_charger_mode_state. boost and the two countdowns are MQTT
|
||||
// signals; pass their zero values when only cloud data is available.
|
||||
func chargerMode(statusDesc string, boost bool, plugCountdown, startCountdown int) string {
|
||||
if boost {
|
||||
return modeBoostCharge
|
||||
}
|
||||
switch statusDesc {
|
||||
case statePreparing:
|
||||
if plugCountdown > 0 {
|
||||
return modeWaitPlug
|
||||
}
|
||||
if startCountdown > 0 {
|
||||
return modeWaitStart
|
||||
}
|
||||
return modeStartCharge
|
||||
case stateCharging, stateChargerPaused, stateVehiclePaused:
|
||||
return modeStartCharge
|
||||
default:
|
||||
return modeStopCharge
|
||||
}
|
||||
}
|
||||
|
||||
// chargerModeOptions lists the modes reachable from the current one, mirroring
|
||||
// ev_charger_mode_options. The result is sorted so the output is stable.
|
||||
func chargerModeOptions(mode, statusDesc string) []string {
|
||||
if mode == "" {
|
||||
return []string{}
|
||||
}
|
||||
set := map[string]bool{mode: true}
|
||||
switch mode {
|
||||
case modeWaitPlug, modeWaitStart, modeStartCharge:
|
||||
set[modeStopCharge] = true
|
||||
// A start delay can only be skipped while it is running; boost only applies
|
||||
// once charging has been commanded.
|
||||
if mode == modeWaitStart {
|
||||
set[modeSkipDelay] = true
|
||||
} else if mode == modeStartCharge {
|
||||
set[modeBoostCharge] = true
|
||||
}
|
||||
case modeBoostCharge:
|
||||
set[modeStopCharge] = true
|
||||
default:
|
||||
// Only a charger sitting in standby can be told to start.
|
||||
if statusDesc == stateStandby {
|
||||
set[modeStartCharge] = true
|
||||
}
|
||||
}
|
||||
opts := make([]string, 0, len(set))
|
||||
for m := range set {
|
||||
opts = append(opts, m)
|
||||
}
|
||||
sort.Strings(opts)
|
||||
return opts
|
||||
}
|
||||
|
||||
// ---- token management --------------------------------------------------------
|
||||
|
||||
// ensureToken guarantees a non-expired auth token, logging in as needed.
|
||||
|
||||
@@ -250,3 +250,182 @@ func TestLoginBodyShape(t *testing.T) {
|
||||
t.Fatal("login password encryption did not round-trip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeRequiresSiteID(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"email": "u@example.com", "password": "p"})
|
||||
|
||||
for _, action := range []string{"charger-state", "site-status", "charge-energy"} {
|
||||
if _, err := p.Invoke(context.Background(), action, nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "requires a siteId") {
|
||||
t.Fatalf("action %q: expected siteId-required error, got %v", action, err)
|
||||
}
|
||||
}
|
||||
if _, err := p.Invoke(context.Background(), "vehicle", nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "requires a vehicleId") {
|
||||
t.Fatalf("expected vehicleId-required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnergyRange(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"day": "day", "week": "week", "month": "month", "year": "year",
|
||||
"YEAR": "year", " month ": "month",
|
||||
"": "week", "decade": "week",
|
||||
} {
|
||||
if got := energyRange(in); got != want {
|
||||
t.Errorf("energyRange(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChargerMode(t *testing.T) {
|
||||
// Mirrors anker-solix-api's ev_charger_mode_state: a boost overrides
|
||||
// everything, preparing splits on the countdowns, and the three active
|
||||
// charging states all read as start_charge.
|
||||
cases := []struct {
|
||||
status string
|
||||
boost bool
|
||||
plugCountdown, startCountdown int
|
||||
want string
|
||||
}{
|
||||
{stateCharging, true, 0, 0, modeBoostCharge},
|
||||
{stateStandby, true, 0, 0, modeBoostCharge},
|
||||
{statePreparing, false, 30, 0, modeWaitPlug},
|
||||
{statePreparing, false, 0, 30, modeWaitStart},
|
||||
{statePreparing, false, 30, 30, modeWaitPlug}, // plug wins over start
|
||||
{statePreparing, false, 0, 0, modeStartCharge},
|
||||
{stateCharging, false, 0, 0, modeStartCharge},
|
||||
{stateChargerPaused, false, 0, 0, modeStartCharge},
|
||||
{stateVehiclePaused, false, 0, 0, modeStartCharge},
|
||||
{stateStandby, false, 0, 0, modeStopCharge},
|
||||
{stateCompleted, false, 0, 0, modeStopCharge},
|
||||
{stateDisabled, false, 0, 0, modeStopCharge},
|
||||
{stateError, false, 0, 0, modeStopCharge},
|
||||
{stateUnknown, false, 0, 0, modeStopCharge},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := chargerMode(c.status, c.boost, c.plugCountdown, c.startCountdown)
|
||||
if got != c.want {
|
||||
t.Errorf("chargerMode(%q, boost=%v, %d, %d) = %q, want %q",
|
||||
c.status, c.boost, c.plugCountdown, c.startCountdown, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChargerModeOptions(t *testing.T) {
|
||||
// Mirrors ev_charger_mode_options; results are sorted.
|
||||
cases := []struct {
|
||||
mode, status string
|
||||
want []string
|
||||
}{
|
||||
{modeStartCharge, stateCharging, []string{modeBoostCharge, modeStartCharge, modeStopCharge}},
|
||||
{modeWaitStart, statePreparing, []string{modeSkipDelay, modeStopCharge, modeWaitStart}},
|
||||
{modeWaitPlug, statePreparing, []string{modeStopCharge, modeWaitPlug}},
|
||||
{modeBoostCharge, stateCharging, []string{modeBoostCharge, modeStopCharge}},
|
||||
// Stopped: startable only from standby.
|
||||
{modeStopCharge, stateStandby, []string{modeStartCharge, modeStopCharge}},
|
||||
{modeStopCharge, stateCompleted, []string{modeStopCharge}},
|
||||
{modeStopCharge, stateError, []string{modeStopCharge}},
|
||||
{"", stateUnknown, []string{}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := chargerModeOptions(c.mode, c.status)
|
||||
if len(got) != len(c.want) {
|
||||
t.Errorf("chargerModeOptions(%q, %q) = %v, want %v", c.mode, c.status, got, c.want)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Errorf("chargerModeOptions(%q, %q) = %v, want %v", c.mode, c.status, got, c.want)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScenePiles(t *testing.T) {
|
||||
body := []byte(`{"code":0,"data":{"charging_pile_info":{"charging_pile_list":[
|
||||
{"device_sn":"EVSN1","device_name":"Garage","operating_state":2,"power":"7.4","ocpp_connect_status":2},
|
||||
{"device_sn":"","device_name":"nameless"},
|
||||
{"device_sn":"EVSN2","operating_state":99}]}}}`)
|
||||
|
||||
got := parseScenePiles(body, "site-1")
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("parsed %d chargers, want 2 (the SN-less entry is dropped)", len(got))
|
||||
}
|
||||
|
||||
c := got[0]
|
||||
if c.SN != "EVSN1" || c.Name != "Garage" || c.SiteID != "site-1" || c.Source != "scene" {
|
||||
t.Errorf("identity = %+v", c)
|
||||
}
|
||||
if c.StatusDesc != stateCharging || c.Mode != modeStartCharge {
|
||||
t.Errorf("status/mode = %q/%q, want %q/%q", c.StatusDesc, c.Mode, stateCharging, modeStartCharge)
|
||||
}
|
||||
if c.Charging == nil || !*c.Charging {
|
||||
t.Error("a charging charger must read as charging")
|
||||
}
|
||||
if c.Power != "7.4" {
|
||||
t.Errorf("power = %q, want 7.4", c.Power)
|
||||
}
|
||||
if c.OcppStatusDesc != "connected" {
|
||||
t.Errorf("ocppStatusDesc = %q, want connected", c.OcppStatusDesc)
|
||||
}
|
||||
|
||||
// An unmapped status code degrades to unknown, and unknown stops.
|
||||
if got[1].StatusDesc != stateUnknown || got[1].Mode != modeStopCharge {
|
||||
t.Errorf("unmapped status = %q/%q, want %q/%q",
|
||||
got[1].StatusDesc, got[1].Mode, stateUnknown, modeStopCharge)
|
||||
}
|
||||
if got[1].OcppStatus != nil {
|
||||
t.Error("absent ocpp_connect_status must stay nil, not default to disconnected")
|
||||
}
|
||||
if parseScenePiles([]byte("not json"), "site-1") != nil {
|
||||
t.Error("invalid json should parse to nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHesChargers(t *testing.T) {
|
||||
body := []byte(`{"code":0,"data":{"hasEvCharger":true,"evChargerInfos":[
|
||||
{"evChargerSn":"EVSN9","evChargerStatus":0,"evChargerName":"V1 EV Charger"}]}}`)
|
||||
|
||||
got := parseHesChargers(body, "site-2")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("parsed %d chargers, want 1", len(got))
|
||||
}
|
||||
c := got[0]
|
||||
if c.SN != "EVSN9" || c.Source != "hes" || c.SiteID != "site-2" {
|
||||
t.Errorf("identity = %+v", c)
|
||||
}
|
||||
if c.StatusDesc != stateStandby || c.Mode != modeStopCharge {
|
||||
t.Errorf("status/mode = %q/%q, want %q/%q", c.StatusDesc, c.Mode, stateStandby, modeStopCharge)
|
||||
}
|
||||
if c.Charging == nil || *c.Charging {
|
||||
t.Error("a standby charger must not read as charging")
|
||||
}
|
||||
// Standby is the one stopped state that can be started again.
|
||||
var startable bool
|
||||
for _, o := range c.ModeOptions {
|
||||
if o == modeStartCharge {
|
||||
startable = true
|
||||
}
|
||||
}
|
||||
if !startable {
|
||||
t.Errorf("standby should offer start_charge, options = %v", c.ModeOptions)
|
||||
}
|
||||
if parseHesChargers([]byte("{"), "site-2") != nil {
|
||||
t.Error("invalid json should parse to nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusName(t *testing.T) {
|
||||
for code, want := range map[int]string{
|
||||
0: stateStandby, 1: statePreparing, 2: stateCharging, 3: stateChargerPaused,
|
||||
4: stateVehiclePaused, 5: stateCompleted, 6: stateReserving, 7: stateDisabled,
|
||||
8: stateError, 9: stateUnknown, -1: stateUnknown,
|
||||
} {
|
||||
if got := statusName(code); got != want {
|
||||
t.Errorf("statusName(%d) = %q, want %q", code, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user