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 }