Skip to content

Commit 3a4ca23

Browse files
committed
feat(eu-data-act): adopt 4 fixes from HA_VAG fork (sentinel filter, aliases, sum-fallback)
Cherry-picked from mikrohard/HA_VAG-EU-Data-Act (the active Cupra-specific fork with substantially more curation than the original mikrohard repo). #1 Sentinel value filtering (lib/euDataAct.js isSentinelReading) Portal emits 65535 / 2147483647 / 4294967295 when a sensor has no current reading, plus field-specific sentinels: -1 for remaining_charging_time, 0/1 for tyre_pressure_*. Without this an odometer can read 4 billion km and tyre pressure 0 bar. Filtered values are skipped entirely — better an absent state than garbage. #4 Usability filter in the duplicate tie-break When the same dataFieldName arrives multiple times in one dataset, a usable reading now always beats a sentinel regardless of monotonic or last-occurrence semantics. Builds on #1. #5 sum_fallback for cruising_range_combined PHEVs (Passat GTE, Golf GTE, Cupra Terramar) often leave the combined range empty while reporting cruising_range_primary_engine and cruising_range_secondary_engine individually. Fill the combined total from the two components, but only when the portal omits it. #6 Field-name aliases remaining_climatisation_time -> remaining_climate_time charging_plug1_connectionstate -> plug_state Cupra Terramar PHEV uses non-standard names for these; aliasing keeps the state path consistent with ID.x cars so vis scripts work cross- model. Verified with 9 test cases including the real VW_connect.json dataset: mileage still resolves to 82 (the prior monotonic fix), sentinels are filtered, aliases land at the canonical path, sum_fallback only fires when the primary field is missing. Explicitly NOT adopted from the fork: - unit conversions (deci-kWh, hectometers) — risk of double-halving when a vehicle reports in normal units and our heuristic gets it wrong. - value/state field renames — would break user scripts referencing existing state paths without migration. - local dataset cache — ioBroker's history recorder already retains the last value across restarts. - freshness-aware merge / escalating backoff — pure polish, no end-user data-correctness impact.
1 parent 3c9af4d commit 3a4ca23

1 file changed

Lines changed: 91 additions & 3 deletions

File tree

lib/euDataAct.js

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -324,12 +324,59 @@ const MONOTONIC_FIELDS = new Set([
324324
"mileage",
325325
]);
326326

327+
// Field-name aliases — some car models (Cupra Terramar PHEV, older Passat
328+
// GTE) ship semantically-equal data under different dataFieldNames. Map
329+
// them to the canonical name so a state path stays the same across models.
330+
// Ported from HA_VAG fork's _FIELD_ALIASES.
331+
const FIELD_ALIASES = {
332+
remaining_climatisation_time: "remaining_climate_time",
333+
charging_plug1_connectionstate: "plug_state",
334+
};
335+
336+
// Sentinel integer values the portal emits when a sensor has no current
337+
// reading. Without filtering these reach the user-facing state as
338+
// "Odometer: 4,294,967,295 km" or "remaining charging time: 65,535 min".
339+
// Ported from HA_VAG fork's is_usable_reading (data.py:730-793).
340+
const GENERIC_INT_SENTINELS = new Set([65535, 2147483647, 4294967295]);
341+
// Field-specific sentinels: -1 from remaining_charging_time means
342+
// "unknown"; tyre_pressure_* uses 0/1 as protocol status codes instead of
343+
// real bar values (0 = invalid, 1 = warning, real pressures are >1.5).
344+
function isSentinelReading(value, fieldName) {
345+
if (value === null || value === undefined || value === "") return true;
346+
const num = typeof value === "number" ? value : parseFloat(value);
347+
if (!Number.isFinite(num)) return false;
348+
if (GENERIC_INT_SENTINELS.has(num)) return true;
349+
if (
350+
(fieldName === "remaining_charging_time" ||
351+
fieldName === "remaining_charging_time_to_complete") &&
352+
num === -1
353+
) {
354+
return true;
355+
}
356+
if (typeof fieldName === "string" && fieldName.includes("tyre_pressure") && (num === 0 || num === 1)) {
357+
return true;
358+
}
359+
return false;
360+
}
361+
362+
// Field pairs to reconstruct when the primary is missing: a PHEV's
363+
// combined cruising range often arrives empty while both per-engine
364+
// components are populated. Sum them as a fallback. Portal value wins
365+
// when present. Ported from HA_VAG sum_fallback_fields.
366+
const SUM_FALLBACKS = [
367+
{
368+
target: "cruising_range_combined",
369+
components: ["cruising_range_primary_engine", "cruising_range_secondary_engine"],
370+
},
371+
];
372+
327373
/**
328374
* Convert the flat `Data: [{key, dataFieldName, value}]` array into a nested
329375
* object keyed by the dotted dataFieldName, with values typed via the
330376
* dictionary.
331377
*
332378
* Tie-break across duplicates of the same dataFieldName:
379+
* - sentinel readings (65535, etc.) are skipped if any usable value exists.
333380
* - monotonic fields (e.g. mileage.value): pick the LARGEST numeric value.
334381
* The portal mixes several report snapshots into one array; older
335382
* snapshots lag the current odometer by a few km, so the max is the
@@ -347,15 +394,28 @@ function normalizeDataset(payload) {
347394
for (const item of payload.Data || []) {
348395
if (!item || !item.key) continue;
349396
const meta = dictionary[item.key] || {};
350-
const fieldName = item.dataFieldName || meta.name || item.key;
397+
let fieldName = item.dataFieldName || meta.name || item.key;
351398
if (!fieldName) continue;
399+
if (FIELD_ALIASES[fieldName]) fieldName = FIELD_ALIASES[fieldName];
352400
const sequence = seq++;
401+
const itemIsSentinel = isSentinelReading(item.value, fieldName);
353402
if (!points[fieldName]) {
354-
points[fieldName] = { keys: [item.key], item, meta, sequence };
403+
points[fieldName] = { keys: [item.key], item, meta, sequence, sentinel: itemIsSentinel };
355404
continue;
356405
}
357406
const cur = points[fieldName];
358407
cur.keys.push(item.key);
408+
// Usability filter: a usable reading always beats a sentinel, regardless
409+
// of monotonic/last-occurrence semantics. A sentinel never overwrites a
410+
// good reading. Two-sentinel or two-good fall through to normal tie-break.
411+
if (cur.sentinel && !itemIsSentinel) {
412+
cur.item = item;
413+
cur.meta = meta;
414+
cur.sequence = sequence;
415+
cur.sentinel = false;
416+
continue;
417+
}
418+
if (!cur.sentinel && itemIsSentinel) continue;
359419
if (MONOTONIC_FIELDS.has(fieldName)) {
360420
// Largest numeric value wins; sequence is only a tie-breaker for
361421
// equal values.
@@ -371,18 +431,25 @@ function normalizeDataset(payload) {
371431
cur.item = item;
372432
cur.meta = meta;
373433
cur.sequence = sequence;
434+
cur.sentinel = itemIsSentinel;
374435
}
375436
} else if (sequence > cur.sequence) {
376437
// Last-occurrence wins.
377438
cur.item = item;
378439
cur.meta = meta;
379440
cur.sequence = sequence;
441+
cur.sentinel = itemIsSentinel;
380442
}
381443
}
382444

383445
const out = {};
384446
for (const fieldName of Object.keys(points)) {
385-
const { item, meta } = points[fieldName];
447+
const { item, meta, sentinel } = points[fieldName];
448+
if (sentinel) {
449+
// Skip writing — the only occurrences in the dataset were sentinels.
450+
// Better an absent state than a 4-billion km odometer.
451+
continue;
452+
}
386453
let value = parseValue(item.value, meta.type);
387454
if ((meta.type || "").toLowerCase() === "enum" && typeof value === "number" && meta.description) {
388455
// Enum fields occasionally deliver the protobuf integer index instead
@@ -397,6 +464,27 @@ function normalizeDataset(payload) {
397464
}
398465
setNested(out, fieldName, value);
399466
}
467+
468+
// sum_fallback: PHEV combined range frequently arrives empty while both
469+
// per-engine components are present. Reconstruct so users don't see
470+
// "unknown" for total range. Portal value wins when present (we only
471+
// fill if the target is still missing after the normal pass).
472+
for (const fallback of SUM_FALLBACKS) {
473+
if (points[fallback.target]) continue;
474+
let sum = 0;
475+
let any = false;
476+
for (const c of fallback.components) {
477+
const p = points[c];
478+
if (!p || p.sentinel) continue;
479+
const n = parseFloat(p.item.value);
480+
if (Number.isFinite(n)) {
481+
sum += n;
482+
any = true;
483+
}
484+
}
485+
if (any) setNested(out, fallback.target, sum);
486+
}
487+
400488
out._meta = {
401489
vin: payload.vin || null,
402490
user_id: payload.user_id || null,

0 commit comments

Comments
 (0)