← All articles

Automating my mileage log with the BMW CarData API

· Sebastiaan Slootweg · aws, terraform, python, serverless

View the code on GitHub

I drive my private BMW for business and claim the kilometres from my own Dutch B.V. That needs a mileage log, which usually means a spreadsheet you fill in by hand and forget about until the end of the month.

So I built km-administratie-bmw. Every hour it reads the odometer and position of the car, turns those readings into trips, sorts them into business and private, and mails me a statement on the first of the month as Excel and PDF. The only thing left for me to do is answer the occasional question, usually with one click.

It's open source under the MIT licence and runs in my own AWS account. The interface and documents are in Dutch, but the ideas below apply to any project built on vehicle data.

The car is the sensor

I looked at the obvious alternatives first and turned them all down:

  • Mileage apps mean another subscription, with my location data at a third party.
  • Phone geofences can't tell the car apart from any other way of travelling.
  • An OBD or GPS dongle was overkill for this.
  • bimmer_connected, an unofficial client, depends on reverse engineering and runs into captchas at login.
  • Fixed distances per route miss what really happened, such as a detour around a traffic jam.

BMW now offers an official API, CarData, which exists because of the EU Data Act. It gives me the real odometer value, so the kilometres are exact, and days when the car doesn't move simply don't appear.

Architecture

Everything is serverless and defined in Terraform:

EventBridge (hourly)        -> Lambda poller  -> BMW CarData, then DynamoDB
EventBridge (daily, 20:00)  -> Lambda notify  -> SES mail with open questions
EventBridge (1st of month)  -> Lambda report  -> Excel and PDF, to S3 and SES
Browser                     -> Lambda web     -> server-rendered HTML, no JavaScript

BMW tokens live in Secrets Manager, and CloudWatch alarms mail me when polling fails or too few polls come through. There's no password to manage either: logging in works with a one-time link sent to a fixed address.

Normal use costs a few euros a month at most, and usually less. The two secrets cost about $0.80 a month, and most of the rest stays within AWS's free tier. A budget alert watches the total.

From odometer readings to trips

The core idea is simple. While the odometer doesn't change, the car is parked. Between two parked periods is a trip, and its length is the difference in odometer value.

A simplified example:

Time Odometer Position Meaning
07:00 32,332 Home Parked
08:00 32,382 Client Arrived, 50 km driven
17:00 32,382 Client Still parked
18:00 32,412 Somewhere else Moved on, 30 km driven

Hourly polling does hide things. Two short stops within the same hour become one trip, but the total kilometres are still correct, which is what the statement depends on. A reading can also arrive mid-drive, so an unknown place only counts as a stop if the car has demonstrably stood still there for at least 10 minutes.

Business or private, and when in doubt

You record your places once: home, clients, the office. After that, one rule shapes everything: a forgotten confirmation must never lead to claiming too much.

  • A trip between known business places is compared with the expected distance, which the app learns from your earlier trips. If it's within 15%, it counts as business in full.
  • After a large detour, only the expected distance counts as business, and the app asks you to confirm the rest with one click.
  • Trips to unknown places, and everything on weekends and public holidays, count as private without a question. The monthly mail lists them, so you can still correct a business trip you forgot about.

A day in the app: three trips with maps and odometer readings, and an open question about a stop at an unknown place.

In this example (made-up trips between public places), the car drove 54 km with an unknown stop on the way home. The app counts 49 km as business until I answer.

Answers and corrections are stored separately and reapplied every time trips are recalculated. Trips are derived again each hour, so otherwise every answer would disappear.

What I learned about the CarData API

The official documentation sits behind a login, so I checked my client against three community clients and a real car. A few things are worth knowing if you build on it:

  • The quota is tight. You get 50 REST calls per 24 hours, and calls for vehicle mappings and containers count too. The poller stops itself at 40 and doesn't retry, because a missed poll costs less than wasted quota. The next one comes an hour later anyway.
  • Refresh tokens rotate. They last 14 days and change on every refresh, so the new token has to be stored immediately, and only one process can own it.
  • You need a container first. You register the data points you want once, then read them using that container's ID. Every value comes back as a string.
  • There is no history. The REST API only returns the current state, so a missed reading is gone for good. That's why the alarms exist, and why the logic is built around parked periods rather than individual readings.
  • Not every field is filled. vehicle.isMoving and the ignition fields stayed empty, even mid-drive. The logic therefore only uses the odometer, position and timestamps.
  • Only the primary user gets data. You need to be the main user of the car in the My BMW app.
  • Outages happen. I saw BMW's API return 502 errors from a Friday evening until Monday morning. The app can't know what the car did during that time, so it adds at most the expected distance on the day of arrival and asks me by mail to add trips myself.

Keeping it small

A few choices keep the project cheap and easy to maintain:

  • Few dependencies. The PDF is written with the Python standard library instead of a PDF package, which keeps the Lambda package at about 1.2 MB.
  • Limited deploy rights. The deploy user can only manage kmlog-* resources, and every role it creates gets a permissions boundary, so it can't gain extra rights through a Lambda.
  • Safe rollbacks. Each release is a git tag plus a build, and scripts/deploy.sh v0.6.0 installs or rolls back to that version. Stored data is only ever extended, so an older version can still read it.
  • Tested rules. The calculation rules are tested against the example days written out in the design document.

Limitations

It's a personal tool, and it shows. It supports BMW only, with one car and one user per installation, and it needs your own AWS account plus a mail address on a domain you control. Short stops within an hour stay invisible. It also isn't tax advice, so check for yourself that the rate and records meet what your B.V. and the Dutch tax authority require.

The README covers installation, and the design document has every rule and the reasoning behind it. Both are in Dutch.