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 your keyboard and mouse drive iPadOS directly — while the same antenna streams your PC audio to earbuds and keeps a two-way clipboard in sync. All of this, without installing anything on the iPad.
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
Ten 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.
A copy killed the mouse — and it wasn’t our bug
Copy anything in Windows and the edge crossing died silently — the iPad still showed as connected, but the pointer would not cross. Restarting the portal fixed it, every time. Three fixes aimed at the hook code all failed. Instrumentation ended the argument: the hook procedures never blocked, never threw, and SetWindowsHookEx kept returning valid handles — while delivering zero callbacks with the cursor provably moving. Six re-installs: nothing. Brand-new threads: nothing. Only a new process ever restored it.
Fix — Windows UIPI: a non-elevated process receives no low-level input hooks while an elevated window has focus. The copying was happening inside an admin terminal — so the instant it had focus, the portal went deaf, and “restart the portal” only ever worked because restarting steals focus back. Run OpenSpan elevated and it vanishes. It now detects this, warns at launch, and shows ⚠ NOT ADMIN. Every line of scaffolding written to chase it was deleted. It looked exactly like a bug in our code. It wasn’t.
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.
- The mouse stops crossing and nothing is wrong — if OpenSpan is not elevated and any admin window has focus, Windows delivers it no input hooks at all. Run OpenSpan as administrator. The status bar warns you (
⚠ NOT ADMIN) rather than letting you hunt for a bug that isn’t there. - “It reconnected to my iPad on its own” — it used to: the daemon advertised as a Bluetooth keyboard from boot and never stopped, so any bonded iPad would silently rejoin. Broadcasting is now opt-in — nothing is advertised until you press Pair/Broadcast, and the status bar tells you the truth (
📡 BROADCASTING/not broadcasting).
Work in progress — copy/paste. The two-way clipboard’s plumbing works (FKA chords → Apple Shortcuts → a token-guarded LAN relay), but it is not finished: the iPad Shortcut’s token can drift out of sync with the relay’s, and a paste then returns a “bad token” error instead of your text. The setup also isn’t documented well enough for anyone else to reproduce reliably yet. Treat clipboard sync as experimental — it’s actively being figured out and written up.
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.