# Reading your car from the VW Group EU Data Act portal How to get the four settings the **Car** page needs, and what to do when it does not work. > **Two things, in order.** Steps 1–6 below get the **Car** page showing your car when you > press its button — which is how you prove the credentials and that this car's fields are understood. > [Step 7](#step-7--switch-the-feed-on) then switches on the *feed*: the same portal read on the > controller's own clock, feeding the dashboard's vehicle card and a Home Assistant entity. The feed is > off until you say otherwise, and having credentials is not saying otherwise. The design and the > reasoning are in [#137](https://github.com/mpospisil/gleanvolt/issues/137) and > [#140](https://github.com/mpospisil/gleanvolt/issues/140). ## Before you start - A Volkswagen Group car — VW, Audi, Škoda, SEAT, Cupra or Bentley — with connected services active. - The brand account you already use for the app (Volkswagen ID, myAudi, MyŠkoda, …), with the car linked to it. - **Access is free.** The EU Data Act obliges the manufacturer to give the *owner* their vehicle's data at no charge. There is no developer programme to join, no API key to buy and no separate API user to create — which is exactly why the owner's own login ends up being the credential. ## The four parameters | Variable | What it is | Where it comes from | |---|---|---| | `VW_USERNAME` | Your brand ID, an email address | You already have it — it is your app login | | `VW_PASSWORD` | That account's password | You already have it. Nothing issues it | | `VW_BRAND` | Which brand you drive | You know it. `vw`, `audi`, `skoda`, `seat`, `cupra`, `bentley` | | `VW_VIN` | Which car, when the account sees several | Only needed with more than one car — step 4 | **None of them requires looking anything up.** The first two are the credentials you already own; the third is the badge on the car. **You can fill them in from a browser instead.** [`/car`](../README.md#car--the-car-and-editing-it) in the web UI asks the same four things: pick your brand from the list, fill in the VW ID, the password and the VIN, and save. The brand list is the client's own, the password goes to the secret store rather than to a file beside the configuration, and the choice disables whichever other feed it replaces in the same save. Steps 1, 2 and 4 below are browser work in the portal itself and are the same either way; steps 3, 5 and 7 are what the page does for you. ## Step 1 — Sign in, consent, and link the car Open and sign in with your brand ID. On first use the portal shows a **consent screen** — the legal grant that lets it release your vehicle's data. Accept it, then link the vehicle. **If the car is not in the portal's list, connect it here first.** Being linked in the brand app is not the same thing: this portal keeps its own list, and a car missing from it cannot be consented to, cannot be given a data request in [step 2](#step-2--enable-a-continuous-data-request), and will not appear in the VIN list in [step 4](#step-4--your-vin-if-you-need-it). Add it on the portal's own vehicle page, by VIN, before going any further. Until it shows up there the client reports `VehicleNotFound`, which is the truth and not a fault to debug. > **This step can never be automated.** A consent screen is a legal act, and the client treats one > appearing mid-flow as `OwnerActionRequired` and refuses to retry, because retrying a consent screen > has never once helped anybody. The same applies to terms updates, an email OTP or a CAPTCHA. ## Step 2 — Enable a continuous data request **This is the step people miss, and skipping it looks like a broken client rather than a missing setting.** Signing in is not enough: the portal delivers nothing at all until you have asked it to. In the portal: **Data clusters → Vehicle overview → Get customised data**. Choose **All data** and a **15-minute** frequency. Two things to expect: - **It is not instant.** A newly created request can take a while — reports range from hours to a couple of days — before the first dataset appears. Until then the page reports `NoDataAvailable` and the feed stays `Degraded`, which is correct and not a bug. - **Fifteen minutes is the floor.** The portal is a batch delivery, not a live API. Polling more often than the request's own frequency achieves nothing whatsoever. - **A delivery is not a snapshot of the whole car.** These are *partial* deliveries: each carries the reports that changed, so one may hold the doors, the climate and the settings and no battery at all. The controller therefore merges the newest few — see [step 7](#step-7--switch-the-feed-on) — and nothing you configure here changes that. ## Step 3 — Say which brand you drive ```bash VW_BRAND=vw ``` One of `vw`, `audi`, `skoda`, `seat`, `cupra`, `bentley`. Case and surrounding spaces do not matter. That is the whole step.
What that actually sets, and what to do if your brand is missing The portal signs in with an OIDC **client id** that differs per brand. It belongs to the portal rather than to us — there is no "register your own OAuth app" route on this interface — so it has to be looked up rather than owned. [`VwGroupBrands`](../src/Gleanvolt.Infrastructure/Vehicles/VwGroup/VwGroupBrands.cs) holds the table and `VW_BRAND` picks the row, which is what every Home Assistant integration for this portal does too. Two rows are shared, which is a fact about the portal rather than a shortcut: VW passenger cars and commercial vehicles are one client, and SEAT and Cupra are another. **The ids are reverse-engineered, not published**, so one can go stale without warning. When that happens — or for a brand the table does not list — state it outright and it overrides the table: ```bash VW_CLIENT_ID= ``` To read your own: start signing in at the portal and, while you are on `identity.vwgroup.io`, take `client_id=` from the address bar. If the redirect is too fast to catch, open developer tools (F12) → **Network**, tick *Preserve log*, sign in again, and find the `/oidc/v1/authorize` request. A misspelt brand does not fail as a missing one — the page names it and lists what it knows: ``` Missing a known brand -- 'vollkswagen' is not one of vw, audi, skoda, seat, cupra, bentley; set an explicit client id if yours is missing. ```
## Step 4 — Your VIN, if you need it Only when the account can see more than one vehicle. With a single car, leave `VW_VIN` unset and it is taken automatically. You do not have to hunt for it: press the button without it and, if there is a choice to be made, it refuses and lists what it found rather than picking for you — ``` VehicleNotFound: the account can see 2 vehicles (WVW…1234, TMB…5678) and none was configured ``` Those are masked for the log. Take the full VIN from the portal's own vehicle list, or from the windscreen. ## Step 5 — Put them in `.env`, or on `/car` **On `/car`**, the four fields and the switch in step 7 are one save, and a restart applies them. Choose **Audi · SEAT · Cupra · Bentley · VW commercial** — or **Volkswagen** if you also want volkswagen.de's live feed, which reads a VW better. Then carry on at step 6. Otherwise, in the repository root — `.env` is gitignored, and the worker loads the nearest one at or above the working directory, so it is found from the project folder too: ```bash VW_BRAND=vw VW_USERNAME=you@example.com VW_PASSWORD= VW_VIN= ``` `.env.example` carries the same list with commentary. The controller reads `.env` once at startup, so **changing any of these means restarting it**. `Vehicle__DataAct__Brand` and friends work too, and are the better form for a deployment. The plain `VW_*` names are honoured because they are shorter and because one `.env` should not need the same credentials written twice; where both are set, the sectioned form wins. ## Step 6 — Press the button Restart the controller, open the web UI and go to **Car** in the navigation. Under **Ask the car**, press **Ask the car**. (That section was its own **Vehicle portal** page until [#227](https://github.com/mpospisil/gleanvolt/issues/227); the old address still works and redirects here.) The page shows what the car said — battery, range, charge state, plug state, and the *car's* capture time rather than this moment's — then the delivery it came in, and then the fields nothing here recognises yet. Each press signs in afresh. That is deliberate for a button: it is fine by hand, and repeatedly replaying a password at a real identity provider is how accounts get locked — which is also why nothing on this page loops on failure, and why the feed in step 7 holds a session instead. **The reading from this page is not fed to anything.** It proves the credentials and the mapping, and nothing more: the dashboard's car comes from whatever feed is configured, and no charging decision sees any of it. ## Step 7 — Switch the feed on Once the button works, this is what makes it a feed: ```bash VW_ENABLED=true ``` (`Vehicle__DataAct__Enabled=true` is the sectioned form, and wins where both are set.) On `/car` this is not a separate step: picking the brand *is* switching the feed on. Restart the controller. From then on it reads the portal **every fifteen minutes** — the portal's own delivery frequency, and not a setting: asking faster achieves nothing at all — and writes what it finds into the dashboard's vehicle card. It **holds one session** rather than signing in on every read, and signs in again only when the portal bounces it. It also times each session and logs how long it lasted, which is a question nobody could answer before: nothing had ever kept one. Each read takes the newest delivery and, **only if that one carries no state of charge**, merges the one before it, and so on up to four (an hour of a fifteen-minute request). That is not belt-and-braces: the reference car's newest delivery really did arrive with the odometer, the charge target, the doors and the climate in it and no battery reading at all. A delivery that does carry the battery costs exactly one download. A reading assembled this way is dated by the newest report that actually contributed to it, so a state of charge from forty minutes ago is shown as forty minutes old rather than as fresh. > **Both feeds now run, and the freshest reading wins.** This used to stop the MQTT feed being > subscribed at all — one holder, one writer, no race — until the reference install showed the portal's > state of charge for that car to be coarser and later than the same manufacturer's app API arriving > through Home Assistant. Which source is better is a fact about a car and a moment, not something a > configuration file can settle once. So both write to one reading, whichever saw the car most recently > is kept, and the controller says at startup when both are on. The dashboard names which feed the > reading on screen came from. ## Step 8 — Leave both on for a week, then hand over Do not switch the old feed off on the day you switch this one on. Run them together and decide on what they actually delivered. The dashboard names the feed behind the reading on screen, and **`/health`** counts the portal's **Feed reads** (readings of attempts) and its **Portal session** — how many sign-ins there have been: one, however long the controller has been up, is the healthy answer, and more means portal sessions are expiring. Those counts are **since the controller started**, on purpose, so a week means a week without redeploying. (The per-feed tally page from #141 was removed in #212: an installation now runs one manufacturer feed, so there is nothing left to compare.) When the week is boring, the handover is a setting: ```bash VW_ENABLED=true # leave the portal feed on Vehicle__Enabled=false # stop subscribing to the MQTT topic ``` …and stop the Home Assistant automation that was publishing to it. **Nothing is deleted.** The MQTT worker, its payload contract and its settings all stay, so if the portal disappoints next month the old feed is one line away — and it remains the answer for any car nobody has written a service for. If the week is *not* boring — sessions expiring daily, gaps of hours, a state of charge that disagrees with Home Assistant's by a steady margin — that is a result too. Write it down, stay on MQTT, and do not hand over because a document said to. ### When it needs you Two states, and they are deliberately not alike: | The card says | What happened | What to do | |---|---|---| | a reading, marked **stale** | the feed is trying and not currently succeeding — a 5xx, a timeout, a delivery not filled yet | nothing; it backs off and clears itself | | **Sign-in required** | a refused password, a consent screen, an OTP, a CAPTCHA, or no continuous data request | the sentence says which; clear it in a browser, press the button on the page to check, then restart the controller | **Sign-in required stops the feed asking.** That is the point rather than a limitation: a password replayed at an identity provider on a clock is how accounts get locked, and a consent screen polled forever is answered by nobody. You will not miss it. While it lasts, the web UI carries a band across the top of **every** page with the reason and a link to this portal page, the **Health** page shows it as a `Car feed` row, and the controller logs it as a **warning** every six hours — a log line, never another request. The **Car feed** entity carries the same three states (`Ok`, `Degraded`, `NeedsOwner`) with the sentence as a `reason` attribute, which is what a Home Assistant notification keys off. ### If it asks for a code from your email It is treated exactly as the consent screen is: `OwnerActionRequired`, reported, and **never retried**. There is no way around this and there is not meant to be — a one-time code is a person's mailbox, and a client that kept trying would only be replaying your password at VW's identity provider on a schedule. The code page is recognised before anything is posted, two ways: by the words on it (`verification code`, `security code`, `one-time password`, `Einmalcode`) and by a field asking for one (`otp`, `emailOtp`, `securityCode` and their like). The second is what still works on a page in a language the first does not cover, and it is also what stops the client mistaking a code page for a login page when the page happens to carry your address in a hidden field. Enter the code once in a browser, press **Ask the car** to confirm the sign-in works, and restart the controller. On the reference account this has not come up — six cold sign-ins with only an email and a password — and holding one session rather than signing in ninety-six times a day is partly there to keep new-device challenges rare. ### About the unrecognised fields Expect that list to be non-empty on a first run, and read it rather than ignoring it. [`VwGroupFieldNames`](../src/Gleanvolt.Infrastructure/Vehicles/VwGroup/VwGroupFieldNames.cs) was written from a *description* of the portal rather than from a capture, so if the battery or range above is blank, its real name is almost certainly in that list. Add the names it reports to that file and the mapping stops guessing. ## When it does not work The page names the **kind** of failure and whether retrying could help, and the feed turns the same kinds into either a backoff or a full stop. The kinds are deliberate: a consent screen and an expired session need opposite responses, and a client that cannot tell them apart either hammers a screen it can never answer, or gives up on something a re-login would fix. | Reported | What it means | What to do | |---|---|---| | `NotConfigured` | Something is missing before anything was attempted | It names which of the three: brand, VW ID, password | | `SignInRejected` | Credentials replayed and refused — or the form was not the shape the client expects | Check the password. If it is right, VW has changed the sign-in form and the parser needs updating. **Do not loop**: repeated failures risk the account | | `OwnerActionRequired` | Consent, terms, an OTP or a CAPTCHA — a screen only a person can answer | Open the portal in a browser and clear it. Never retried by design | | `VehicleNotFound` | No vehicle, or not the one asked for | Link the car in the portal, or set `VW_VIN` from the list it prints | | `NoDataAvailable` | Signed in, but there is nothing to download | Step 2. Either no continuous data request exists, or it exists and has not been filled yet — the message distinguishes the two | | `UnusableData` | The bundle arrived and could not be believed | The message says which field, and the page lists the names nothing here reads. Present-but-unusable is rejected whole, on purpose | | `SessionExpired` | A 401, a bounce to `/login`, or HTML where JSON was expected | Ordinary. Press again; the feed signs itself back in | | `Transient` | A 5xx, a timeout, a dropped connection — or a **429**, the portal rate-limiting the delivery endpoint | Wait. A 429 is provoked by asking for many deliveries at once, so it clears on its own and a lower `VW_MAX_DATASETS` prevents it. Nothing needs changing, and the message quotes the portal's own `Retry-After` when it sends one | `Missing a brand ..., a VW ID.` before any network traffic means `.env` was not found or not read — check you are running from inside the repository. With the feed switched on, the same sentence appears as **sign-in required** on the dashboard rather than as a failed press. ## About that password It is worth being blunt, because the alternative is implying a protection that does not exist. `VW_PASSWORD` is **not a read-only credential**. The same account that reads your car's state also unlocks it and locates it. There is no scoped token to use instead: this interface authenticates the owner, not an application. What protects it here is that `.env` is gitignored, and the client never logs it, never renders it and never writes it anywhere. That is the whole of it, and it is worth saying plainly rather than dressing up: an untracked file is what a LAN appliance has to offer. Entered on `/car` instead, it goes to the [secret store](../README.md#the-data-directory-holds-secrets-the-secrets-section) — an owner-only file in the data directory, or Windows DPAPI — and the page says which, in the store's own words. That is a better answer than an untracked file and still not proof against a local root, which is why the page says what it is rather than the word *encrypted*. It is write-only there: the box renders empty always, empty on submit means *unchanged*, and **Remove** is what clears it. [#137](https://github.com/mpospisil/gleanvolt/issues/137) accepted this trade knowingly rather than casually, and records why: the alternative that stores no password cannot re-authenticate unattended, and a car feed that needs a human at a web form every few hours is not a feed. ## What comes next Not this: what #140 described is what step 7 now does. The feed runs on its own schedule, writes through the same `VehicleStateHolder` the MQTT feed writes to, and shows *sign-in required* as a state distinct from *stale reading*. What is still open is everything a **second** manufacturer would settle. The contract behind this (`IVehicleUpdateService`) is deliberately small — no credential abstraction, no capability taxonomy, no auth-model enum — because one implementation cannot tell you what a second one wants. A car that has to be woken up to answer, and whose polling costs its owner range, is the argument that will shape it.