SpokoMeter: a digital car gauge

· 9 min read

A digital car gauge on ESP32 with a phone app, built with AI in Kotlin and C++.

A digital car gauge on ESP32 with a phone app, built with AI in Kotlin and C++.

At a glance

Problem
Off-the-shelf digital gauges cost several hundred euros, are closed, and extending them is expensive. I wanted my own, one I can adapt and extend however I like.
What was built
A digital car gauge with a round 360×360 screen on an ESP32-S3 that looks like a factory VW part, the SpokoMeter Android app that connects to it over Bluetooth and configures it, and an Android Auto view on the head unit.
My role
The idea, the visual direction, steering the AI agents and reviewing every change, and outside the code the electronics, the soldering and the 3D-printed housing.
What makes it interesting
Before this I had never written a line of Kotlin or C++ and had never built a native mobile app, only PWAs and an APK from Vue through Capacitor. What carried over was the way I work on web projects.
Result
A working gauge and app after a few days: more than 130 merged pull requests across two repositories, each one through a local build, tests and review.

Where the idea came from

I own a VW Polo 6R, and modifying it has become my hobby, so this project did not come out of nowhere. A few years ago I collected the available options in an overview of additional gauges for the Polo 6R. For a few years I have also been driving with the German CANchecked display, which I wrote up in a review. It works, but for a device that costs more than 650 euros it is poorly made. The housing is a cheap 3D print, and the display has an outdated micro-USB port that did not fit well even with an angled plug, so the manufacturer simply trimmed it to make it fit. Extending it costs extra too: a strip of a few shift-light LEDs alone is about PLN 300.

I like DIY, so I made my own: open, and something I can adapt and extend however I like. I approach websites the same way, because there is always something to fix or improve.

If all I wanted were classic dials, the auxiliary gauges from a Beetle or a Scirocco would have done. I was after a digital gauge I can customise freely, which might one day go into the housing of the Scirocco’s centre gauges. Above all, the project was a test of what is possible.

It still had to suit the car, hence the project’s main rule: by default everything looks like the factory gauges, meaning the VW font, red zones made of dots as on the 6R, and LCD segments like the fuel gauge. Every extra is an option you switch on.

The dials came together by trial and error. Claude Code designed them, and I asked for corrections many times, comparing the result with my own close-up photos of the 6R gauges: move this element, make that line thicker, change a gap. I wanted them to look like factory dials while still letting you change the style and the values shown.

Three requirements came on top of that:

  • Configuration from the phone. The phone connects to the gauge over Bluetooth and sets the pages, layouts, channels and colours, and the preview in the app matches the display pixel for pixel.
  • Extensibility. A new data channel, a new layout or a new source (CAN, OBD) must not mean rewriting half the code.
  • Android Auto. Live values, page switching and fault codes on the head unit.
SpokoMeter, Gauge tab: the dial preview and drive data, namely rpm, boost, oil, speed, gear, coolant, fuel, range and voltageSpokoMeter, Look tab: a gallery of dials (VW, Segments) and editing of colours, needle and scale, with changed items markedSpokoMeter, Car tab: a list of fault codes, one active and two stored, with the module and the fault describedSpokoMeter, History tab: saved drives with maximum and minimum values and CSV export

What it does today

  • Pages and layouts: a classic dial, several values at once, a sides layout with arcs, a clock and a map.
  • Channel icons in the style of VW warning lights.
  • A battery voltage channel, added in one afternoon.
  • An OpenStreetMap map, added partly for fun: it looks like the GTA minimap, has colour themes and works offline.
  • Fault codes (DTC) and Android Auto.

The car’s data will come from the CAN bus. The CAN module is the next step, so for now the values come from a simulator built into the firmware. Everything else, the display, the app, the protocol and Android Auto, runs on real hardware.

This is the gauge wired up for testing on my desk:

The gauge on the desk: a sport rev counter dial with gear and speed in the middle, the ESP32 board with a red LED behind itThe gauge on the desk: a VW-style dial with speed in the middle, boost at the top and oil and coolant pointers at the sidesThe gauge on the desk: an OpenStreetMap map with the surrounding roads and a position arrowThe gauge on the desk: an analogue clock face with orange hands and the date, the ESP32 board and wires beside it

In a short clip from the desk, posted on the polo.blue channel, the gauge cycles through its pages:

Play video: DIY digital gauge for VW Polo 6R on ESP32

Architecture

  • Display ESP32-S3 with a round 360×360 GC9B72 screen. C++, LVGL 9, NimBLE, PlatformIO.
  • SpokoMeter app Kotlin, Material 3, screens moving to Jetpack Compose. A BLE link service, drive history, offline maps, UI tests in Robolectric.
  • Android Auto Car App Library templates: live values, page control and a list of fault codes on the head unit.

The display and the phone connect over Bluetooth Low Energy (BLE) and talk through a small frame protocol of their own: settings, pages, live values, position, and larger uploads split into chunks with a checksum (dial photos, map areas). One document describes the protocol, and both I and the agents working on either side use it.

How I work

Claude Code (on the Max 20x plan) leads the implementation, but a single prompt in a chat would not have been enough here. Everything rests on the same process I use on web projects.

  1. Step 1

    Description and photo

    I say in Polish what I want, usually with a photo: a close-up of the factory 6R dial, an auxiliary Beetle gauge, other round displays.

  2. Step 2

    Issue and plan

    Claude turns it into an issue and a plan. For visual changes it first renders quick previews of a few variants, and I pick one before anything goes to the hardware.

  3. Step 3

    Agents in parallel

    Sub-agents work separately on the firmware and the app. Some GitHub issues are handled on their own by Cezar, the agent runner from Open Mercato, which opens pull requests.

  4. Step 4

    Local gates

    Build, unit tests and lint run locally, because I do not rely on GitHub Actions. On top of that, a check on the real display over the serial port: a scripted screenshot, fps and memory use.

  5. Step 5

    Review and merge

    Every PR goes through code review with the om-code-review skill, and /simplify runs before each code commit. Nothing is merged without my OK; the agent does not merge its own work.

  6. Step 6

    On the desk

    I flash the display, install the app on the phone and look at the result live. Only then does the next iteration start.

Some of the work goes to Cezar, an open-source runner that runs agents in parallel, and code review is done by the om-code-review skill from the Open Mercato set. The project also has its own rules in CLAUDE.md files that the agents read at the start: code and docs in English, the conversation in Polish, defaults as on the factory gauges, every setting handled in both repositories at once.

Cezar working on the two-pane layout task: the agent renders the app on a wide screen and a tablet and looks at the screenshots before opening a pull request

Here Cezar is working on the wide-screen layout. Before opening a pull request it renders the app at several screen sizes and looks at the result, so what reaches review is a change the agent has already seen. The final judgement is still mine, on the phone.

Quality gates that mattered

  • Tests for design rules Nothing overlaps the scale, the side scales are symmetric, the pointer never covers a numeral, the segments are equal. I wrote the visual rules as tests, so review does not have to police them.
  • One geometry The preview in the app and the image on the display are computed from the same dimensions. I compare them side by side, so any drift shows at once.
  • Budgets on the device Frames per second, the slowest frame and LVGL memory fragmentation, measured on the real ESP32. The classic dial with dots holds 44-45 fps.

Three stories from the project

Faithful to the original: dots and segments

Two details show it well. The red zone on the factory 6R dial is a halftone of dots that get denser towards the end of the scale. The first version used triangles, the second dots, and later ones corrected their shape and direction from my close-up photos. Twice the AI misread a photo: it reversed the direction of the gradient, and once it decided a pointer was reversed when it was not. A side-by-side comparison at four times the size settled it.

Comparison of the dotted red zone: two of my close-up photos of the factory Polo 6R rev counter and the same section drawn on the display

The fuel gauge went the same way. I replaced a plain arc with LCD segments like on the base Polo, with a separate reserve slot and one red segment when fuel is low.

Variants of the fuel bar with LCD segments on a full tank and on reserve, with the red reserve segment

Diagnosing with evidence: slow map downloads

Downloading the offline map was very slow. Instead of guessing, I measured where the time went, and it turned out the public Overpass servers were holding it back. After switching to OpenFreeMap vector tiles, 50 km of roads around home download in 11 seconds, where before only 2 of 37 pieces were done after several minutes.

Map colour themes: four palettes on the same area and the same palettes on the round screen with a highlighted route

A preview on the computer before anything reaches the gauge

At first, small visual tweaks went through the full cycle: change, build, flash, screenshot from the display, so every round of corrections dragged on for a long time. I reversed the order: first I look at a few variants side by side, rendered on the computer, and only the chosen one goes to the hardware. Most visual changes were made this way, from the first version of the dial to the current one.

An early version of the dial: oil and coolant scales with dotted red zones and fuel as a plain red arc
Early: fuel as a plain arc
The current dial: lighter scales with red pointers and fuel as LCD segments like on the Polo
Later: LCD segments and lighter scales

The preview and the display are drawn from the same geometry, so before anything is merged I put them one above the other. Two changes from the last stage, checked this way:

The middle value in four variants, VW and LCD fonts in large and small sizes: the display on top, the app's preview below
The middle value’s font and size, chosen in the app: the display on top, the preview below
The digital clock with small hour and minute hands on the scale, in the VW and Segments styles: the display on top, the app's preview below
Small hands on the digital clock’s scale, added after one remark: the display on top, the preview below

Where I stumbled

  • The visual layer. Getting the look right took the most time. AI handles logic and tests noticeably better than reproducing a design pixel for pixel.
  • Reference photos. The model misreads details in photos: direction, proportions, what is flipped. Putting both versions side by side, enlarged, clears up doubts quickly.
  • Shared resources. Parallel agents shared one serial port to the display. Once a test build that did not know about a new channel wiped the pages I had set up. I restored them from a backup, and the project rules gained a new one: read the owner’s state, restore it after the test, and check against the newest build.
  • Permissions. The agent does not merge its own PRs without my explicit OK. That is a gate I kept on purpose.

Beyond the code: soldering iron, calipers, printer

Claude Code speeds up every layer of the project, but there are a few things it cannot do for me.

Electronics. The display module ships with a loose pin header you have to solder in. The GND pin sits on a large copper area, so a cold joint is easy, and the symptom is sneaky: the backlight goes out when you move the wires. Then the display has to be wired to the ESP32-S3 according to the pin table, keeping in mind that the module takes 3.3 V, not 5 V, and a voltage divider has to be built for measuring the battery, which I calibrate with a multimeter.

The gauge wired up for testing on the desk: the round screen with a dial, the ESP32 and a tangle of wires behind it, a breadboard at the side

Housing. Instead of clicking it together in a CAD program, I described it in OpenSCAD code. I took the module’s dimensions from the manufacturer’s drawing and filled in the ones it lacks, such as the glass height above the board or the thickness of the air-vent slat, with caliper measurements. The model is parametric: the wall thickness matches two printer perimeters, and the fit clearances are tuned for printing. Each version also has a separate test part, just the front, for a quick print and a test fit. It is the same idea as previewing dials on the computer, only in plastic.

Successive housing versions from OpenSCAD: a round body, two frames for the air vent, a body mounted on a vent slat, the slat clip and the assembly from the back

Claude wrote the model, but measuring, test-fitting and deciding whether it fits stayed with me.

The app and Android Auto

SpokoMeter is my first native app. It has five tabs: a preview of the display with data from the current drive, pages and their layouts, appearance, car settings (engine version, scale, red zone, fault codes) and drive history with CSV export. Any part of the dial can be tapped in the preview and changed, and the display updates straight away.

At first it was just an app: classic Android Views, a dial preview and a few settings. The next morning Material 3 arrived, that evening the Jetpack Compose foundation, and after it the current layout: five tabs, app settings behind a gear icon, and the preview beside the settings on wide screens. Screens move to Compose one at a time, without rewriting everything at once.

An early version of the app under the name Spoko Gauge: a light theme, a classic rev counter dial, a pairing button and a brightness slider
Early: the light “Spoko Gauge” version
The current SpokoMeter app: a dark theme, the dial preview of the connected display and the current drive card
Later: SpokoMeter
SpokoMeter, Screens tab: the list of display pages (dial, values, sides layout, map) with ordering and a visibility toggleThe page editor for the sides layout: a pinned dial preview and the list of slots with their assigned channelsThe map sheet in the app: status, fetch on demand, colour themes and north-up orientationSpokoMeter, Car tab: fuel type, engine version, rev counter scale, red zone start and the needle sweep at start-up

On the head unit the app shows live values, lets you switch the display’s pages and lists the fault codes.

SpokoMeter in Android Auto with the Polish interface: Engine and Temperatures sections with live values and previous and next page buttonsSpokoMeter in Android Auto: the list of fault codes

What is next

  • The CAN module and real data from the car: first passive sniffing of the bus, then diagnostic requests for boost and oil temperature.
  • Fault codes from OBD instead of simulated ones.
  • Three displays on one ESP32, in place of the Scirocco’s auxiliary gauges.
  • Support for the MQB platform, not just PQ25.

What I learned

Before this project I had never written a line of Kotlin or C++. A few days later the app works together with the display and Android Auto. What helped were habits from building websites and web apps: a plan before code, tests, a review of every change, and checking instead of guessing. AI writes code faster than I do, but I decide what goes in and I answer for the result.

If you have a problem that means connecting an app, a device or an existing system, get in touch. I am happy to advise and help you solve it.

Back to portfolio