Sep 16, 202611 min read/2026/09/16/emulate-the-device-not-the-radio-ble-pendant/

Emulate the Device, Not the Radio: Building a BLE Pendant I Don't Own

I'm building an app whose whole job is to pair with a small wearable audio pendant over Bluetooth Low Energy, pull audio off it, and turn that audio into notes. This is part of my ongoing accessibility research — capturing spoken information hands-free and turning it into something structured and searchable is, at its core, an assistive-technology problem, and a wearable that just listens and remembers is a very direct way to attack it. The pendant speaks a compact BLE protocol built for open audio wearables — GATT services for streaming Opus audio, a ring buffer for storage, the usual battery and button characteristics — so a real unit and my app are supposed to be a matched pair.

For a good while, I didn't physically have one.

That is a worse problem than it sounds, and not only because you can't develop against hardware that's in a box on another continent. Even once you have a unit, a physical pendant is a bad test rig. It plays whatever the room gives it, so no two runs are the same. It has one battery that drains on its own schedule, not yours. You cannot ask it to drop the Bluetooth link at the exact instant your reconnect code is under test, or to hand you the same 82 seconds of audio twice. Hardware is nondeterministic by nature, and nondeterminism is the enemy of a test you actually trust.

So I did the thing you do when you need a device you don't have, or don't want to depend on: I emulated it. One Linux box, running BlueZ, pretending to be the pendant well enough that my app cannot tell the difference.

To be honest about the alternative: I could have built the real thing. I have the protocol spec, and the parts to build a pendant from it aren't exotic. But I am genuinely bad at soldering — and even if I were good at it, a breadboard full of my mistakes is a slower, flakier, less repeatable path than a Python script. Software-first isn't only cheaper here; it's the faster road to a device I can actually rely on while I develop against it.

The app can't tell it apart — that's the point

The emulator is a single Python script that turns a Linux machine into a BLE peripheral: it advertises itself as the pendant, stands up a GATT server with the same services and characteristics as the real firmware, and answers the same ring-storage commands packet-for-packet. To the app, a real unit and this fake are the same device — same advertised name, same service UUIDs, same [packet u16 LE][fragment] audio headers, same time-sync write.

That identity is not a nice-to-have. It's the reason the rig is worth building. If the emulator needed the app to take a special branch — "if this is the fake device, do X" — then the thing I'd be testing would be the fake branch, not the code that runs in production. Because the emulator is indistinguishable on the wire, the app registers it through its ordinary pendant transport and runs the exact same path it will run against real hardware. No #if TEST, no mock transport, no divergence to explain away later. You test the real thing by making the fake thing honest.

The rule that kept it honest: emulate the device, not the radio

Here is the line I drew on day one, and the single most important design decision in the whole tool:

It emulates the device, not the radio. It will not simulate packet loss, MTU changes, or storage corruption. A rig that lies about the transport is worse than no rig.

This is worth sitting with, because the temptation runs the other way. Once you have a software pendant, it is so easy to add a "drop 5% of packets" slider, a "corrupt the ring buffer" button, a "pretend the MTU renegotiated" toggle. And every one of those would be a lie, because I'd be inventing a physics I don't actually understand. Real BLE packet loss has a shape — it correlates with distance, interference, the specific adapter, the phone's radio scheduler. If I hand-wave a uniform 5% drop and my app passes, I've proven nothing except that my app survives my guess about radio behavior. A green test against a fiction is worse than a red one, because it retires your suspicion without earning it.

So the emulator is scrupulous about the boundary. Things that are the device's behavior — what audio it produces, what its battery reads, whether the button was pressed, whether it stored to its ring buffer or streamed live — those I emulate exactly, because I own that logic and I can make it deterministic. Things that are the radio and the physical link — loss, MTU, timing jitter, corruption — I leave entirely to the real BlueZ stack, two real adapters, and the air between them. The transport is never faked. If you want to test packet loss, you walk across the room, because that's the only version of packet loss that's true.

What the fake gives me that the real one can't

Everything I gave up by not using hardware, I got back multiplied, because now the device is scriptable:

  • A deterministic audio source. Instead of "whatever the mic hears," it loops a fixed 16 kHz mono WAV, or plays a clip once at scheduled minutes — 15, 130, 200 minutes after start — so I can run an overnight detection test and know exactly what should have been captured and when.
  • A ring buffer I can watch. The real firmware stores audio to a ring when no phone is subscribed; on the emulator that ring is a live gauge on a web page, filling and draining in front of me, so "did recovery actually work" stops being a guess.
  • A control room. A little page (and a POST /control endpoint behind it) where I set the battery to any value and watch the phone follow within two seconds, press single / double / long button taps, toggle charging, unset the clock, or force a disconnect — a link-layer event a real pendant never does on command, but exactly the event my app's reconnect path needs to be provoked with.

None of that is possible with a sealed piece of plastic. The emulator isn't a downgrade I tolerate until hardware arrives — for testing, it's strictly better than the hardware, precisely because it does the things a real device would never agree to do.

Why it's a standalone tool, not part of the app

A fair question: the app is Flutter, it already talks BLE — why not fold the emulator in? Two hard reasons. First, our Flutter BLE plugin has no peripheral role on Linux; it can be a central (connect to a pendant) but it cannot be one. Standing up a GATT server and advertising is a job for BlueZ directly, over D-Bus. Second, that D-Bus path is Linux-only, so macOS and Windows can't host the emulator at all — only a --selftest mode that renders the UI from fake counters and touches no Bluetooth. The emulator is therefore what it has to be: one self-contained script, no pip install, Opus pulled from the system libopus.so through ctypes, the GATT server built from distro python3-dbus and python3-gi. It runs on a spare Linux box and nowhere else, and that constraint is a feature — it keeps the fake device firmly outside the app it's testing.

The traps were the protocol, teaching me

Every serious trap I hit building this turned out to be the real protocol explaining itself. A few that each cost me an evening:

  • Killing the emulator does not drop the LE link. The process dies, but the laptop's adapter keeps the connection open, and the phone happily sits "streaming" against a corpse — no disconnect event, zero frames, forever. That's not an emulator bug; it's how BLE works, and it's why the app needs a 20-second stall watchdog. A real pendant streams continuously — silence included — so a quiet link is a dead link, and the app has to decide that for itself.
  • A connected peripheral stops advertising. BlueZ pulls the advertisement the moment a central connects, so a paired emulator vanishes from the "nearby pendants" scan list. Correct behavior — and a good thing to know before you spend twenty minutes hunting for a device that's working perfectly.
  • The ring stores only when nothing is subscribed and the clock is set — both, always. While the phone is subscribed, audio goes out live and nothing is written to the ring; until a time-sync arrives, frames are discarded rather than buffered, because every stored record carries a timestamp and the device refuses to invent one. That single two-part rule shaped the entire audio-recovery design, and I only really understood it by watching the emulator refuse to store.

Prove it in rungs, each one honest on its own

The rig verifies in three steps, and each is provable without the next. First, --selftest renders the control page from fake counters with no Bluetooth at all — it runs on my Mac and asserts every field the UI reads actually exists. Second, a small bleak-based client on a third machine does the full protocol handshake — scan, connect, read codec and firmware, write the time sync, stream while checking the packet header for gaps, then the whole ring-recovery sequence — proving the protocol end to end without the app in the room. Only then, third, the app itself: link a pendant, watch the battery slider on the box move the percentage on the phone, kill the emulator and watch the ring fill, bring it back and watch the app recover what it missed.

The audio this fake pendant streams is the same 16 kHz mono signal that feeds the voice-activity-detection gate I wrote about earlier — the emulator is the front door to that whole pipeline, which is a large part of why having a deterministic one mattered so much.

Where else this goes

I built this to unblock one app, but the pattern is much bigger than my pendant, and I keep finding new uses for it.

Software-first hardware design. You don't need the physical device to exist yet — you barely need it to be designed yet. If you can describe how a device should behave over BLE, you can stand that behavior up in software first, build the entire app against it, and discover the awkward parts of the protocol before anyone etches a PCB. The behavior gets nailed down where it's cheap to change — in a Python script — and the eventual firmware has a working reference to match instead of a spec to argue about. The only real cost of entry is a Linux computer with a Bluetooth adapter that can act as a peripheral (advertise and run a GATT server); with that one piece in place, you can prototype a device that doesn't exist yet — and you're not limited to one. The same box can impersonate an entirely different class of device tomorrow: a heart-rate strap, a thermometer, a custom sensor, anything you can describe as a GATT profile. One adapter, a new script, and it is that device — no second breadboard, no second soldering iron.

Quality assurance. Because every input is scriptable, you can manufacture the exact scenarios that are painful or impossible to reproduce on real hardware — the battery falling to 5% mid-recording, the link dropping at the worst moment, an overnight run with clips at precise minutes, a device whose clock was never set. Those are the cases that break apps in the field and that a human with a real pendant can almost never stage on demand. Here they're a slider, a button, or a one-line POST, so they can go straight into a repeatable QA checklist — or eventually a CI job.

And those are just the two I've leaned on most. The same rig is a natural fit for demos that must look the same every time, for reproducing a customer's bug from a captured log, for load-testing the app against a device that never gets tired, and for onboarding someone new without handing them scarce hardware. Once the device is software, anything you can describe, you can stage.

If there's a single thing to take from this, it's the boundary. When you have to build for hardware you don't have, emulate it — but be ruthless about which half you're emulating. Emulate the device, whose behavior you own and can make deterministic. Never emulate the radio, whose physics you don't own and can only fake. The most useful test rig is the one that's honest about the line between them, because a test that passes against a lie is the most expensive kind of green there is.