The Charger information card read "—" beside State and OCPP status while the raw
block three rows below it printed chargerStatus 1 and ocpp_connect_status 2. The
account had answered both. The merge asked for the state as evChargerStatus,
operating_state or status, which is how the standalone and station views spell
it, and the bound-device view — the one this account actually answers from —
spells it chargerStatus. The OCPP state it never asked that view for at all. All
three views now read through one fillDevice, which tries every spelling a view is
known to use, so a value any of them sends reaches the row that was drawing a
dash for want of it.
The same views were carrying the whole box-on-the-wall half unread: the Wi-Fi
network and its MAC, the signal strength, the Bluetooth MAC, the time zone, when
the account bound the charger, how the app can reach it — BLE, Wi-Fi — and the
product shot for the model, which now sits beside the charger's name in both
apps. Named rows, in three languages, the way the register map's readings are
named.
One field wanted the opposite treatment. The device record carries blue_password,
the charger's own Bluetooth pairing password, and the card was printing it in
clear into every screenshot anyone takes of that page. Any leaf key holding a
password, secret, token, private key or certificate is now masked in the raw
block: that the field exists is worth reporting, its value is not.
Four endpoints answer only when a serial is named, so none of them could belong
to the list the card is drawn from, and nothing had ever called them. The station
record, the charging totals, the OCPP backend and the RFID cards now arrive
through a charger-details capability behind
GET …/anker-solix/chargers/{sn}/details, asked for the charger being looked at,
best effort, each view reporting its own failure — 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.
Those four are shown under the cloud's own keys, and that is not an oversight.
The REST map documents which endpoints exist and what each is for; it does not
document a single one of their payloads. Naming those fields is the next commit,
made from what actually comes back, now that there is somewhere to see it.
Not touched: the endpoints the map marks ready but unwired — session history,
site price, OTA, sharing, notifications — each a feature rather than a row on this
card; and the unmapped ones, which the map warns delete sessions and unbind
devices with payloads nobody has ever seen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
90 lines
3.1 KiB
Go
90 lines
3.1 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. Four more endpoints answer only when a serial
|
|
// is named: the station record the app opens on a charger, its cumulative
|
|
// charging totals, which OCPP backend it is pointed at, and the RFID cards
|
|
// authorised on it. None of them lists a charger, so none belongs in the merge —
|
|
// and none of them was reachable from the app at all until this capability.
|
|
//
|
|
// 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"
|
|
)
|
|
|
|
// 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.
|
|
type chargerDetailView struct {
|
|
ID string `json:"id"`
|
|
Attrs map[string]string `json:"attrs,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type chargerDetailsDoc struct {
|
|
SN string `json:"sn"`
|
|
Views []chargerDetailView `json:"views"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
views := []struct {
|
|
id string
|
|
endpoint string
|
|
payload map[string]any
|
|
}{
|
|
{"station", epStationInfo, map[string]any{"evChargerSn": sn, "featuretype": 1}},
|
|
{"totals", epChargeStats, map[string]any{
|
|
"device_sn": sn, "date_type": "all", "start_date": "", "end_date": ""}},
|
|
{"ocpp", epOcppInfo, map[string]any{"device_sn": sn}},
|
|
{"rfid", epRfidCards, map[string]any{"device_sn": sn}},
|
|
}
|
|
|
|
doc := chargerDetailsDoc{SN: sn, Views: make([]chargerDetailView, 0, len(views))}
|
|
failed := 0
|
|
for _, v := range views {
|
|
out := chargerDetailView{ID: v.id}
|
|
body, err := p.apiRequest(ctx, v.endpoint, v.payload)
|
|
if err != nil {
|
|
out.Error, failed = shorten(err.Error()), failed+1
|
|
} else {
|
|
out.Attrs = map[string]string{}
|
|
flattenInto(out.Attrs, "", dataValue(body))
|
|
}
|
|
doc.Views = append(doc.Views, out)
|
|
}
|
|
if failed == len(views) {
|
|
return nil, fmt.Errorf("anker-solix: charger %s: no per-charger view answered", sn)
|
|
}
|
|
return json.Marshal(doc)
|
|
}
|
|
|
|
// 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
|
|
}
|