A field we have no name for is still a field it sent

Two cards on the Charging page were answering with a fraction of what the charger
and the account actually report, and in both the losses happened quietly, in a
parse that kept the fields it recognised and dropped the rest on the floor.

Charger information asked three account-wide views and kept fourteen fields.
A charger registered on its own is absent from the site view, which is the only
one of the three carrying state, charge power and OCPP status — so exactly the
charger that stands alone got the column of dashes, and nothing said why. The
per-charger station record, get_evcharger_station_info, is what the mobile app
opens when you tap a charger, and it is the one view that answers for a charger
outside a station; it is now the fourth view, asked per charger, a failure there
costing that charger's row and no more. Alongside it, every field each of the
four views sent is kept as attrs, under the cloud's own key, nested objects
joined with a dot and arrays carrying their index. First view to answer a key
wins, which is the rule the named fields already merged by. Two hundred keys and
two hundred and forty runes per value keep a station record with a session list
from becoming the whole card.

Charger readings lost data twice over. The frame decoder skipped any field byte
its per-message map could not name, and a message type with no map decoded to
nothing at all; those fields are now kept under the message and the byte they
arrived in — 0410.c9 — decoded but unscaled, because a factor is half of a
meaning and we do not have the other half. Then the projection read forty-odd
names into typed fields and dropped the remainder: sessionStartedAt, the
per-phase session energies, the three touch modes, the load-balance monitor and
its meter flag, the solar monitor. Those land in extra, and the list maintains
itself — the four accessors note every key they read, extra is what is left, and
a field modelled later stops appearing there without anyone remembering to
remove it.

Keeping unnamed fields had one consequence worth guarding. An unmapped message
now decodes to something rather than nothing, and ingest stamped settingsAt for
anything that was not telemetry — the timestamp a control command waits on to
say the charger acknowledged it. A frame we cannot read is not an
acknowledgement, so the stamp is now conditional on the message type being one
we map, while its fields are kept either way.

Both cards show the remainder as what it is: the service's own key, no unit, no
translation, no renaming, under a heading that says whose words these are. The
blocks appear only when there is something in them, so a Modbus charger's
readings card and a charger the cloud says nothing more about are unchanged.
Naming one of these fields is a later commit, made from evidence; inventing a
label for it today would only make a guess look settled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-02 20:58:15 +02:00
co-authored by Claude Opus 5
parent 1dc20461de
commit 2425a8d3d6
19 changed files with 609 additions and 18 deletions
@@ -180,7 +180,7 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
Category: plugins.CategoryChargers,
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 and bound-device views (see chargers.go)."},
{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-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)."},
@@ -14,10 +14,17 @@ package ankersolix
// ones (the same split chargerState handles).
// - get_relate_and_bind_devices knows every bound device with its model,
// firmware and Wi-Fi state, but nothing about charging.
// - get_evcharger_station_info answers for one charger at a time — the record
// the mobile app opens when you tap a charger. It is the only view that says
// anything live about a charger standing outside a station, which is exactly
// the charger the three list views say least about.
//
// accountChargers therefore asks all three and merges the answers by serial, so
// accountChargers therefore asks all four and merges the answers by serial, so
// the list matches what the mobile app shows however the chargers were
// registered.
// registered. Merging keeps the fields the rest of DriverVault has names for,
// and keeps every other field the cloud sent as well, under the cloud's own key
// (see attrs): a value nothing here recognises is still a value the owner of the
// charger may want to read.
import (
"context"
@@ -48,6 +55,89 @@ type accountCharger struct {
Power string `json:"power,omitempty"`
OcppStatus *int `json:"ocppStatus,omitempty"`
OcppStatusDesc string `json:"ocppStatusDesc,omitempty"`
// 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,
// because Anker documents none of this and a field we have no name for today
// is still the only place some of what the charger knows ever appears.
Attrs map[string]string `json:"attrs,omitempty"`
}
// Two limits keep one charger's attributes to something a card can hold: a
// station record that carries a session list would otherwise arrive as hundreds
// of indexed keys, and a base64 blob as one unreadable page.
const (
maxAttrs = 200
maxAttrsLen = 240
)
// note folds one view's raw record into the charger's attributes. A key already
// answered is left alone, so the first view to report a field wins — the same
// rule the typed fields merge by.
func (c *accountCharger) note(m map[string]any) {
if c.Attrs == nil {
c.Attrs = map[string]string{}
}
flattenInto(c.Attrs, "", m)
}
// flattenInto records every scalar under v in dst, keyed by the path the cloud
// nested it at: objects join with a dot, arrays carry their index. A null is not
// an answer and leaves no key behind.
func flattenInto(dst map[string]string, key string, v any) {
switch t := v.(type) {
case map[string]any:
for k, sub := range t {
flattenInto(dst, joinAttrKey(key, k), sub)
}
case []any:
for i, sub := range t {
flattenInto(dst, fmt.Sprintf("%s[%d]", key, i), sub)
}
case nil:
default:
if key == "" || len(dst) >= maxAttrs {
return
}
if _, seen := dst[key]; seen {
return
}
s := attrString(t)
if s == "" {
return // nothing said is not a value
}
// 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 {
s = string(r[:maxAttrsLen]) + "…"
}
dst[key] = s
}
}
func joinAttrKey(prefix, key string) string {
if prefix == "" {
return key
}
return prefix + "." + key
}
// attrString words a scalar the way the wire did: a number keeps every digit it
// arrived with rather than gaining an exponent, a bool stays a bool.
func attrString(v any) string {
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
case bool:
return strconv.FormatBool(t)
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
case json.Number:
return t.String()
default:
return strings.TrimSpace(fmt.Sprint(t))
}
}
// chargerInventory merges chargers by serial, keeping the order they were first
@@ -103,6 +193,7 @@ func (inv *chargerInventory) addStandalone(body []byte) {
continue
}
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"))
@@ -150,6 +241,7 @@ func (inv *chargerInventory) addBound(body []byte) {
continue // some other Anker device on the same account
}
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"))
@@ -160,6 +252,59 @@ func (inv *chargerInventory) addBound(body []byte) {
}
}
// noteRecords folds a view's raw records into the chargers already found. The
// site views reach the inventory as normalized state (addStates), which is the
// right shape for the fields we name and drops the rest — so the raw list is
// walked once more here, purely for what those records also carried. A serial
// nothing has found yet is skipped: this pass enriches chargers, it does not
// discover them.
func (inv *chargerInventory) noteRecords(records []map[string]any, snKeys ...string) {
for _, m := range records {
sn := pickString(m, snKeys...)
if sn == "" {
continue
}
if c, ok := inv.byID[sn]; ok {
c.note(m)
}
}
}
// addStation folds the per-charger station record into one charger: everything
// it carries as attributes, and the fields we have names for where no earlier
// view answered. Anker does not document this record, so each field is looked up
// by candidate key the way the list views are.
func (inv *chargerInventory) addStation(sn string, body []byte) {
c, ok := inv.byID[sn]
if !ok {
return
}
m := dataObject(body)
if m == nil {
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"))
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.
type siteRef struct{ ID, Name string }
@@ -254,11 +399,13 @@ func (p *Plugin) chargerInventory(ctx context.Context) (chargersDoc, error) {
found := 0
if body, err := p.apiRequest(ctx, epSceneInfo, map[string]any{"site_id": st.ID}); err == nil {
found = inv.addStates(parseScenePiles(body, st.ID), st.Name)
inv.noteRecords(rawRecords(body, "charging_pile_info", "charging_pile_list"), "device_sn", "deviceSn", "sn")
}
if found == 0 {
// Not a power-service site, or it reported no pile: try the HES view.
if body, err := p.apiRequest(ctx, epSystemRunInfo, map[string]any{"siteId": st.ID}); err == nil {
inv.addStates(parseHesChargers(body, st.ID), st.Name)
inv.noteRecords(rawRecords(body, "evChargerInfos"), "evChargerSn", "device_sn", "sn")
}
}
}
@@ -271,6 +418,26 @@ func (p *Plugin) chargerInventory(ctx context.Context) (chargersDoc, error) {
inv.addBound(body)
}
// 4. The station record, one charger at a time. The three list views above
// answer for the account; this one answers for a charger, and for a charger
// registered on its own it is the only view that says what it is doing. Each
// call is its own request, so a charger the cloud will not talk about costs
// that charger's row and no more — a station view that fails everywhere is
// one warning, not one per charger.
views++
stationFailed, stationErr := 0, error(nil)
for _, sn := range inv.order {
body, err := p.apiRequest(ctx, epStationInfo, map[string]any{"evChargerSn": sn, "featuretype": 1})
if err != nil {
stationFailed, stationErr = stationFailed+1, err
continue
}
inv.addStation(sn, body)
}
if stationErr != nil && stationFailed == len(inv.order) {
fail("station info", stationErr)
}
if failed == views {
return chargersDoc{}, fmt.Errorf("anker-solix: chargers: every cloud view failed: %s", strings.Join(warnings, "; "))
}
@@ -326,6 +493,47 @@ func dataList(body []byte, keys ...string) []map[string]any {
return nil
}
// dataObject returns a response's "data" object, or nil when it carries
// something else — a list endpoint's data is an array, and the per-charger views
// have been known to answer with a bare value.
func dataObject(body []byte) map[string]any {
var env struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
return nil
}
return env.Data
}
// rawRecords returns the objects in the array at path inside a response's "data"
// object, descending through the objects named before the last element. It is
// dataList's answer for a list the cloud nests rather than puts at the top.
func rawRecords(body []byte, path ...string) []map[string]any {
node := any(dataObject(body))
for i, key := range path {
obj, ok := node.(map[string]any)
if !ok {
return nil
}
node = obj[key]
if i == len(path)-1 {
break
}
}
raw, ok := node.([]any)
if !ok {
return nil
}
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
if m, ok := item.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// pickString returns the first non-empty value among keys, as a string.
func pickString(m map[string]any, keys ...string) string {
for _, k := range keys {
@@ -155,3 +155,82 @@ func TestFillString(t *testing.T) {
t.Fatalf("s = %q, want first", s)
}
}
// Everything a view sent is kept, not only the fields the merge has a name for:
// nested objects and arrays arrive under the path the cloud nested them at, and
// a field that says nothing leaves no row behind.
func TestAttrsKeepEveryFieldTheCloudSent(t *testing.T) {
inv := newInventory()
inv.addStandalone([]byte(`{"data":{"evChargers":[{"evChargerSn":"EVSN1","evChargerName":"Garage",
"wifi_online":true,"rssi":-52,"grid":{"tariff":"day"},"tags":["a","b"],"blank":" ","absent":null}]}}`))
attrs := inv.list()[0].Attrs
want := map[string]string{
"evChargerSn": "EVSN1", "evChargerName": "Garage", "wifi_online": "true",
"rssi": "-52", "grid.tariff": "day", "tags[0]": "a", "tags[1]": "b",
}
for k, v := range want {
if attrs[k] != v {
t.Fatalf("attrs[%q] = %q, want %q", k, attrs[k], v)
}
}
for _, k := range []string{"blank", "absent"} {
if _, ok := attrs[k]; ok {
t.Fatalf("attrs[%q] = %q; a field that says nothing must not make a row", k, attrs[k])
}
}
// A second view fills gaps without overwriting what the first one answered.
inv.addBound([]byte(`{"data":{"data":[{"device_sn":"EVSN1","device_pn":"A5191","rssi":-70,"bt_mac":"AA:BB"}]}}`))
attrs = inv.list()[0].Attrs
if attrs["rssi"] != "-52" {
t.Fatalf("rssi = %q, want the first view's -52", attrs["rssi"])
}
if attrs["bt_mac"] != "AA:BB" {
t.Fatalf("bt_mac = %q, want AA:BB", attrs["bt_mac"])
}
}
// The station record is the only view that answers for a charger standing
// outside a station, so it fills the live fields the list views left empty —
// without claiming the charger is registered anywhere new.
func TestAddStationFillsGapsWithoutAddingASource(t *testing.T) {
inv := newInventory()
inv.addStandalone([]byte(`{"data":{"evChargers":[{"evChargerSn":"EVSN1","evChargerName":"Garage"}]}}`))
inv.addStation("EVSN1", []byte(`{"code":0,"data":{"evChargerSn":"EVSN1","evChargerStatus":2,"power":"7.4",
"ocpp_connect_status":2,"station_name":"Home-DK","device_sw_version":"v1.0.6.1","plug_temperature":41}}`))
c := inv.list()[0]
if c.StatusDesc != stateCharging || c.Power != "7.4" {
t.Fatalf("station view did not fill the live fields: %+v", c)
}
if c.OcppStatusDesc != "connected" || c.SiteName != "Home-DK" || c.Firmware != "v1.0.6.1" {
t.Fatalf("station view did not fill the named fields: %+v", c)
}
if c.Attrs["plug_temperature"] != "41" {
t.Fatalf("plug_temperature = %q, want 41", c.Attrs["plug_temperature"])
}
if len(c.Sources) != 1 || c.Sources[0] != "standalone" {
t.Fatalf("sources = %v; the station record is a view, not a registration", c.Sources)
}
// A serial no view found is not a charger this pass may invent.
inv.addStation("EVSN9", []byte(`{"code":0,"data":{"evChargerSn":"EVSN9"}}`))
if len(inv.list()) != 1 {
t.Fatalf("got %d chargers, want 1", len(inv.list()))
}
}
func TestRawRecordsDescendsToANestedList(t *testing.T) {
body := []byte(`{"data":{"charging_pile_info":{"charging_pile_list":[{"device_sn":"EVSN1","power":"7.4"}]}}}`)
got := rawRecords(body, "charging_pile_info", "charging_pile_list")
if len(got) != 1 || got[0]["power"] != "7.4" {
t.Fatalf("rawRecords = %v", got)
}
if rawRecords(body, "charging_pile_info") != nil {
t.Fatal("an object where a list was asked for should yield nothing")
}
if rawRecords([]byte(`{"data":[]}`), "missing") != nil {
t.Fatal("a data that is not an object should yield nothing")
}
}
@@ -478,9 +478,14 @@ func (c *mqttConn) ingest(msg mqtt.Message) {
st.values[k] = v
}
now := time.Now()
if msgType == msgEVTelemetry {
// Only a message this package can name counts as a report. An unnamed one
// still leaves its fields behind, but it must not stamp settingsAt: that
// timestamp is what a control command waits on to say the charger
// acknowledged, and a frame we cannot read is not an acknowledgement.
switch {
case msgType == msgEVTelemetry:
st.telemetryAt = now
} else {
case evMessages[msgType] != nil:
st.settingsAt = now
}
c.wakeLocked()
@@ -146,6 +146,24 @@ func TestIngestMergesTelemetryAndSettings(t *testing.T) {
}
}
// A message type we have no map for still carries its fields into the state,
// but it must not pass for a settings report: settingsAt is what a control
// command waits on to say the charger acknowledged it, and a frame we cannot
// read is not an acknowledgement.
func TestIngestOfAnUnmappedMessageKeepsFieldsButNotTheStamp(t *testing.T) {
c := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})}
c.ingest(envelope(t, "SN1", buildInbound(t, "0400", field(0xa2, typeUint8, 0x01))))
values, telemetryAt, settingsAt, _ := c.snapshotOf("SN1")
if v, _ := values["0400.a2"].(float64); v != 1 {
t.Errorf("values = %v, want the unnamed field kept as 0400.a2", values)
}
if !settingsAt.IsZero() || !telemetryAt.IsZero() {
t.Errorf("an unmapped message stamped the state: telemetry %v, settings %v", telemetryAt, settingsAt)
}
}
func TestWaitForReturnsWhenTheStateArrives(t *testing.T) {
c := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})}
go func() {
@@ -294,8 +294,12 @@ func xorChecksum(b []byte) byte {
// ---- inbound frames ----------------------------------------------------------
// decodeFrame parses one device frame and returns its message type together with
// the values its field map names. A field the map does not know is skipped
// rather than guessed at, and a frame whose checksum does not add up is
// the values it carries: the ones its field map names, under those names, and
// the ones no map names, under the message and name byte they arrived with (see
// rawFieldName). Nothing is guessed at — an unnamed field keeps its raw number
// and gains no unit, scaling or meaning — but nothing is thrown away either,
// because a field this package cannot name is still the only place some of what
// the charger knows ever appears. A frame whose checksum does not add up is
// rejected: these arrive over a cloud connection we do not control, so a
// truncated one must not be read as a charger reporting zeros.
func decodeFrame(data []byte) (string, map[string]any, error) {
@@ -331,6 +335,11 @@ func decodeFrame(data []byte) (string, map[string]any, error) {
for _, r := range raw {
f, known := fields[r.name]
if !known || f.name == "" {
// No name for it: keep the value under the message and name byte, with
// no scaling — a factor is part of a field's meaning, and we have none.
if v, ok := decodeValue(r.typ, r.value, mqttField{}); ok {
values[rawFieldName(msgType, r.name)] = v
}
continue
}
if v, ok := decodeValue(r.typ, r.value, f); ok {
@@ -340,6 +349,15 @@ func decodeFrame(data []byte) (string, map[string]any, error) {
return msgType, values, nil
}
// rawFieldName keys a field no map names, by the message it arrived in and the
// name byte the charger gave it — "0410.b6". The message type belongs in the key
// because a name byte means whatever its message says it means: the same b6 is a
// different quantity in the telemetry stream and in the settings group, and one
// key for both would merge two readings into one wrong number.
func rawFieldName(msgType string, name byte) string {
return msgType + "." + encodeHex([]byte{name})
}
// rawFieldBytes is one data field as it sat in the frame, before its map entry
// decides what it means.
type rawFieldBytes struct {
@@ -202,15 +202,32 @@ func TestDecodeFrameSkipsTheCounterByte(t *testing.T) {
}
}
// A message type we have no map for still has to parse, so an unknown frame is
// an empty answer rather than an error that hides the ones we can read.
func TestDecodeFrameOfAnUnmappedTypeIsEmpty(t *testing.T) {
// A message type we have no map for still has to parse, and what it carried is
// kept under the message and name byte it arrived with rather than dropped: an
// unnamed field is the only place some of what the charger knows appears.
func TestDecodeFrameOfAnUnmappedTypeKeepsRawFields(t *testing.T) {
_, values, err := decodeFrame(buildInbound(t, "0400", field(0xa2, typeUint8, 0x01)))
if err != nil {
t.Fatalf("decodeFrame: %v", err)
}
if len(values) != 0 {
t.Errorf("values = %v, want none for an unmapped message type", values)
if v, _ := values["0400.a2"].(float64); v != 1 {
t.Errorf("values = %v, want 0400.a2 = 1", values)
}
}
// A field a mapped message does not name is kept the same way, beside the ones
// it does — and unscaled, since a factor is part of a meaning we do not have.
func TestDecodeFrameKeepsUnnamedFieldsBesideNamedOnes(t *testing.T) {
body := append(field(0xbb, typeUint8, 0x05), field(0xc9, typeInt16LE, 0x2c, 0x01)...)
_, values, err := decodeFrame(buildInbound(t, msgEVTelemetry, body))
if err != nil {
t.Fatalf("decodeFrame: %v", err)
}
if v, _ := values["status"].(float64); v != 5 {
t.Errorf("status = %v, want 5", values["status"])
}
if v, _ := values[rawFieldName(msgEVTelemetry, 0xc9)].(float64); v != 300 {
t.Errorf("0410.c9 = %v, want 300 unscaled", values[rawFieldName(msgEVTelemetry, 0xc9)])
}
}
@@ -81,6 +81,14 @@ type MqttSnapshot struct {
// mode's setup screen otherwise has to be given by hand.
Local *MqttLocalAccess `json:"local,omitempty"`
// Extra is everything else the charger sent: the values its messages carry
// that the fields above have no home for, and the fields no message map can
// name at all, keyed by the message and name byte they arrived with. They
// have no unit and no scaling here — naming one would be claiming to know
// what it means — but they are what the charger actually said, so they are
// carried rather than dropped.
Extra map[string]any `json:"extra,omitempty"`
// TelemetryAt and SettingsAt are when each half of the snapshot last arrived;
// Live says the fast stream is currently flowing.
TelemetryAt string `json:"telemetryAt,omitempty"`
@@ -272,7 +280,13 @@ func (p *Plugin) mqttCommand(ctx context.Context, sn, command string, amps float
func projectMqttSnapshot(sn, model string, v map[string]any) MqttSnapshot {
snap := MqttSnapshot{Serial: sn, Model: model}
// Every read goes through one of the four below, and each one notes the key
// it took. What is left over at the end is what this projection has no field
// for — which is exactly what Extra is, and keeping the list that way means a
// field added above stops appearing there without anyone having to remember.
read := map[string]bool{}
num := func(key string) *float64 {
read[key] = true
f, ok := v[key].(float64)
if !ok {
return nil
@@ -280,6 +294,7 @@ func projectMqttSnapshot(sn, model string, v map[string]any) MqttSnapshot {
return &f
}
whole := func(key string) *int {
read[key] = true
f, ok := v[key].(float64)
if !ok {
return nil
@@ -288,6 +303,7 @@ func projectMqttSnapshot(sn, model string, v map[string]any) MqttSnapshot {
return &n
}
flag := func(key string) *bool {
read[key] = true
f, ok := v[key].(float64)
if !ok {
return nil
@@ -296,6 +312,7 @@ func projectMqttSnapshot(sn, model string, v map[string]any) MqttSnapshot {
return &b
}
text := func(key string) string {
read[key] = true
s, _ := v[key].(string)
return strings.TrimSpace(s)
}
@@ -374,6 +391,19 @@ func projectMqttSnapshot(sn, model string, v map[string]any) MqttSnapshot {
if *local != (MqttLocalAccess{}) {
snap.Local = local
}
// Whatever the projection did not take. The charger sent it, so it is part of
// the reading — under the charger's own name for it, since this package has
// none.
for key, val := range v {
if read[key] {
continue
}
if snap.Extra == nil {
snap.Extra = map[string]any{}
}
snap.Extra[key] = val
}
return snap
}
@@ -144,3 +144,40 @@ func TestProjectSnapshotOfNothingIsEmpty(t *testing.T) {
t.Errorf("an empty message set produced state: %+v", snap)
}
}
// A value the projection has a field for belongs in that field; everything else
// the charger sent belongs in extra — the named values this package has not
// modelled, and the fields no message map can name at all.
func TestProjectSnapshotKeepsWhatItHasNoFieldFor(t *testing.T) {
snap := projectMqttSnapshot("SN1", "A5191", map[string]any{
"powerTotal": 3680.0,
"maxCurrentSetA": 16.0,
"sessionStartedAt": 1756800000.0,
"sessionWhL1": 4100.0,
"swipeUpMode": 2.0,
"loadBalanceMonitorSN": "METER1",
rawFieldName(msgEVTelemetry, 0xc9): 300.0,
})
for _, key := range []string{"powerTotal", "maxCurrentSetA"} {
if _, ok := snap.Extra[key]; ok {
t.Errorf("extra[%q] is set; a value with a field of its own must not be repeated there", key)
}
}
want := map[string]any{
"sessionStartedAt": 1756800000.0, "sessionWhL1": 4100.0, "swipeUpMode": 2.0,
"loadBalanceMonitorSN": "METER1", "0410.c9": 300.0,
}
for key, value := range want {
if snap.Extra[key] != value {
t.Errorf("extra[%q] = %v, want %v", key, snap.Extra[key], value)
}
}
// A charger whose every value has a field of its own reports no extra at all,
// so a view can hide the block rather than draw an empty one.
bare := projectMqttSnapshot("SN1", "A5191", map[string]any{"powerTotal": 0.0})
if bare.Extra != nil {
t.Errorf("extra = %v, want none when nothing is left over", bare.Extra)
}
}