diff --git a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go index 5a6afe0..c05819a 100644 --- a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go +++ b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go @@ -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. diff --git a/API Server/internal/plugins/builtin/ankersolix/ankersolix_test.go b/API Server/internal/plugins/builtin/ankersolix/ankersolix_test.go index 1652303..27d71a4 100644 --- a/API Server/internal/plugins/builtin/ankersolix/ankersolix_test.go +++ b/API Server/internal/plugins/builtin/ankersolix/ankersolix_test.go @@ -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) + } + } +}