A dash printed above the value it was missing

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>
This commit is contained in:
tajniak81
2026-09-02 21:45:08 +02:00
co-authored by Claude Opus 5
parent 8e2073fc4c
commit 1a7f04cba0
18 changed files with 670 additions and 36 deletions
@@ -87,6 +87,18 @@ type providerCharger struct {
OcppStatus *int `json:"ocppStatus,omitempty"`
OcppStatusDesc string `json:"ocppStatusDesc,omitempty"`
// What the service knows about the box on the wall rather than the charging:
// the networks it is on, where it thinks it is, when the account bound it,
// and the picture the service shows for the model.
WifiName string `json:"wifiName,omitempty"`
WifiMac string `json:"wifiMac,omitempty"`
WifiRSSI *int `json:"wifiRssi,omitempty"`
BleMac string `json:"bleMac,omitempty"`
TimeZone string `json:"timeZone,omitempty"`
LinkedAt *float64 `json:"linkedAt,omitempty"` // unix seconds
ImageURL string `json:"imageUrl,omitempty"`
RelatedBy []string `json:"relatedBy,omitempty"`
// Attrs is everything else the service said about this charger, under the
// service's own field names. The fields above are the ones DriverVault has a
// name for; this is the remainder, relayed so a card can show what the
@@ -138,6 +150,15 @@ func (ankerChargerSource) chargers(raw json.RawMessage) []providerCharger {
OcppStatus *int `json:"ocppStatus"`
OcppStatusDesc string `json:"ocppStatusDesc"`
WifiName string `json:"wifiName"`
WifiMac string `json:"wifiMac"`
WifiRSSI *int `json:"wifiRssi"`
BleMac string `json:"bleMac"`
TimeZone string `json:"timeZone"`
LinkedAt *float64 `json:"linkedAt"`
ImageURL string `json:"imageUrl"`
RelatedBy []string `json:"relatedBy"`
Attrs map[string]string `json:"attrs"`
} `json:"chargers"`
}
@@ -154,6 +175,8 @@ func (ankerChargerSource) chargers(raw json.RawMessage) []providerCharger {
Firmware: c.Firmware, SiteID: c.SiteID, SiteName: c.SiteName,
Sources: c.Sources, Status: c.StatusDesc, Online: c.Online,
Power: c.Power, OcppStatus: c.OcppStatus, OcppStatusDesc: c.OcppStatusDesc,
WifiName: c.WifiName, WifiMac: c.WifiMac, WifiRSSI: c.WifiRSSI, BleMac: c.BleMac,
TimeZone: c.TimeZone, LinkedAt: c.LinkedAt, ImageURL: c.ImageURL, RelatedBy: c.RelatedBy,
Attrs: c.Attrs,
})
}
@@ -544,3 +544,41 @@ func (s *Server) handleAnkerChargers(w http.ResponseWriter, r *http.Request) {
// that document as-is so the UI sees exactly what the capability produced.
writeJSON(w, http.StatusOK, json.RawMessage(raw))
}
// GET /api/integrations/anker-solix/chargers/{sn}/details — every per-charger
// view the account holds: the station record, the charging totals, the OCPP
// backend and the RFID cards. Gated exactly like the charger list, and answered
// the same way when a gate is off: 200 with nothing and the reason, because a
// disconnected integration is a normal state with an answer.
func (s *Server) handleAnkerChargerDetails(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
sn := strings.TrimSpace(r.PathValue("sn"))
if sn == "" {
writeError(w, http.StatusBadRequest, "charger serial required")
return
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
res := s.resolveAnker(r.Context(), who, userRaw)
if reason := ankerGate(res, true); reason != "" {
writeJSON(w, http.StatusOK, map[string]any{"sn": sn, "views": []any{}, "unavailable": true, "detail": reason})
return
}
cfg := map[string]string{
"email": res.eff.Email,
"password": res.eff.Password,
"country": res.eff.Country,
}
raw, err := s.plugins.InvokeWith(r.Context(), ankerPlugin, cfg, "charger-details",
mustJSON(map[string]any{"sn": sn}))
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, json.RawMessage(raw))
}
+2
View File
@@ -51,6 +51,7 @@
// GET /api/integrations/anker-solix PUT /api/integrations/anker-solix
// POST /api/integrations/anker-solix/health
// GET /api/integrations/anker-solix/chargers
// GET /api/integrations/anker-solix/chargers/{sn}/details
// GET /api/integrations/greencell PUT /api/integrations/greencell
// POST /api/integrations/greencell/health
// GET /api/integrations/greencell/chargers
@@ -439,6 +440,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("PUT /api/integrations/anker-solix", s.handlePutAnker)
mux.HandleFunc("POST /api/integrations/anker-solix/health", s.handleAnkerHealth)
mux.HandleFunc("GET /api/integrations/anker-solix/chargers", s.handleAnkerChargers)
mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/details", s.handleAnkerChargerDetails)
mux.HandleFunc("GET /api/integrations/greencell", s.handleGetGreencell)
mux.HandleFunc("PUT /api/integrations/greencell", s.handlePutGreencell)
mux.HandleFunc("POST /api/integrations/greencell/health", s.handleGreencellHealth)
@@ -181,6 +181,7 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
AuthType: plugins.AuthBasic,
Capabilities: []plugins.Capability{
{ID: "chargers", Method: "POST", Endpoint: epStandaloneChargers, Description: "Every EV charger on the account, merged from the standalone, per-site, bound-device and per-charger station views, each charger carrying every field those views reported (see chargers.go)."},
{ID: "charger-details", Method: "POST", Endpoint: epStationInfo, Description: "Every per-charger view the account holds — the station record, the charging totals, the OCPP backend and the RFID cards — each relayed as the fields it sent (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)."},
@@ -320,6 +321,12 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
if action == "chargers" {
return p.accountChargers(ctx)
}
if action == "charger-details" {
if pp.SN == "" {
return nil, fmt.Errorf("anker-solix: action %q requires an sn (EV charger serial)", action)
}
return p.chargerDetails(ctx, pp.SN)
}
if action == "charger-state" {
if pp.SiteID == "" {
return nil, fmt.Errorf("anker-solix: action %q requires a siteId", action)
@@ -0,0 +1,89 @@
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
}
@@ -56,6 +56,19 @@ type accountCharger struct {
OcppStatus *int `json:"ocppStatus,omitempty"`
OcppStatusDesc string `json:"ocppStatusDesc,omitempty"`
// What the account knows about the box on the wall, as opposed to the
// charging: which networks it is on, where it thinks it is, when it was
// bound, and the picture the app shows for it. Every one of these arrives
// with the device views and none of them was ever read.
WifiName string `json:"wifiName,omitempty"`
WifiMac string `json:"wifiMac,omitempty"`
WifiRSSI *int `json:"wifiRssi,omitempty"`
BleMac string `json:"bleMac,omitempty"`
TimeZone string `json:"timeZone,omitempty"`
LinkedAt *float64 `json:"linkedAt,omitempty"` // unix seconds
ImageURL string `json:"imageUrl,omitempty"`
RelatedBy []string `json:"relatedBy,omitempty"` // ble, wifi — how the app reaches it
// Attrs is everything each view reported about this charger, under the key
// the cloud used for it. The fields above are the ones DriverVault gives a
// name and a meaning to; Attrs is the rest, relayed rather than dropped,
@@ -107,6 +120,13 @@ func flattenInto(dst map[string]string, key string, v any) {
if s == "" {
return // nothing said is not a value
}
// A device record carries the charger's own Bluetooth pairing password,
// and the account views hand out more than one credential besides. That a
// field exists is worth reporting; its value is not something a card
// should put on a screen, or a screenshot should carry off one.
if isSecretKey(key) {
s = "\u2022\u2022\u2022\u2022\u2022\u2022"
}
// Cut by rune, not by byte: half a character is not shorter, it is
// broken, and it would reach the UI as a replacement glyph.
if r := []rune(s); len(r) > maxAttrsLen {
@@ -116,6 +136,23 @@ func flattenInto(dst map[string]string, key string, v any) {
}
}
// secretKeyWords are the field names whose values are credentials rather than
// readings. Matched on the leaf name, so a nested one is caught too.
var secretKeyWords = []string{"password", "passwd", "secret", "token", "private_key", "privatekey", "certificate", "auth_key", "authkey"}
func isSecretKey(key string) bool {
leaf := strings.ToLower(key)
if i := strings.LastIndex(leaf, "."); i >= 0 {
leaf = leaf[i+1:]
}
for _, word := range secretKeyWords {
if strings.Contains(leaf, word) {
return true
}
}
return false
}
func joinAttrKey(prefix, key string) string {
if prefix == "" {
return key
@@ -194,16 +231,49 @@ func (inv *chargerInventory) addStandalone(body []byte) {
}
c := inv.get(sn, "standalone")
c.note(m)
fillString(&c.Name, pickString(m, "evChargerName", "device_name", "alias_name", "name"))
fillString(&c.Model, pickString(m, "device_pn", "product_code", "evChargerPn"))
fillString(&c.SiteID, pickString(m, "site_id", "siteId", "station_id", "stationId"))
fillString(&c.Firmware, pickString(m, "device_sw_version", "sw_version"))
if n, ok := pickInt(m, "evChargerStatus", "operating_state", "status"); ok {
setChargerStatus(c, n)
}
if b, ok := pickBool(m, "wifi_online", "online", "is_online"); ok {
c.Online = &b
fillDevice(c, m)
}
}
// fillDevice reads the fields every device view shares. The three views name
// them alike where they can and differently where they cannot — the same status
// is evChargerStatus in one record and chargerStatus in the next — so each is
// looked up by candidate key, first answer winning, the way the rest of the
// merge works.
func fillDevice(c *accountCharger, m map[string]any) {
fillString(&c.Name, pickString(m, "evChargerName", "device_name", "alias_name", "deviceName", "name"))
fillString(&c.Model, pickString(m, "device_pn", "product_code", "evChargerPn", "pn"))
fillString(&c.Firmware, pickString(m, "device_sw_version", "sw_version", "version"))
fillString(&c.SiteID, pickString(m, "site_id", "siteId", "station_id", "stationId"))
fillString(&c.SiteName, pickString(m, "site_name", "siteName", "station_name", "stationName"))
fillString(&c.WifiName, pickString(m, "wifi_name", "wifiName", "ssid"))
fillString(&c.WifiMac, pickString(m, "wifi_mac", "wifiMac"))
fillString(&c.BleMac, pickString(m, "bt_ble_mac", "bleMac", "bt_mac"))
fillString(&c.TimeZone, pickString(m, "time_zone", "timeZone", "timezone"))
fillString(&c.ImageURL, pickString(m, "img_url", "imgUrl", "image_url"))
if n, ok := pickInt(m, "rssi", "wifi_rssi", "signal"); ok && c.WifiRSSI == nil {
c.WifiRSSI = &n
}
if t, ok := pickFloat(m, "link_time", "linkTime", "bind_time", "create_time"); ok && c.LinkedAt == nil {
c.LinkedAt = &t
}
if len(c.RelatedBy) == 0 {
c.RelatedBy = pickStrings(m, "relate_type", "relateType", "connect_type")
}
// The state, however the view spells it. A charger that answers one view and
// not another is the normal case, which is why this is tried on all of them.
if n, ok := pickInt(m, "evChargerStatus", "chargerStatus", "operating_state", "status"); ok {
setChargerStatus(c, n)
}
if n, ok := pickInt(m, "ocpp_connect_status", "ocppConnectStatus"); ok && c.OcppStatus == nil {
desc, named := ocppConnStatus[n]
if !named {
desc = stateUnknown
}
c.OcppStatus, c.OcppStatusDesc = &n, desc
}
if b, ok := pickBool(m, "wifi_online", "online", "is_online"); ok {
c.Online = &b
}
}
@@ -242,13 +312,7 @@ func (inv *chargerInventory) addBound(body []byte) {
}
c := inv.get(sn, "bound")
c.note(m)
fillString(&c.Name, pickString(m, "device_name", "alias_name", "name"))
fillString(&c.Model, pn)
fillString(&c.Firmware, pickString(m, "device_sw_version", "sw_version", "version"))
fillString(&c.SiteID, pickString(m, "site_id", "siteId"))
if b, ok := pickBool(m, "wifi_online", "online", "is_online"); ok {
c.Online = &b
}
fillDevice(c, m)
}
}
@@ -284,25 +348,8 @@ func (inv *chargerInventory) addStation(sn string, body []byte) {
return
}
c.note(m)
fillString(&c.Name, pickString(m, "evChargerName", "device_name", "alias_name", "name"))
fillString(&c.Model, pickString(m, "device_pn", "product_code", "evChargerPn"))
fillString(&c.Firmware, pickString(m, "device_sw_version", "sw_version", "version"))
fillString(&c.SiteID, pickString(m, "station_id", "stationId", "site_id", "siteId"))
fillString(&c.SiteName, pickString(m, "station_name", "stationName", "site_name", "siteName"))
fillDevice(c, m)
fillString(&c.Power, pickString(m, "power", "charging_power", "chargingPower"))
if n, ok := pickInt(m, "evChargerStatus", "operating_state", "status"); ok {
setChargerStatus(c, n)
}
if n, ok := pickInt(m, "ocpp_connect_status", "ocppConnectStatus"); ok && c.OcppStatus == nil {
desc, named := ocppConnStatus[n]
if !named {
desc = stateUnknown
}
c.OcppStatus, c.OcppStatusDesc = &n, desc
}
if b, ok := pickBool(m, "wifi_online", "online", "is_online"); ok && c.Online == nil {
c.Online = &b
}
}
// siteRef is one system (site) registered on the account.
@@ -549,6 +596,44 @@ func pickString(m map[string]any, keys ...string) string {
return ""
}
// pickFloat returns the first numeric value among keys, unrounded — a unix
// timestamp does not fit an int on every platform and does not want scaling.
func pickFloat(m map[string]any, keys ...string) (float64, bool) {
for _, k := range keys {
switch v := m[k].(type) {
case float64:
return v, true
case string:
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
return f, true
}
}
}
return 0, false
}
// pickStrings returns the first list of strings among keys. The device views
// carry the ways the app can reach a charger this way — ble, wifi — as a plain
// array of names.
func pickStrings(m map[string]any, keys ...string) []string {
for _, k := range keys {
raw, ok := m[k].([]any)
if !ok {
continue
}
out := make([]string, 0, len(raw))
for _, item := range raw {
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
out = append(out, strings.TrimSpace(s))
}
}
if len(out) > 0 {
return out
}
}
return nil
}
// pickInt returns the first numeric value among keys.
func pickInt(m map[string]any, keys ...string) (int, bool) {
for _, k := range keys {
@@ -1,6 +1,9 @@
package ankersolix
import "testing"
import (
"strings"
"testing"
)
// A charger that sits in a station is absent from the standalone list even
// though the account count includes it — the case that made a good login look
@@ -234,3 +237,58 @@ func TestRawRecordsDescendsToANestedList(t *testing.T) {
t.Fatal("a data that is not an object should yield nothing")
}
}
// The bound-device view carries the charger's state and its OCPP link under
// names of its own — chargerStatus, not evChargerStatus — which is how a card
// ends up printing a dash beside a value the same response contains.
func TestBoundViewNamesTheStateItsOwnWay(t *testing.T) {
inv := newInventory()
inv.addBound([]byte(`{"data":{"data":[{"device_sn":"EVSN1","device_pn":"A5191","alias_name":"Home-DK",
"chargerStatus":2,"ocpp_connect_status":2,"wifi_online":true,"wifi_name":"AP_1_IOT",
"wifi_mac":"7CE91373C236","bt_ble_mac":"7CE91373C238","rssi":-63,"time_zone":"Europe/Copenhagen",
"link_time":1788186620,"img_url":"https://example.invalid/a5191.png","relate_type":["ble","wifi"]}]}}`))
c := inv.list()[0]
if c.StatusDesc != stateCharging {
t.Errorf("statusDesc = %q, want %q — the bound view spells it chargerStatus", c.StatusDesc, stateCharging)
}
if c.OcppStatusDesc != "connected" {
t.Errorf("ocppStatusDesc = %q, want connected", c.OcppStatusDesc)
}
if c.WifiName != "AP_1_IOT" || c.WifiMac != "7CE91373C236" || c.BleMac != "7CE91373C238" {
t.Errorf("networks = %+v", c)
}
if c.WifiRSSI == nil || *c.WifiRSSI != -63 {
t.Errorf("wifiRssi = %v, want -63", c.WifiRSSI)
}
if c.TimeZone != "Europe/Copenhagen" || c.ImageURL == "" {
t.Errorf("timeZone/imageUrl = %q / %q", c.TimeZone, c.ImageURL)
}
if c.LinkedAt == nil || *c.LinkedAt != 1788186620 {
t.Errorf("linkedAt = %v, want 1788186620", c.LinkedAt)
}
if len(c.RelatedBy) != 2 || c.RelatedBy[0] != "ble" || c.RelatedBy[1] != "wifi" {
t.Errorf("relatedBy = %v, want [ble wifi]", c.RelatedBy)
}
}
// A device record carries the charger's Bluetooth pairing password. That the
// field exists is reportable; its value is not something a card should carry.
func TestAttrsMaskCredentials(t *testing.T) {
inv := newInventory()
inv.addBound([]byte(`{"data":{"data":[{"device_sn":"EVSN1","device_pn":"A5191",
"blue_password":"AT200025277*!","wifi_name":"AP_1_IOT","cloud":{"auth_token":"abc"}}]}}`))
attrs := inv.list()[0].Attrs
for _, key := range []string{"blue_password", "cloud.auth_token"} {
if attrs[key] == "" {
t.Fatalf("attrs[%q] is absent; the field should still be reported", key)
}
if strings.ContainsAny(attrs[key], "ATabc") {
t.Errorf("attrs[%q] = %q, want it masked", key, attrs[key])
}
}
if attrs["wifi_name"] != "AP_1_IOT" {
t.Errorf("wifi_name = %q; masking must not reach a value that is not a credential", attrs["wifi_name"])
}
}