Skip to content

Commit 8e18cf1

Browse files
committed
refactor(eu-data-act): apply Codex simplification review + README
Simplifications (net -60 LoC code, no functional change): - drop unused getLatestStatus + datasetCount/contentCount/byteSize fields in lib (main.js calls listDatasets+downloadDataset directly now) - inline enumMembers into normalizeDataset (single call site) - collapse parseValue int/float regex pair into one Number() with regex pre-check (Number rejects suffixed strings + leading whitespace) - tighten extractVins from defensive recursive walker to direct list-or-vehicles-array iteration (the proxy_api endpoint shape is known) - extract _ensureEuDataActIdentifier helper and replace duplicated getMetadata/cache logic in discoverEuDataActVehicles + getEuDataActStatus - drop the commented-out rawJson block (18 lines of dead code) README: add the 'VW ID via EU Data Act portal' section explaining the mandatory portal-side setup (continuous 15-min data request, Data Clusters selection), the resulting object tree under <vin>.statuseudata, and a troubleshooting subsection. Adds a 0.9.0 changelog entry. Image asset path admin/eu-data-act-data-clusters.png needs the screenshot to be committed separately (admin/ is included in the npm package, .docu/ is gitignored).
1 parent 57fed98 commit 8e18cf1

3 files changed

Lines changed: 128 additions & 129 deletions

File tree

README.md

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,65 @@
1313

1414
## vw-connect adapter for ioBroker
1515

16-
Adapter for VW We Connect, We Connect ID, We Charge, myAudi, Skoda Connect, Seat Connect and We Connect Go
16+
Adapter for VW We Connect, We Connect ID (now via the EU Data Act portal), We Charge, myAudi, Skoda Connect, Seat Connect and We Connect Go
1717

1818
Please update your system on Node 10.
1919
<https://forum.iobroker.net/topic/22867/how-to-node-js-f%C3%BCr-iobroker-richtig-updaten>
2020

21+
## VW ID via EU Data Act portal (since v0.9.0)
22+
23+
Volkswagen retired the WeConnect/MBB API for ID-series vehicles. The adapter now consumes the **continuous 15-minute datasets** that VW publishes via the EU Data Act portal at <https://eu-data-act.drivesomethinggreater.com>. Your data shows up under `<vin>.statuseudata.*` (snake_case dotted names like `battery_state_report.soc`, `mileage.value`, `parking_brake`, `charging_state_report.current_charge_state` and friends).
24+
25+
### Prerequisite — enable a continuous data request once
26+
27+
The adapter only **downloads** datasets the portal generates; it cannot create the data request for you. You have to do that **once in a browser** before adding the adapter:
28+
29+
1. Open <https://eu-data-act.drivesomethinggreater.com/> and **log in with your Volkswagen ID** (same email/password you use in the Volkswagen App).
30+
2. Go to **Data clusters → Vehicle overview**.
31+
3. Click **Connect your car** if your VIN isn't already listed and follow the on-screen pairing/consent steps.
32+
4. Click **Get customised data** and configure:
33+
- **Frequency:** 15 minutes (continuous)
34+
- **Data clusters:** select **All data** (the 7 clusters below — picking only some restricts what `<vin>.statuseudata.*` will contain)
35+
36+
![Data Cluster auswählen](admin/eu-data-act-data-clusters.png)
37+
38+
The clusters available on the portal:
39+
- **All data** — every EU Data Act relevant data point (recommended)
40+
- **Charging** — charging-state, charge power, target SoC, battery state report
41+
- **Driving behaviour** — driving-related data
42+
- **Maintenance related info** — service intervals, mileage
43+
- **Parking data** — parking brake, doors locked, parking position
44+
- **Vehicle warning lights** — status / warning indicators
45+
5. Wait for datasets to start appearing in the portal's data delivery list — typically **15 minutes to a few hours**. The first batch may show up as `*_no_content_found.zip` until your car wakes up. Force-syncing the car via the Volkswagen app or driving once kicks the producer side awake.
46+
47+
### Configure the adapter
48+
49+
In the adapter settings select **VW ID / Volkswagen App (EU Data Act portal)** as type, enter the same email/password you used on the portal, save. Polling defaults are sane — the adapter checks the listing every minute and only downloads when a new ZIP appears (so 14 of 15 cycles short-circuit on the filename cache).
50+
51+
Object tree per VIN:
52+
53+
```
54+
<vin>.general.vin
55+
<vin>.general.nickname
56+
<vin>.statuseudata.battery_state_report.soc (= 58 %)
57+
<vin>.statuseudata.battery_state_report.charge_power (= 0.0 kW)
58+
<vin>.statuseudata.charging_state_report.current_charge_state
59+
<vin>.statuseudata.mileage.value
60+
<vin>.statuseudata.parking_brake
61+
<vin>.statuseudata.locked
62+
<vin>.statuseudata._dataset_name
63+
<vin>.statuseudata._dataset_created_on
64+
... and many more (which exact fields depend on the Data Clusters you ticked on the portal)
65+
```
66+
67+
### Troubleshooting
68+
69+
- **No vehicles found** in the adapter logs: you skipped the portal-side setup. Open <https://eu-data-act.drivesomethinggreater.com/>, log in, and connect your car (steps above).
70+
- **HTTP 400 from the data delivery endpoint**: the portal hasn't finished provisioning your continuous data request yet — can take a couple of hours after activation. Adapter retries automatically.
71+
- **`<vin>.statuseudata` channel is missing**: the portal has no content datasets yet. Force-sync the car via the VW app, or just drive once.
72+
- **Stale values**: the portal merges several report snapshots into one flat array per dataset. Where the same field appears multiple times with different values, the adapter deterministically picks the entry with the smallest UUID (stable across refreshes — same approach as the home-assistant integration).
73+
- **Reference implementation** (Home Assistant, Python): <https://github.com/mikrohard/hass-vw-eu-data-act>
74+
2175
## Usage
2276

2377
Use the state under remote control to control your car remotely.
@@ -39,7 +93,12 @@ You can set climatisaton temperature in
3993
```
4094
4195
```
96+
### 0.9.0 (2026-05-30)
97+
98+
- VW ID flow migrated to the EU Data Act portal (`eu-data-act.drivesomethinggreater.com`). Status data is now under `<vin>.statuseudata.*`. Requires a continuous 15-min data request set up once on the portal — see "VW ID via EU Data Act portal" above.
99+
42100
### 0.8.8 (2026-05-28)
101+
43102
- fix audi and vw login
44103

45104
### 0.8.7 (2026-05-27)

lib/euDataAct.js

Lines changed: 24 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,7 @@ function loginErrorText(html) {
143143
// --- value parsing (mirrors data.py.parse_value) ---------------------------
144144

145145
const _DURATION_RE = /^(-?\d+(?:\.\d+)?)\s*s$/i;
146-
const _INT_RE = /^-?\d+$/;
147-
const _FLOAT_RE = /^-?\d+\.\d+$/;
146+
const _NUMBER_RE = /^-?\d+(?:\.\d+)?$/;
148147
const _ENUM_TOKEN_RE = /^[A-Z][A-Z0-9_]*$/;
149148

150149
function parseValue(raw, typeHint) {
@@ -156,27 +155,15 @@ function parseValue(raw, typeHint) {
156155
if (hint === "boolean" || s.toLowerCase() === "true" || s.toLowerCase() === "false") {
157156
return s.toLowerCase() === "true";
158157
}
159-
if ((hint === "int" || hint === "integer") && _INT_RE.test(s)) return parseInt(s, 10);
160-
if (hint === "float") {
161-
const f = parseFloat(s);
162-
return Number.isFinite(f) ? f : s;
163-
}
158+
// duration shorthand ("0s", "1800s") - strip suffix and treat as seconds.
164159
const dur = _DURATION_RE.exec(s);
165160
if (dur) return parseFloat(dur[1]);
166-
if (_INT_RE.test(s)) return parseInt(s, 10);
167-
if (_FLOAT_RE.test(s)) return parseFloat(s);
161+
// Plain integers / floats. Number() rejects "12abc" / leading/trailing
162+
// whitespace / hex unlike parseFloat, so the regex pre-check is needed.
163+
if (_NUMBER_RE.test(s)) return Number(s);
168164
return s;
169165
}
170166

171-
function enumMembers(description) {
172-
if (!description) return [];
173-
const members = description
174-
.split(",")
175-
.map((p) => p.replace(/\s+/g, ""))
176-
.filter((m) => _ENUM_TOKEN_RE.test(m));
177-
return members.length >= 2 ? members : [];
178-
}
179-
180167
// --- ZIP helper (one-file central-directory-less inflate) ------------------
181168

182169
function unzipFirstJson(buf, name) {
@@ -222,24 +209,16 @@ function unzipFirstJson(buf, name) {
222209
// --- VIN extractor (proxy_api/consent/me/vehicles wraps loosely) -----------
223210

224211
function extractVins(payload) {
225-
const vins = {};
226-
function walk(node) {
227-
if (Array.isArray(node)) {
228-
node.forEach(walk);
229-
return;
230-
}
231-
if (node && typeof node === "object") {
232-
const vin = node.vin || node.vehicleIdentificationNumber;
233-
if (typeof vin === "string" && vin.length === 17) {
234-
if (!vins[vin]) vins[vin] = { vin };
235-
const nick = node.vehicleNickname || node.nickname || node.modelName;
236-
if (nick) vins[vin].nickname = nick;
237-
}
238-
for (const v of Object.values(node)) walk(v);
239-
}
212+
const list = Array.isArray(payload) ? payload : payload && payload.vehicles;
213+
if (!Array.isArray(list)) return [];
214+
const seen = {};
215+
for (const v of list) {
216+
const vin = v && (v.vin || v.vehicleIdentificationNumber);
217+
if (typeof vin !== "string" || vin.length !== 17 || seen[vin]) continue;
218+
const nick = v.vehicleNickname || v.nickname || v.modelName;
219+
seen[vin] = nick ? { vin, nickname: nick } : { vin };
240220
}
241-
walk(payload);
242-
return Object.values(vins);
221+
return Object.values(seen);
243222
}
244223

245224
// --- raw data points -> structured object (for json2iob) -------------------
@@ -276,9 +255,16 @@ function normalizeDataset(payload) {
276255
for (const fieldName of Object.keys(points)) {
277256
const { item, meta } = points[fieldName];
278257
let value = parseValue(item.value, meta.type);
279-
if ((meta.type || "").toLowerCase() === "enum" && typeof value === "number") {
280-
const members = enumMembers(meta.description);
281-
if (value >= 0 && value < members.length) value = members[value];
258+
if ((meta.type || "").toLowerCase() === "enum" && typeof value === "number" && meta.description) {
259+
// Enum fields occasionally deliver the protobuf integer index instead
260+
// of the label; resolve via the comma-separated UPPER_SNAKE member list
261+
// documented in the data dictionary (PDF extraction inserts stray
262+
// spaces inside the tokens, hence the whitespace strip).
263+
const members = meta.description
264+
.split(",")
265+
.map((p) => p.replace(/\s+/g, ""))
266+
.filter((m) => _ENUM_TOKEN_RE.test(m));
267+
if (members.length >= 2 && value >= 0 && value < members.length) value = members[value];
282268
}
283269
setNested(out, fieldName, value);
284270
}
@@ -536,43 +522,12 @@ class EuDataActClient {
536522
const unzipped = unzipFirstJson(r.body, name);
537523
return { ...unzipped, byteSize: r.body.length };
538524
}
539-
540-
/**
541-
* Convenience: download newest content dataset and return both the raw
542-
* payload and the structured object suitable for json2iob.
543-
*/
544-
async getLatestStatus(vin, identifier) {
545-
const datasets = await this.listDatasets(vin, identifier);
546-
const contentDatasets = datasets.filter((d) => d && d.name && !d.name.endsWith(NO_CONTENT_SUFFIX));
547-
const newest = contentDatasets
548-
.sort((a, b) => String(b.createdOn || b.name).localeCompare(String(a.createdOn || a.name)))[0];
549-
this.log.debug(
550-
`[euDataAct] ${vin} list: ${datasets.length} datasets total, ${contentDatasets.length} with content`,
551-
);
552-
if (!newest) {
553-
const error = new Error("No content datasets available yet");
554-
error.code = "NO_CONTENT";
555-
throw error;
556-
}
557-
const { fileName, json, byteSize } = await this.downloadDataset(vin, identifier, newest.name);
558-
return {
559-
datasetName: newest.name,
560-
datasetCreatedOn: newest.createdOn || null,
561-
datasetCount: datasets.length,
562-
contentCount: contentDatasets.length,
563-
byteSize,
564-
fileName,
565-
raw: json,
566-
normalized: normalizeDataset(json),
567-
};
568-
}
569525
}
570526

571527
module.exports = {
572528
EuDataActClient,
573529
normalizeDataset,
574530
parseValue,
575-
enumMembers,
576531
loadDictionary,
577532
// exported for tests
578533
_internal: { extractVins, loginFields, loginErrorText, unzipFirstJson },

main.js

Lines changed: 44 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -6776,62 +6776,65 @@ class VwWeconnect extends utils.Adapter {
67766776
forceIndex: true,
67776777
channelName: "General Information",
67786778
});
6779-
try {
6780-
const meta = await this.euDataAct.getMetadata(vin);
6781-
if (meta && meta.Identifier) {
6782-
this.euDataActIdentifiers[vin] = meta.Identifier;
6783-
this.log.debug(
6784-
`EU Data Act: ${vin} data request - ` +
6785-
`Identifier=${meta.Identifier} ` +
6786-
`Name=${meta.Name || "?"} ` +
6787-
`Frequency=${meta.Frequency || "?"} ` +
6788-
`StartDate=${meta.StartDate || "?"} ` +
6789-
`EndDate=${meta.EndDate || "?"} ` +
6790-
`EmailFrequency=${meta.EmailFrequency || "?"} ` +
6791-
`LastNotificationDate=${meta.LastNotificationDate || "?"} ` +
6792-
`DataClusters=[${(meta.DataClusters || []).join(", ")}]`,
6793-
);
6794-
} else {
6795-
this.log.warn(
6796-
`EU Data Act: ${vin} has no Identifier - enable a continuous data request on the portal first`,
6797-
);
6798-
this.log.debug(`EU Data Act: ${vin} metadata raw: ${JSON.stringify(meta || {})}`);
6799-
}
6800-
} catch (err) {
6801-
this.log.warn(`EU Data Act: metadata fetch failed for ${vin}: ${err.message || err}`);
6779+
const identifier = await this._ensureEuDataActIdentifier(vin);
6780+
if (!identifier) {
6781+
this.log.warn(
6782+
`EU Data Act: ${vin} has no Identifier - enable a continuous data request on the portal first`,
6783+
);
68026784
}
68036785
}
68046786
}
68056787

6788+
/**
6789+
* Lookup (and lazily fetch) the per-VIN data-request Identifier from the
6790+
* portal. Cached on `this.euDataActIdentifiers` and dropped on rotation
6791+
* detection in getEuDataActStatus, so a missing identifier means we need
6792+
* to refetch /metadata. Returns null on failure or when the user has not
6793+
* yet activated a continuous data request.
6794+
*/
6795+
async _ensureEuDataActIdentifier(vin) {
6796+
if (this.euDataActIdentifiers[vin]) return this.euDataActIdentifiers[vin];
6797+
let meta;
6798+
try {
6799+
meta = await this.euDataAct.getMetadata(vin);
6800+
} catch (err) {
6801+
this.log.debug(`EU Data Act: ${vin} metadata fetch failed: ${err.message || err}`);
6802+
return null;
6803+
}
6804+
if (meta && meta.Identifier) {
6805+
this.euDataActIdentifiers[vin] = meta.Identifier;
6806+
this.log.debug(
6807+
`EU Data Act: ${vin} data request - ` +
6808+
`Identifier=${meta.Identifier} ` +
6809+
`Name=${meta.Name || "?"} ` +
6810+
`Frequency=${meta.Frequency || "?"} ` +
6811+
`StartDate=${meta.StartDate || "?"} ` +
6812+
`EndDate=${meta.EndDate || "?"} ` +
6813+
`EmailFrequency=${meta.EmailFrequency || "?"} ` +
6814+
`LastNotificationDate=${meta.LastNotificationDate || "?"} ` +
6815+
`DataClusters=[${(meta.DataClusters || []).join(", ")}]`,
6816+
);
6817+
return meta.Identifier;
6818+
}
6819+
this.log.debug(`EU Data Act: ${vin} metadata raw: ${JSON.stringify(meta || {})}`);
6820+
return null;
6821+
}
6822+
68066823
/**
68076824
* Pull the newest content dataset for one VIN, parse it and write the
68086825
* normalized values into `<vin>.statuseudata.*`. The raw payload is also
68096826
* stored in `<vin>.statuseudata.rawJson` when `config.rawJson` is enabled.
68106827
*/
68116828
async getEuDataActStatus(vin) {
68126829
if (!this.euDataAct) return;
6813-
const identifier = this.euDataActIdentifiers && this.euDataActIdentifiers[vin];
6814-
if (!identifier) {
6815-
this.log.debug(`EU Data Act: no Identifier for ${vin}, retrying metadata`);
6816-
try {
6817-
const meta = await this.euDataAct.getMetadata(vin);
6818-
if (meta && meta.Identifier) {
6819-
this.euDataActIdentifiers = this.euDataActIdentifiers || {};
6820-
this.euDataActIdentifiers[vin] = meta.Identifier;
6821-
} else {
6822-
return;
6823-
}
6824-
} catch (err) {
6825-
this.log.debug(`EU Data Act: metadata retry for ${vin} failed: ${err.message || err}`);
6826-
return;
6827-
}
6828-
}
6830+
const identifier = await this._ensureEuDataActIdentifier(vin);
6831+
if (!identifier) return;
68296832
this.euDataActLastDataset = this.euDataActLastDataset || {};
68306833
try {
68316834
// Cheap step: just list. Listing is small (~few KB JSON) and the
68326835
// portal happily serves it every minute.
68336836
const listStart = Date.now();
6834-
const datasets = await this.euDataAct.listDatasets(vin, this.euDataActIdentifiers[vin]);
6837+
const datasets = await this.euDataAct.listDatasets(vin, identifier);
68356838
const contentDatasets = datasets.filter((d) => d && d.name && !d.name.endsWith("_no_content_found.zip"));
68366839
const newest = contentDatasets.sort(
68376840
(a, b) => String(b.createdOn || b.name).localeCompare(String(a.createdOn || a.name)),
@@ -6852,7 +6855,7 @@ class VwWeconnect extends utils.Adapter {
68526855
return;
68536856
}
68546857
const downloadStart = Date.now();
6855-
const dl = await this.euDataAct.downloadDataset(vin, this.euDataActIdentifiers[vin], newest.name);
6858+
const dl = await this.euDataAct.downloadDataset(vin, identifier, newest.name);
68566859
const normalized = normalizeEuDataActDataset(dl.json);
68576860
const dataPoints = (dl.json.Data || []).length;
68586861
const normalizedKeys = Object.keys(normalized).length;
@@ -6879,24 +6882,6 @@ class VwWeconnect extends utils.Adapter {
68796882
forceIndex: true,
68806883
channelName: "EU Data Act 15-min dataset",
68816884
});
6882-
// The optional rawJson dump is intentionally disabled for the EU Data
6883-
// Act flow: the raw payload is several hundred KB and rewriting it
6884-
// every 15 min hammers any history adapter (InfluxDB, SQL) for little
6885-
// gain. Re-enable here if needed for one-off debugging.
6886-
// if (this.config.rawJson) {
6887-
// await this.extendObjectAsync(vin + ".statuseudata.rawJson", {
6888-
// type: "state",
6889-
// common: {
6890-
// name: "Raw EU Data Act dataset JSON",
6891-
// role: "json",
6892-
// type: "string",
6893-
// read: true,
6894-
// write: false,
6895-
// },
6896-
// native: {},
6897-
// });
6898-
// await this.setStateAsync(vin + ".statuseudata.rawJson", JSON.stringify(dl.json), true);
6899-
// }
69006885
this.euDataActLastDataset[vin] = newest.name;
69016886
} catch (err) {
69026887
// The lib already retries once on 401/403 internally; if it still fails

0 commit comments

Comments
 (0)