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:
tajniak81andClaude Opus 5 committed 2026-09-02 20:58:15 +02:00
1 parent 1dc20461de
commit 2425a8d3d6
19 files changed
+609 -18

No files matched your search

@@ -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 {