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.

BLE HID-over-GATT A2DP audio Two-way clipboard Single-file exe MIT
demo.live — move across the seam PC has control
this pc
Notes — iPad
◊ one radio · 8087:0aaa BLE HID → iPad A2DP → earbuds time-shared · 15–30 ms LE interval

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.

01

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.

02

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.

03

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.

04

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.

FixControllerMode=dual with Discoverable=false. BR/EDR stays enabled (the audio needs it) but not discoverable, so only the LE keyboard shows.

05

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.

06

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.

07

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.

08

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.

09

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 / --portal children 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.

✓ shipped
Fast pairing, in-frame dialogs, tray-crash fix, a lean one-window UI. Confirmed live on hardware: discovery is fast, the bridge auto-starts on bond, audio returns on its own.
◆ now
v1.0. A reproducible VM provisioner takes a fresh Debian install to “the mouse moves on the iPad” — validated on a fresh clone.
· next
Tier-2 fast-advertise (explicit LE interval + TX power during the pairing burst) and a pointer-drift re-sync, since BLE sends only relative mouse motion.

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.