Open source · Windows ↔ iPad · Bluetooth
Drive an iPad from your PC over a keyboard Windows said you couldn’t build.
Windows blocks apps from publishing the Bluetooth HID service, so OpenSpan runs a headless Debian VM that owns the radio and advertises as a BLE keyboard. Shove your mouse past a screen edge and it drives iPadOS directly — while the same antenna streams your PC audio to earbuds and keeps a two-way clipboard in sync. A personal engineering project; this is its field manual.
What it is
A Windows app, one Linux VM, and three data paths over a single antenna.
Windows reserves the Bluetooth HID service for itself; Linux’s BlueZ does not. So a headless Debian VM owns the PC’s radio over USB passthrough and pretends to be a keyboard, while a small Windows app captures your real input and streams it in.
Control app
Windows / Tkinter — standard-library Python + ctypes, no runtime deps
The bridge
Debian 12 VM — owns the Intel radio via xHCI USB passthrough
Keyboard + mouse
BLE HID-over-GATT — a BlueZ GATT peripheral (HOGP)
Audio
WASAPI → UDP → A2DP — PipeWire on a persistent user bus
Clipboard
Two-way, plain Ctrl+C/V — Apple Shortcuts + a token-guarded LAN relay
Ship
One OpenSpan.exe — PyInstaller onefile, role-flag dispatch
The build log
Nine things that had to break first.
Each of these is why the next line of code exists. Laid side by side they read as one lesson: the platform fights you, and the fix is almost never the obvious one.
Classic Bluetooth keyboard
A Classic (BR/EDR) HID keyboard pairs with the iPad and is then silently ignored — iOS accepts the connection but never takes a keystroke.
Fix — Apple only cooperates over BLE HID-over-GATT (HOGP). Rebuilt as a Low-Energy GATT keyboard. This is the whole reason a Linux VM exists.
USB 1.1 / 2.0 passthrough
Handed to the VM over EHCI/OHCI, the radio dropped off the bus under streaming load — fine idle, gone the moment audio flowed.
Fix — xHCI (USB 3.0) passthrough enumerates it in ~8 s and holds, plus usbcore.autosuspend=-1 so it never idle-suspends.
Connected, subscribed, dead
iOS connects to the LE keyboard and even subscribes to notifications, then ignores every report because the link was never bonded.
Fix — mark the HID report characteristics encrypt-read: that forces iOS to bond before it can read them, and bonding is what activates the keyboard.
Two “OpenSpan Keyboard”s
A dual-mode adapter that stays Classic-discoverable shows the iPad a second, un-pairable decoy next to the real LE entry — a coin-flip which one you tap.
Fix — ControllerMode=dual with Discoverable=false. BR/EDR stays enabled (the audio needs it) but not discoverable, so only the LE keyboard shows.
Audio that quits after five seconds
A2DP suspended itself the moment the stream went quiet, then wouldn’t wake — every pause killed the earbuds.
Fix — a modified WirePlumber config (suspend-timeout=0, pause-on-idle=false) and a silence feed on the sink so the codec never goes idle.
Pairing broke the music
Hitting Broadcast to pair the iPad knocked out audio that was playing — one radio, two jobs, contending for the same airtime.
Fix — make it a deliberate, confirmed trade rather than a surprise: the fast-pair flow (next), which frees the whole radio only when you actually mean to pair.
The iPad was slow to see the keyboard
There is no “low-power advertising” setting — the real cause is that the silence feed keeps A2DP transmitting even when nothing plays, starving the advertising.
Fix — on a confirmed pair, briefly drop the earbud audio link to free the whole radio, broadcast at full power, auto-start the bridge the instant the iPad bonds, then reconnect the earbuds — back to steady state on its own.
Send-to-tray crashed the exe
“Send to system tray” hard-crashed the packed build — twice — orphaning the audio and portal children. Source never reproduced it; the Windows crash log named it: _ctypes.pyd, 0xc000041d.
Fix — the tray’s ctypes callback raised, and a --noconsole build has sys.stderr = None, so ctypes printing the traceback from inside the callback faulted the process. Give the frozen build a writable stderr, wrap the callback so it can’t raise, and add a self-check that verifies the path inside the packed binary.
The volume slider only worked at the bottom
The volume slider felt broken: anything above ~25% was already too loud, with no usable range above it. The sender mirrored the Windows master volume by reading its slider position (GetMasterVolumeLevelScalar, a tapered 0–1) and multiplying the audio by it as a raw amplitude — but a slider position is not a gain. At 5% it applied −26.7 dB where the slider actually meant −46.2 dB (~20 dB too hot), and linear-amplitude faders bunch all their travel into the bottom.
Fix — read the real level with GetMasterVolumeLevel (dB) and apply 10^(dB/20), so the earbuds follow the same perceptual curve as a normal Windows volume slider. The first theory — “these earbuds are just louder hardware” — was wrong; a five-agent review of the whole volume chain found the actual curve bug.
Operational notes
Symptoms that look like bugs but are the environment.
- App stuck “Booting” — two VMs both grabbing the one radio via their USB filters. Power off the extra; the survivor re-grabs it.
- Two keyboards on the iPad — a stale twin from restarting the daemon; forgetting one removes both, proving it’s one device. Forget both, pair once.
- Window gone, audio still plays — the GUI crashed; the
--audio/--portalchildren survived. Kill them, relaunch. - “Works from source, crashes as an exe” — a frozen windowed build is a different runtime. Verify frozen-only paths inside the packed binary.
- Earbuds come in at full blast after a swap — a Bluetooth sink WirePlumber has no saved volume for comes up at 100%, and because A2DP absolute-volume makes that the earbuds’ own hardware level, a never-seen pair (or one that reset to a new MAC) arrives at max. Set it once and it’s remembered per device.
Where it goes
Roadmap.
Reference
The exact switches.
- build
- python build_exe.py → OpenSpan.exe (PyInstaller --onefile --noconsole, ~64 MB)
- radio
- Intel 8087:0aaa · xHCI passthrough · usbcore.autosuspend=-1
- adapter
- ControllerMode=dual · Discoverable=false · LE interval 15–30 ms
- bond
- HID report characteristics flagged encrypt-read (forces iOS to bond)
- audio
- WASAPI loopback → UDP :4010 → PipeWire A2DP · suspend-timeout=0 + silence feed
- provision
- create-vm.ps1 → sudo bash guest/provision.sh all → reboot · verify: cold-test.ps1
Behind the scenes
The making-of.
OpenSpan was built by me with Claude as a pair. The record is only trustworthy if it owns the misses — so they’re here, one click away, for anyone who cares.
What the collaboration got wrong, and banked for those who really care · expand
Four misses and the rule each one became.
the miss“Shipping for months”
A week-old codebase got described as long-shipped for narrative punch. Caught it. Rule banked: document for a hostile reader — every claim survives fact-checking, no embellishment.
the missA native light-mode dialog on a dark app
A stock Windows messagebox shattered the dark theme. Rule banked: no native dialogs — themed, in-frame modals that look like they belong.
caught pre-shipTwo bugs an adversarial review found first
Before the fast-pair change touched hardware, an eight-agent review caught an audio-restore that could silently no-op and a stale snapshot that mislabeled a just-started button. Rule banked: adversarially verify a change before it costs a real test cycle.
caught pre-shipA crash only the frozen exe could reveal
The tray fault never showed from source. Rule banked: a windowed frozen build is its own runtime — add a self-check that runs the risky path inside the packed binary before shipping.