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

+5 -1
View File
@@ -210,6 +210,8 @@
"mqttLink": "MQTT",
"alarmWord": "Ord {n}",
"alarmsHint": "Laderen melder en alarm. Anker offentliggør ikke, hvad de enkelte bit betyder, så ordene vises, som de kommer.",
"extra": "Som laderen sender det",
"extraHint": "Værdier, laderen sender, som DriverVault ikke har et navn til — vist råt og uden omregning. En nøgle som 0410.c9 angiver beskeden og den feltbyte, den kom i; Anker offentliggør ikke deres betydning.",
"phaseMode0": "Automatisk",
"phaseMode1": "Enfaset",
"phaseMode3": "Trefaset",
@@ -251,7 +253,9 @@
"added": "Tilføjet",
"online": "Online",
"offline": "Offline",
"refresh": "Opdater"
"refresh": "Opdater",
"rawTitle": "Som tjenesten melder det",
"rawHint": "Alle øvrige felter, tjenesten sendte om denne lader, under Ankers egne navne. De er udokumenterede, så de vises, som de kommer, i stedet for at blive omdøbt."
},
"home": {
"count": {
+5 -1
View File
@@ -210,6 +210,8 @@
"mqttLink": "MQTT",
"alarmWord": "Word {n}",
"alarmsHint": "The charger reports an alarm. Anker does not publish what the individual bits mean, so the words are shown as they arrive.",
"extra": "As the charger sends it",
"extraHint": "Values the charger sends that DriverVault has no name for, shown raw and unscaled. A key like 0410.c9 names the message and the field byte it arrived in; Anker publishes no meaning for them.",
"phaseMode0": "Automatic",
"phaseMode1": "Single-phase",
"phaseMode3": "Three-phase",
@@ -251,7 +253,9 @@
"added": "Added",
"online": "Online",
"offline": "Offline",
"refresh": "Refresh"
"refresh": "Refresh",
"rawTitle": "As the service reports it",
"rawHint": "Every other field the service sent about this charger, under its own field names. They are undocumented, so they are shown as they arrive rather than renamed."
},
"home": {
"count": {
+5 -1
View File
@@ -212,6 +212,8 @@
"mqttLink": "MQTT",
"alarmWord": "Słowo {n}",
"alarmsHint": "Ładowarka zgłasza alarm. Anker nie publikuje znaczenia poszczególnych bitów, więc słowa pokazane są tak, jak przychodzą.",
"extra": "Tak, jak przysyła to ładowarka",
"extraHint": "Wartości, które ładowarka przysyła, a dla których DriverVault nie ma nazwy — pokazane surowo, bez przeliczeń. Klucz w rodzaju 0410.c9 wskazuje wiadomość i bajt pola, w którym przyszła; Anker nie publikuje ich znaczenia.",
"phaseMode0": "Automatycznie",
"phaseMode1": "Jednofazowo",
"phaseMode3": "Trójfazowo",
@@ -253,7 +255,9 @@
"added": "Dodano",
"online": "Online",
"offline": "Offline",
"refresh": "Odśwież"
"refresh": "Odśwież",
"rawTitle": "Tak, jak podaje to usługa",
"rawHint": "Wszystkie pozostałe pola, które usługa przysłała o tej ładowarce, pod jej własnymi nazwami. Nie są udokumentowane, więc pokazujemy je tak, jak przychodzą, bez zmiany nazw."
},
"home": {
"count": {
+30
View File
@@ -20,6 +20,19 @@ DateTime? _asDate(dynamic v) =>
List<String> _asStrList(dynamic v) =>
v is List ? v.map(_asStr).where((s) => s.isNotEmpty).toList() : const [];
/// A JSON object of plain values, read as strings — a service's own fields,
/// relayed under its own keys. A value that says nothing leaves no entry, so a
/// caller can take a key's presence to mean the service answered it.
Map<String, String> _asStrMap(dynamic v) {
if (v is! Map) return const {};
final out = <String, String>{};
v.forEach((key, value) {
final s = _asStr(value);
if (s.isNotEmpty) out[key.toString()] = s;
});
return out;
}
/// The single optional file a record carries — a receipt, a scan, a photo of a
/// part's box. Mirrors the API's embedded Attachment: the bytes are never in the
/// JSON, only whether there are any and what they were stored as. Fetch them
@@ -1190,6 +1203,15 @@ class ChargerStatus {
return v is Map ? Map<String, dynamic>.from(v) : const {};
}
/// Everything the charger sent that no field above has a name for: the values
/// a message carries that the integration has not modelled, and the fields no
/// published map names at all, keyed by the message and field byte they
/// arrived in. Raw and unscaled — a unit would be a meaning we do not have.
Map<String, dynamic> get extra {
final v = raw["extra"];
return v is Map ? Map<String, dynamic>.from(v) : const {};
}
/// The alarm words, as they arrive: the spec defers what the individual bits
/// mean to a list Anker does not publish, so which word is set is still the
/// thing to report.
@@ -1377,6 +1399,12 @@ class ProviderCharger {
final int? ocppStatus;
final String ocppStatusDesc;
/// Everything else the service said about this charger, under its own field
/// names. The fields above are the ones DriverVault has a name for; this is
/// the remainder, kept rather than dropped — the service documents none of it,
/// so its key is the only honest label these values have.
final Map<String, String> attrs;
/// Set when this charger is already in DriverVault, so the import never
/// offers the same one twice.
final String linkedChargerId;
@@ -1395,6 +1423,7 @@ class ProviderCharger {
this.power = "",
this.ocppStatus,
this.ocppStatusDesc = "",
this.attrs = const {},
this.linkedChargerId = "",
});
@@ -1412,6 +1441,7 @@ class ProviderCharger {
power: _asStr(j["power"]),
ocppStatus: _asIntOrNull(j["ocppStatus"]),
ocppStatusDesc: _asStr(j["ocppStatusDesc"]),
attrs: _asStrMap(j["attrs"]),
linkedChargerId: _asStr(j["linkedChargerId"]),
);
@@ -1878,6 +1878,7 @@ class _HomeTabState extends State<_HomeTab> {
final live = _liveRows(s);
final settings = _settingRows(s);
final device = _deviceRows(s);
final extra = _extraRows(s);
final alarms = _alarmWords(s);
return [
@@ -1936,6 +1937,24 @@ class _HomeTabState extends State<_HomeTab> {
_ReadingSection(heading: t("charging.modbus.device"), child: _PairList(rows: device)),
const SizedBox(height: 8),
],
// What the charger sends beyond what we model. It appears only when there
// is something in it, so a transport that reports nothing unnamed draws no
// empty block.
if (extra.isNotEmpty) ...[
_ReadingSection(
heading: t("charging.modbus.extra"),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_PairList(rows: extra, breakLong: true),
const SizedBox(height: 6),
Text(t("charging.modbus.extraHint"),
style: TextStyle(fontSize: 11, color: DriverVault.muted(context))),
],
),
),
const SizedBox(height: 8),
],
// Alarms, when any word is non-zero. Which register is set is reportable
// even though the bit list is not published.
if (alarms.isNotEmpty)
@@ -2095,6 +2114,23 @@ class _HomeTabState extends State<_HomeTab> {
]);
}
/// Everything the charger sends that none of the blocks above has a name for.
/// Over the cloud a message carries more fields than this integration models,
/// and some of them no published map names at all — those arrive keyed by the
/// message and the field byte they came in ("0410.c9"). Shown raw: no unit, no
/// scaling, no translation, because a factor and a label are part of a meaning
/// Anker does not publish. Named leftovers sort first, the byte-keyed ones
/// after, so the readable half is not buried under hex.
List<(String, String)> _extraRows(ChargerStatus s) {
final extra = s.extra;
final keys = extra.keys.toList()
..sort((a, b) {
final raw = (a.contains(".") ? 1 : 0) - (b.contains(".") ? 1 : 0);
return raw != 0 ? raw : a.compareTo(b);
});
return [for (final key in keys) (key, "${extra[key]}")];
}
/// Reactive and apparent power are registers of their own, and the cloud has
/// no message carrying either — so on that transport the two columns could
/// only ever be three dashes each. They appear when the charger actually
@@ -2198,6 +2234,21 @@ class _HomeTabState extends State<_HomeTab> {
]),
const SizedBox(height: 8),
_PairList(rows: _infoRows(charger), breakLong: true),
// The rest of what the service knows, in the service's own
// words. It appears only when there is something in it, so a
// charger the cloud says nothing more about stays quiet.
if (_attrRows(charger).isNotEmpty) ...[
const SizedBox(height: 10),
Text(
t("charging.info.rawTitle"),
style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted)
.copyWith(letterSpacing: 1.4),
),
const SizedBox(height: 4),
_PairList(rows: _attrRows(charger), breakLong: true),
const SizedBox(height: 4),
Text(t("charging.info.rawHint"), style: TextStyle(fontSize: 11, color: muted)),
],
],
),
),
@@ -2250,6 +2301,17 @@ class _HomeTabState extends State<_HomeTab> {
];
}
/// Everything else the service said about this charger, under its own field
/// names. The rows above are the ones DriverVault has a name for; these are
/// the remainder — the service documents none of them, so its own key is the
/// only honest label, and renaming one here would be inventing a meaning for
/// it. Sorted so the same charger reads the same way on every refresh.
List<(String, String)> _attrRows(HomeCharger c) {
final attrs = _liveFor(c)?.attrs ?? const <String, String>{};
final keys = attrs.keys.toList()..sort();
return [for (final key in keys) (key, attrs[key]!)];
}
/// The user's own chargers, and the import that fills the list.
Widget _chargerListCard(BuildContext context) {
final muted = DriverVault.muted(context);