How I became a physical LLM interface
This text was written by a human and proofread by an LLM.
I've recently been annoyed by a car which has some useful connected functionality, most of which doesn't work in my country, despite the car being fully equipped for it, simply because corporate overlords don't care that much. And what we, lowly peasants, are allowed to have (like trip tracking) doesn't work too well because the mobile application is unstable, slow and has a mind of its own.
When I get such a great user experience, my first reflex is to make my own thing! Except these days I have maybe an hour a day to do something like this after I'm back from work and my toddler falls asleep.
Well, we all haven't been finding ways to get replaced by LLMs for nothing, right? So I took all my "load bearing" multi-agent LLM harness-driven product design and SDLC experience and dumped it in the bin, because all I have is a $20 Claude subscription and I really don't care to spend more on something that doesn't make me any revenue.
TL;DR: this was quite enough. See the git repository at excieve/odographe.
talking to the car§
My first instinct was to sniff and replicate the Bluetooth protocol that the official app uses to communicate with the car. I ran it past Claude and it actually talked me away from this approach, since it's going to be messy, it could break on change and it's likely a part of the instability issue in the app. Instead it suggested I use either the cloud API they offer or an OBD-II dongle and something to talk to it. The former has an unofficial Home Assistant add-on, but the API is glitchy just like the app, so it wasn't going to solve the problem.
This initial discovery conversation, before even coming up with the architecture, was extremely useful, and Claude kept me real and aligned with my goals. Always set the expectations and goals first. So it was decided to go with a standard approach that has tons of documentation and examples.
finding the right PIDs§
I bought an ELM327-based OBD-II dongle (don't buy the cheapest ones — they're crap), connected it and got to discovering what I can fetch from the car using it. Quite a lot of data actually, some of it standard OBD-II PIDs, some manufacturer-defined and therefore not publicly documented. Most of it not relevant for my purpose of recording trips and fuel consumption, but one of the Android apps for the debug dongles gave detailed chatter logs which I could use later.
One thing LLMs are really good at is pattern matching. So I fed that log and my visual observations from the car dashboard to Claude, and a few minutes later I had a mapping between PID requests and the metrics I needed, with conversion formulas. Sure, I could take the standard ones from the spec, but e.g. the odometer was only exposed by the manufacturer-specific body module, plus this was objectively faster and provided fixtures for testing later on:
| Metric | Request | Header | Echo | Formula | Group |
|---|---|---|---|---|---|
| RPM | 010C | 7DF | 410C | ((A·256)+B)/4 | fast |
| Speed | 010D | 7DF | 410D | A | fast |
| Fuel rate | 015E | 7DF | 415E | ((A·256)+B)/20 | fast |
| Coolant temp | 0105 | 7DF | 4105 | A−40 | slow |
| Intake temp | 010F | 7DF | 410F | A−40 | slow |
| Ambient temp | 0146 | 7DF | 4146 | A−40 | slow |
| MAF | 0110 | 7DF | 4110 | ((A·256)+B)/100 | slow |
| Fuel level | 012F | 7DF | 412F | A·100/255 | slow |
| Engine load | 0104 | 7DF | 4104 | A·100/255 | slow |
| Intake MAP | 010B | 7DF | 410B | A | slow |
| Baro | 0133 | 7DF | 4133 | A | slow |
| Module voltage | 0142 | 7DF | 4142 | ((A·256)+B)/1000 | slow |
| Odometer | 22D802 | 6A9 (resp 689) | 62D802 | 3-byte big-endian = km | slow |
| 12 V (adapter) | ATRV | — | — | direct | slow |
An agent will happily generate test fixtures that confirm its own model of the system. Fixtures captured from reality can contradict it.
where the data goes§
Then it was time to figure out how the raw data is going to get extracted from the dongle in my car, recorded persistently across device power cuts and reliably shipped over to my home data pipeline. Speaking of which, it is essentially an MQTT broker (routed by Traefik), Telegraf for transformations, VictoriaMetrics and Grafana, all orchestrated by Nomad on a bunch of random hardware in my home (an old laptop, a refurbished mini-PC with XCP-ng virtualisation, an RPi and various PCs that go on and off).
Image above is generated using an LLM.
There's also a Home Assistant deployment in there, which I thought about using for the UI, but, jumping ahead — it proved more finicky with structured historical data than just building a nice Grafana dashboard, so that remained "experimental" for now.
choosing the hardware§
Off to brainstorming with Claude again! It recommended throwing in an RPi and writing some Python service to basically fetch, process, store and upload. Or using an existing project, specifically obd2-mqtt on an ESP32. The latter sounded almost like what I wanted, but unfortunately it is a live streaming over cellular use case, and I didn't want to buy a mobile subscription for this. An RPi would be fairly simple to work with, but they're expensive and need a good power supply. Also, total overkill in terms of compute for this.
That's where I disagreed with Claude and decided we're going to build complete firmware for an ESP32 that is designed specifically for my use case, which is: communicate with the dongle over BLE, periodically fetch the data I need, process it, store it, and then, when connected to my home WiFi network, publish it on the MQTT bus, purging the queue. And, well, everything that is needed for this to be reliable enough to sustain days of driving without a connection and the regular sudden power cuts on the car's USB port it's plugged into.
As a proper LLM should, Claude praised me for my "good instinct", and we proceeded to build out the hardware requirements and firmware architecture. Also, on a whim, I asked it to design it for embedded Rust instead of C/C++, which is the typical choice for this. Why? Just because I wrote MCU firmware for ESP32 and STM32 in C before, and I also wrote non-embedded code in Rust, but never combined the two. Since it's not me who's going to actually write much of it — why not, I'll learn something new on a realistic pet project as we go.
architecture and plan§
The result of these deliberations, after a few iterations and reality checks during implementation, was split into architecture and implementation plan documents (warning, Claude-speak inside). This split was very useful during the coding sessions, both as a reference and as a way to sequence things step by step, with assumptions and constraints, dependencies, non-functional requirements, unit and on-car testing.
To be honest, I was quite impressed with the vanilla quality of this. More than a year ago I attempted a similar exercise (also in Rust, but not with an MCU) and it produced something overengineered; I eventually got drowned in code-slop that I had to frequently steer and rewrite. This time was very different, starting with simpler, smaller and more reasonable initial documentation. It even included some isolated spikes to verify a few assumptions early on real hardware.
In Claude's own words when I asked it to reflect on the interaction experience at the end:
Agents are very good at writing throwaway probes — you should ask for more of them than feels natural.
Now, in the modern world, this would not be sufficient for "proper" spec-driven development — that would use something like nWave (which I very much like) or GitHub Spec Kit. For a small, strictly scoped personal pet project — there's no need for this, as it turns out modern agents can figure it out well enough on their own (with some human input).
implementation§
With this sorted, implementation was pretty much a routine of asking the Claude Code agent in my editor of choice (the excellent Zed) to implement a phase from the plan. It would ask me a few things when unsure, I would test things out on real hardware (MCU board connected to my workstation for flashing and debugging + a car parked in the BLE range), come back with the results and iterate.
This lasted for a few weeks, mainly due to me being busy with work/life. Sometimes a real car trip would reveal some issues that the initial docs did not capture, since LLMs are still not great with physical reality, or because some premises just weren't true.
Again, giving the stage to Claude (it's being a bit too critical of itself, TBH):
My own plan document was a source of authoritative-sounding falsehoods. Agent-generated plans carry the same error rate as agent-generated code, but read as more authoritative because they're confident prose with section numbers. They should be verified at the same rate.
testing without a car§
Testing this without a board and a car is tricky. Logic can be unit tested, which is what the agent has done by moving most of it into individual Rust crates within the workspace that do not depend on the embedded toolchain and can therefore be tested hermetically on a host machine. Everything that needs peripheral behaviour (WiFi, BLE, RTC board, flash storage, LED, etc.) is abstractable only to a degree and essentially requires a physical testbed. It could be automated of course, but not something worth doing for this project. The same goes for abstracting away a car with the OBD-II device — even though there are scriptable emulators, the agent doing the implementation advised against it to reduce complexity given the purpose. I became an interface between the agent building software by guessing the next token and the dynamic physical world which it can't access or tokenise.
As Claude says:
Everything that mattered was in the car, and I've never been in the car.
Eventually, I got something that mostly worked and data was flowing, displayed on my Grafana dashboard.
Along with useful diagnostics that I used to debug the device remotely.
a case for it§
Sometime during the implementation, I decided a case was needed for the boards, which will live somewhere in the car most of the time. Earlier I had a bad experience asking Claude to design a 3D-printable model (e.g. with OpenSCAD), but it looks like things have improved. I gave it a description of what it should be, the boards it will hold and the purpose, then asked it to build the model for FreeCAD (which I mostly use for 3D model design these days). It did, and did it pretty well. It created a parametrised macro for FreeCAD which I could adjust based on physical measurements and the tolerances I wanted. After a couple of iterations of minor improvements (again, it still has issues with the physical properties of things, even given exact dimensions) I printed out a simple and perfectly functional case.
Later I found out there's a FreeCAD MCP that would probably have made it even better by comparing the renderings it produces.
was it worth it§
In the end, I got a working physical product for a problem I had that cost me a $20 subscription that I'm paying for anyway (+ some credits that Anthropic doles out from time to time), cheap hardware and some very limited spare time that I wouldn't have enough of otherwise. Even if this project doesn't get any more attention from me — it's fine, no sunk costs to speak of here.
It kind of blows my mind though that I can do this with minimal resources, a 3D printer and some relevant skills to understand what the process and the result should look like. I wonder, will there be a moment when a total rando with no such skills whatsoever can fabricate a narrow-purpose product with a prompt? This would be an actually positive and liberating change the "generative AI" could bring to the world.
random things I learned§
- LLMs got substantially better at creating specifications and writing Rust code without using a complex harness.
- Rust is pretty sweet for embedded programming, especially with
stdover ESP-IDF bindings for ESP32 — most things one is used to in the non-embedded world just work. - Binaries tend to be rather large with the
stdflavour, see the optimisation doc on my (and Claude's) efforts to make it fit into less flash space and memory. - Mainstream Rust supports the RISC-V (e.g. ESP32-C6 and H2) build targets in the nightly toolchain, but doesn't support the Xtensa (e.g. ESP32-S3) targets (there's a fork for that).
- The embedded toolchain with ESP-IDF is integrated into
cargo buildautomagically by embuild. - LittleFS is a fast and reliable modern alternative to SPIFFS for a durable filesystem over the microcontroller flash storage.
- A lot of Rust
stdand ESP-IDF can be optimised away for better binary size through configuration and LTO. - ESP32-C6FH8 is surprisingly capable for a tiny 160MHz/512KB SRAM RISC-V package with WiFi 6, BLE and Zigbee/Thread on board.
- Opus 5 is not qualitatively better than Sonnet 5 at executing an implementation plan, while being much slower and more expensive.
- Opus 5 is acceptably good at printable 3D modelling now.
- When working with a Rust codebase, Claude Code agents are much more proactive at researching the dependency APIs (through the codebase and docs) when building things, and at triaging the bugs against the dependency codebases too, which improves the quality and code clarity a lot.
- Even though I didn't write much of the codebase in this project, it was still pretty entertaining and a good learning experience. Doing it the old way would have been much more rewarding, but in my current circumstances it most likely would never have been done.
- Desoldering a pin header row from the cheap RTC board is a pain.