The connector called ten endpoints of the read surface the map lists, and the charger card showed four views. Everything else an EV charger can reach is now a capability too: the sessions and the history, the savings, the sharing, the binding, the group, the Wi-Fi, the firmware and its update log, the tamper records, the site's own detail, price, networks and energy — plus the vehicle catalogue, dynamic pricing, the currencies and the notification views. Thirty-eight endpoints, one action each. The two message views are GET, so the request path grew a GET half that shares the login retry with the POST one. The per-charger fan-out asks all of them, six at a time rather than one after another, and a charger that belongs to a site brings that site's four views with it once the by-serial lookup has found it. A view that answers with nothing now says so instead of vanishing: the station record is empty for a standalone charger because it has no station, which is an answer worth reading. And "source 0" in the OCPP box carries the address the account's endpoint list gives it. Anker's account-level writes stay out, as do the endpoints whose payloads were only ever read out of the app package. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
9.2 KiB
Go
247 lines
9.2 KiB
Go
package ankersolix
|
|
|
|
// The account's other views of one charger.
|
|
//
|
|
// The merged inventory (chargers.go) answers "what chargers are there", and it
|
|
// asks the views that list them. Everything else the cloud will say about one
|
|
// charger answers only when a serial is named — the station record, the
|
|
// charging totals and the completed sessions, the OCPP backend, the RFID cards,
|
|
// the binding, the sharing, the firmware, the Wi-Fi, the tamper log — so none of
|
|
// them can be part of the merge, and none of them was reachable from the app at
|
|
// all until this capability.
|
|
//
|
|
// Every read the endpoint map lists for one charger is asked here, rather than a
|
|
// chosen few: which of them an account can answer is itself the finding, and a
|
|
// view nobody asks for is a field nobody can see. A charger that belongs to a
|
|
// site brings four more with it — the site is only known once the first pass has
|
|
// asked for it, so the site's own views are a second pass.
|
|
//
|
|
// What they answer with is not documented, by Anker or by the reference: the
|
|
// endpoints are known, their payloads are not. So each view is relayed as the
|
|
// fields it actually sent, flattened under the cloud's own keys, rather than
|
|
// projected onto names invented here. A field that turns out to matter can be
|
|
// named later, from evidence.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// chargerDetailView is one endpoint's answer about one charger. A view that
|
|
// fails carries its reason instead of its fields: an account that is not the
|
|
// owner cannot read the cards, which is a fact about the account rather than an
|
|
// error in the read. A view that answers with nothing carries neither, and says
|
|
// so by being empty — a standalone charger has no station record, and that is
|
|
// worth seeing too.
|
|
//
|
|
// Note is the one thing said in DriverVault's own words rather than the cloud's:
|
|
// a number the account elsewhere gives a meaning to, resolved. It never replaces
|
|
// a field, so the cloud's own keys stay exactly as they arrived.
|
|
type chargerDetailView struct {
|
|
ID string `json:"id"`
|
|
Attrs map[string]string `json:"attrs,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type chargerDetailsDoc struct {
|
|
SN string `json:"sn"`
|
|
Views []chargerDetailView `json:"views"`
|
|
}
|
|
|
|
// detailRequest is one view to ask for: the id it is reported under, the
|
|
// endpoint, and the body it takes.
|
|
type detailRequest struct {
|
|
id string
|
|
endpoint string
|
|
payload map[string]any
|
|
}
|
|
|
|
// detailFanout is how many of these are in flight at once. The cloud is a
|
|
// stranger's API being asked twenty questions about one charger; a handful at a
|
|
// time keeps the whole card under a couple of seconds without arriving as a
|
|
// burst that looks like something to rate-limit.
|
|
const detailFanout = 6
|
|
|
|
// chargerDetails asks every per-charger endpoint and returns what each answered.
|
|
// One failing view is reported in place; only losing all of them is an error,
|
|
// for the same reason the inventory works that way — a charger the cloud will
|
|
// half talk about is still worth showing.
|
|
func (p *Plugin) chargerDetails(ctx context.Context, sn string) (json.RawMessage, error) {
|
|
if _, err := p.ensureToken(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Everything that needs only the serial. Ordered as the card reads them:
|
|
// what it is doing, what it has done, who may use it, what it is, and the
|
|
// housekeeping the account keeps about it.
|
|
views := []detailRequest{
|
|
{"station", epStationInfo, map[string]any{"evChargerSn": sn, "featuretype": 1}},
|
|
{"stationFeature", epStationInfo, map[string]any{"evChargerSn": sn, "featuretype": 2}},
|
|
{"totals", epChargeStats, map[string]any{
|
|
"device_sn": sn, "date_type": "all", "start_date": "", "end_date": ""}},
|
|
{"orders", epChargeStatsList, map[string]any{
|
|
"device_sn": sn, "order_status": 1, "date_type": "all",
|
|
"start_date": "", "end_date": "", "page": 0, "page_size": 10}},
|
|
{"sessions", epChargingOrders, map[string]any{"device_sn": sn, "start_time": ""}},
|
|
{"income", epDeviceIncome, map[string]any{"device_sn": sn, "start_time": ""}},
|
|
{"ocpp", epOcppInfo, map[string]any{"device_sn": sn}},
|
|
{"ocppEndpoints", epOcppEndpoints, map[string]any{}},
|
|
{"rfid", epRfidCards, map[string]any{"device_sn": sn}},
|
|
{"shared", epSharedDevice, map[string]any{"device_sn": sn}},
|
|
{"binding", epDeviceBindDetails, map[string]any{"device_sn_list": []string{sn}}},
|
|
{"group", epGroupDevices, map[string]any{"device_sn": sn}},
|
|
{"wifi", epChargerWifiInfo, map[string]any{"evChargerSn": sn}},
|
|
{"ota", epOtaInfo, map[string]any{"device_sn_list": []string{sn}}},
|
|
{"upgrades", epUpgradeRecords, map[string]any{"device_sn": sn}},
|
|
{"tamper", epTamperRecords, map[string]any{"device_sn": sn, "page_num": 1, "page_size": 20}},
|
|
{"siteBySn", epSiteDetailBySN, map[string]any{"device_sn": sn}},
|
|
}
|
|
|
|
out := p.askViews(ctx, views)
|
|
|
|
// The site's own views, once the first pass has found which site this is.
|
|
// A standalone charger has none, and asking anyway would be four errors
|
|
// about a site that does not exist.
|
|
if siteID := siteIDFrom(out); siteID != "" {
|
|
out = append(out, p.askViews(ctx, []detailRequest{
|
|
{"site", epSiteDetail, map[string]any{"site_id": siteID}},
|
|
{"sitePrice", epSitePrice, map[string]any{"site_id": siteID}},
|
|
{"siteWifi", epSiteWifiList, map[string]any{"site_id": siteID}},
|
|
{"energy", epEnergyAnalysis, map[string]any{
|
|
"site_id": siteID, "device_sn": sn, "device_type": "ev_charger",
|
|
"type": energyRange(""), "start_time": "", "end_time": ""}},
|
|
})...)
|
|
}
|
|
|
|
// Which OCPP backend "source 0" actually is, from the list the account
|
|
// publishes for exactly that purpose.
|
|
noteOcppSource(out)
|
|
|
|
failed := 0
|
|
for _, v := range out {
|
|
if v.Error != "" {
|
|
failed++
|
|
}
|
|
}
|
|
if failed == len(out) {
|
|
return nil, fmt.Errorf("anker-solix: charger %s: no per-charger view answered", sn)
|
|
}
|
|
return json.Marshal(chargerDetailsDoc{SN: sn, Views: out})
|
|
}
|
|
|
|
// askViews asks a batch of views at once and returns them in the order given —
|
|
// concurrency is for the waiting, not for the reading, and a card whose boxes
|
|
// reorder between refreshes is a card nobody can read against itself.
|
|
func (p *Plugin) askViews(ctx context.Context, reqs []detailRequest) []chargerDetailView {
|
|
out := make([]chargerDetailView, len(reqs))
|
|
sem := make(chan struct{}, detailFanout)
|
|
var wg sync.WaitGroup
|
|
for i, req := range reqs {
|
|
wg.Add(1)
|
|
go func(i int, req detailRequest) {
|
|
defer wg.Done()
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
|
|
view := chargerDetailView{ID: req.id}
|
|
body, err := p.apiRequest(ctx, req.endpoint, req.payload)
|
|
if err != nil {
|
|
view.Error = shorten(err.Error())
|
|
} else {
|
|
attrs := map[string]string{}
|
|
flattenInto(attrs, "", dataValue(body))
|
|
if len(attrs) > 0 {
|
|
view.Attrs = attrs
|
|
}
|
|
}
|
|
out[i] = view
|
|
}(i, req)
|
|
}
|
|
wg.Wait()
|
|
return out
|
|
}
|
|
|
|
// siteIDFrom reads the site this charger belongs to out of the by-serial lookup,
|
|
// under whichever key that view used for it.
|
|
func siteIDFrom(views []chargerDetailView) string {
|
|
for _, v := range views {
|
|
if v.ID != "siteBySn" {
|
|
continue
|
|
}
|
|
for _, key := range []string{"site_id", "siteId", "station_id", "site_info.site_id"} {
|
|
if id := strings.TrimSpace(v.Attrs[key]); id != "" {
|
|
return id
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// noteOcppSource names the OCPP backend the charger is pointed at. The per-
|
|
// charger view reports it as a bare number ("source 0"); the account's endpoint
|
|
// list is where those numbers have addresses, so the two are read together and
|
|
// the answer is said once, as the view's note. When the list does not carry the
|
|
// number, nothing is said — a guessed backend is worse than a plain number.
|
|
func noteOcppSource(views []chargerDetailView) {
|
|
var ocpp *chargerDetailView
|
|
var endpoints map[string]string
|
|
for i := range views {
|
|
switch views[i].ID {
|
|
case "ocpp":
|
|
ocpp = &views[i]
|
|
case "ocppEndpoints":
|
|
endpoints = views[i].Attrs
|
|
}
|
|
}
|
|
if ocpp == nil || len(ocpp.Attrs) == 0 || len(endpoints) == 0 {
|
|
return
|
|
}
|
|
source := strings.TrimSpace(ocpp.Attrs["source"])
|
|
if source == "" {
|
|
return
|
|
}
|
|
if url := endpointForSource(endpoints, source); url != "" {
|
|
ocpp.Note = "source " + source + " = " + url
|
|
}
|
|
}
|
|
|
|
// endpointForSource finds the address listed under a source number. The list
|
|
// arrives flattened — list[0].source, list[0].url and whatever else it carries —
|
|
// so the entry whose source matches is found first, and its address taken from
|
|
// that same entry.
|
|
func endpointForSource(endpoints map[string]string, source string) string {
|
|
prefix := ""
|
|
for key, val := range endpoints {
|
|
if !strings.HasSuffix(key, ".source") || strings.TrimSpace(val) != source {
|
|
continue
|
|
}
|
|
prefix = strings.TrimSuffix(key, ".source")
|
|
break
|
|
}
|
|
if prefix == "" {
|
|
return ""
|
|
}
|
|
for _, field := range []string{".url", ".endpoint", ".address", ".ocpp_url", ".server_url", ".name"} {
|
|
if v := strings.TrimSpace(endpoints[prefix+field]); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// dataValue returns a response's "data", whatever shape it came in: these views
|
|
// answer with an object, and the card list answers with an array.
|
|
func dataValue(body []byte) any {
|
|
var env struct {
|
|
Data any `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &env); err != nil {
|
|
return nil
|
|
}
|
|
return env.Data
|
|
}
|