Skip to content

BLE HID support - #79

Draft
sago35 wants to merge 29 commits into
mainfrom
ble-hid-2
Draft

BLE HID support#79
sago35 wants to merge 29 commits into
mainfrom
ble-hid-2

Conversation

@sago35

@sago35 sago35 commented Jul 13, 2026

Copy link
Copy Markdown
Owner

I have finally prepared to add BLE HID to tinygo.org/x/bluetooth. I am creating a PR here to showcase the finished version. For now, it is a draft PR. After the merge into tinygo.org/x/bluetooth is complete, I plan to update go.mod to make it the final version.

sago35 added 13 commits July 13, 2026 20:02
Also updates tinygo.org/x/drivers to v0.35.0 and github.com/tinygo-org/pio
to v0.3.0. During development the bluetooth module is replaced with a local
fork (pairing/bonding, SDFlash, RemoveBond, ...) via an untracked go.work.
Add KeyOutputNext/KeyOutputUSB/KeyOutputBLE (OU_NEXT/OU_USB/OU_BT) for
runtime output switching and KeyBluetoothUnpair (BT_UNPR) for deleting the
stored BLE bond, using the same codes as QMK so they can be assigned from
Vial's Any key. Also aliased in the japanese/italian keycode packages.
Device.Init now calls d.Keyboard.Init() first, so outputs that need setup
(such as the upcoming BLE keyboard) get initialized both via d.Loop() and
via a manual d.Init()+d.Tick() loop. Keyboard and UartTxKeyboard gain empty
Init implementations.

Keyboard.Port is machine/usb/hid/keyboard's Port(), which has no Init, so
its type moves to a new PortUpDowner interface (UpDowner minus Init). Also
add a NewUSBKeyboard helper that returns a plain USB-port Keyboard.
Replace the direct machine.Flash references in Device.Init/Tick and Save
with a package variable FlashDevice (machine.BlockDevice, defaulting to
machine.Flash). While the SoftDevice is enabled, direct NVMC access hangs,
so the BLE keyboard swaps in a SoftDevice-backed implementation with the
same offset/size semantics.
An UpDowner that notifies key reports to a BLE central instead of sending
USB packets. Init (called by Device.Init) enables the BLE stack, turns on
Just Works + LESC pairing, registers the device information (PnP ID),
battery and HID services, and starts advertising with Appearance 961
(keyboard). The advertised name defaults to usb.Product.

The report map uses report IDs: ID 1 is a boot-layout keyboard report with
the key code range widened to 0-255 (japanese keys 0x87-0x90, F13-F24),
ID 2 is a single-slot consumer control report for media keys. Down/Up
dispatch the same way as machine/usb/hid/keyboard and notify only on
change. Unpair (BT_UNPR) deletes the stored bond so a new central can pair;
until then a bonded device cannot be taken over.

Init also swaps FlashDevice to bluetooth.SDFlash, since machine.Flash's
completion polling loses SOC events to the bluetooth SWI2 handler and
hangs once the SoftDevice is enabled.

The file is build-tagged tinygo && softdevice: bluetooth.Characteristic is
only defined inside the backend build tags, so importing bluetooth from
keyboard.go would break USB-only targets such as plain rp2040.
SwitchableKeyboard (build tag tinygo only, so USB-only targets still
build) holds a USB and a BLE UpDowner, initializes both up front, and
routes Down/Up/Write to the active one. SetOutput releases all keys still
pressed on the previous output first so none stay stuck.

Device.Tick handles the QMK connection keycodes: OU_NEXT/OU_USB/OU_BT
switch the output and BT_UNPR forwards to Unpair. These branches must stay
above the TypeMacroKey branch since 0x77xx codes would also match the
macro mask and panic in RunMacro. Both directions of the VIA keycode
conversion pass the codes through so they can be assigned from Vial.

The selected output is persisted in one extra byte at the end of the flash
block written by Save() and restored in Device.Init(); 0xFF (erased or
older layout) keeps Default, so previously saved keymaps stay compatible.

NewBLEKeyboard returns a BLE keyboard bundled this way: BLE by default and
always switchable to USB (the core already imports machine/usb
unconditionally, so a USB-less chip could not build anyway).
BLETxKeyboard grows a third report (ID 3: 5 buttons, relative X/Y and
wheel, the same 4-byte payload as machine/usb/hid/mouse's tx()) and now
implements Mouser by notifying that report, so a BLE central gets mouse
events from the same HID service.

SwitchableMouse is the Mouser counterpart of SwitchableKeyboard:
NewBLEMouse(kb) shares kb's *BLETxKeyboard (no extra Init needed) and
falls back to USB-only when kb.BLE is not a Mouser. SetOutput releases any
held buttons on the previous output so none stay stuck.

Device.Tick's OU_* handling and Device.Init's flash restore now drive
d.Keyboard and d.Mouse with the same output number, so one key switches
both in lockstep and they cannot drift apart. Persistence still stores
only the keyboard-side output.
BLETxKeyboard.SetBatteryLevel updates the battery service characteristic
and notifies subscribed centrals; the value is clamped to 0-100 and only
written when it changes. SwitchableKeyboard forwards it to its BLE output,
so callers can hold the *SwitchableKeyboard returned by NewBLEKeyboard and
report the level regardless of the active output. Call it after d.Init().

BatteryPercent converts a LiPo cell voltage to a percentage by linear
interpolation over the 3.3V (empty) to 4.2V (full) usable range; reading
the voltage (ADC setup, voltage divider) stays board-specific and up to
the caller.
The old Device.OverrideCtrlH() replaces d.Keyboard with a plain USB
Keyboard, so assigning a SwitchableKeyboard afterwards silently disables
it (raw Ctrl+H then reaches the host, which is why it only worked inside
an IME on Android). Wrapping d.Keyboard is not an option either, because
Device.Tick needs the concrete *SwitchableKeyboard for output switching
and unpair. Instead, add the same Ctrl+H -> Backspace translation to
SwitchableKeyboard itself, routed through the active output so it works
over USB and BLE alike.
Even with USB active, the BLE side kept advertising (or stayed connected),
so the keyboard still showed up as connected in the host's Bluetooth
settings. With the opt-in MuteBLEOnUSB flag, switching to USB stops
advertising and disconnects the current central, and switching back to BLE
resumes advertising. applyRadioMute runs from both Init (the saved output
may already be USB at boot; SetOutput alone would not fire since it
returns early when the output is unchanged) and SetOutput, and only acts
when BLE implements MuteRadio()/UnmuteRadio().

In BLETxKeyboard.MuteRadio the order matters: the fork's disconnect
handler restarts advertising while isAdvertising is still set, so Stop()
must come before Disconnect(). Connections are tracked via
adapter.SetConnectHandler. The mouse side needs no change: NewBLEMouse
shares the same *BLETxKeyboard, so it goes quiet and resumes together
with the keyboard.
Volume down/up become W+Z and E+Z (previously W+X and E+C), anchoring all
volume combos on Z next to the mute combo Q+Z.
Wire the keyboard and the ADC joystick mouse through
NewBLEKeyboard/NewBLEMouse so both switch between USB and BLE together
(the joystick previously drove mouse.Port() directly and would have stayed
USB-only). OverrideCtrlH moves from the old d.OverrideCtrlH() - which a
later d.Keyboard assignment would silently disable - to the
SwitchableKeyboard flag, and MuteBLEOnUSB is enabled. Combos on Esc select
the output (Q+Esc USB, W+Esc BLE) and unpair (@+Esc).

USBPULLUP is raised only after d.Init(): runtime init configures USB
before main-package init() can set usb.ProductID, so showing the device to
the host that early makes Windows cache the board-default VID/PID without
HID after a power cycle.

Add per-board LED definitions for xiao_ble and the xiao-ble smoketest
build.
@sago35 sago35 mentioned this pull request Jul 13, 2026
@sago35
sago35 marked this pull request as draft July 13, 2026 11:20
sago35 added 14 commits July 14, 2026 08:50
main.go imported device/nrf and called NewBLEKeyboard - which only exists
under the softdevice build tag - unconditionally, so the xiao and
xiao-rp2040 builds of make smoketest failed.

Introduce two per-target hooks, defined in each board file:
setupKeyboard() wires the output (USB/BLE switchable keyboard and mouse on
xiao_ble; plain USB with d.OverrideCtrlH() elsewhere) and enableUSB()
raises USBPULLUP after d.Init() on xiao_ble (no-op elsewhere). The
xiao_ble init() lowers USBPULLUP so the host never sees the device before
usb.ProductID and the HID descriptors are configured; enableUSB() is the
other half of that handshake. The joystick now drives d.Mouse, which
setupKeyboard points at the right output.
…tches

Releases that arrive while the 48ms combo window is open are buffered
in combosReleased. The matched branch discarded them, but those keys
were already removed from d.pressed when the release was buffered, so
their Up was never sent and the host kept them pressed forever (e.g. a
Ctrl released mid-window stayed held until the next Ctrl press+release).
Flush them into pressToRelease like the timeout branch does.
While the Ctrl+H override is active, the key actually held down on the
output is the override key (Backspace), not the Ctrl/H tracked in
pressed. SetOutput only released pressed and dropped the override list,
leaving Backspace stuck on the previous output after a switch.
The SoftDevice's per-connection HVN TX queue defaults to a depth of 1
and only drains as connection events pass (7.5-15ms with Windows), so
the second notification sent within one tick always fails. The
bluetooth fork used to retry inside Characteristic.Write; it now
returns ErrNotEnoughResources and expects the caller to retry, but
BLETxKeyboard ignored the error while its report state was already
updated, so the dropped state was never resent (notify only on change).
A chord released quickly (Up(A)+Up(Ctrl) in one tick) then left the
host with the modifier stuck until the next report; the Ctrl+H override
hit this on every press/release pair.

Each report is a full snapshot, so nothing needs queueing: on
ErrNotEnoughResources the send helpers mark the report pending and
Flush resends the then-current state. Flush is part of UpDowner
(Keyboard/UartTxKeyboard are no-ops) and Device.Tick calls it every
tick. SwitchableKeyboard flushes both outputs, not just the active one:
a report deferred on BLE still has to go out after switching to USB, or
the BLE host would be left holding a stale pressed state. Mouse
X/Y/wheel are relative rather than snapshots and accumulate in
pendingX/Y/Wheel until sent, so movement beyond the +-127 report range
now also spills into following ticks instead of being clamped away.
The BLE battery service is registered at a fixed 100% and only updates
when SetBatteryLevel is called; sg48key never called it, so a central
always saw 100%. Port the measurement from _x/sgkey-xiao-ble: once a
second, enable the voltage divider via P0.14 (output low only; disable
means going back to input/high-Z, since driving it high could push
P0.31 past the 3.6V absolute maximum), read P0.31 after a 10ms settle
and report BatteryPercent through SetBatteryLevel, which only notifies
the central when the percentage changes.

The measurement runs on the main loop through a third per-target hook,
tickBoard (no-op on xiao/xiao-rp2040), for two reasons: on nrf52,
machine.ADC.Configure writes the global SAADC registers rather than
per-Get settings, so the battery configuration (3.0V reference, 40us
acquisition for the 1M+510k divider, 8x oversampling) is applied right
before the read and reverted right after to keep the joystick reads on
A0/A1 at their defaults; and keeping everything on one goroutine
serializes all SAADC access, which a separate battery goroutine would
race.
Scanning every millisecond dominates battery consumption on a wireless
build, but slowing the loop down blindly breaks everything that counts
ticks or waits on them: the 8-scan debounce would take nine slow
periods to register the first key, flashCnt counts 5000 ticks to the
deferred Vial save, and the combo, tap-hold and wheel-repeat windows
and deferred BLE notifications all assume frequent ticks.

Device.Idle reports whether none of that is pending, so a main loop may
sleep longer between ticks as long as it returns to the fast cadence as
soon as Idle turns false. SquaredMatrixKeyboard.Active makes that safe
for the debounce: raw contact shows up in cycleCounter on the very
first scan, long before the state machine resolves, so one slow scan is
enough to switch back and let the debounce finish at the fast rate.
UpDowner gains Pending (same pattern as Flush; no-ops on outputs that
never defer) so a deferred report keeps the loop fast until it went
out.
The 500us scan loop wakeup (TinyGo scheduler plus SoftDevice SVC per
wakeup) dominates battery draw: measured about 2.6%/h of reported
battery per hour with the loop always fast. Sleep 30ms between scans
once the device has been idle for about a second, and return to 500us
the moment Device.Idle turns false. A slow scan that sees raw contact
flips it on that very scan, so the first key after an idle period is
delayed by at most ~35ms while the debounce still resolves at the fast
cadence; the joystick is read on every slow scan so moving it wakes the
loop too.

time.Sleep replaces the time.Tick ticker on purpose: a ticker keeps
waking the runtime at its period even while the receiver sleeps, which
would defeat the idle saving. The allowIdle per-target hook keeps
USB-powered boards at the fast cadence permanently, and the XIAO BLE
whenever VBUS is present; it is only consulted at (or entering) the
slow cadence so the SoftDevice call stays off the fast path.

Measured on battery with mixed daytime use: ~2.6%/h -> ~0.7-0.8%/h.
Side effect: the battery is measured every ~60s instead of every second
while idle, which is harmless.
On rare occasions (roughly hour scale here) the runtime never returns
from the SoftDevice's sd_app_evt_wait and every goroutine stops, while
SoftDevice interrupts keep the BLE connection alive - so the host keeps
repeating the last input report, e.g. a held space, and only a power
cycle recovers. The root cause sits below the sources we have (shelved
investigation; the rtc_sleep arming race in the local TinyGo checkout
is already patched), so settle for the same trade-off as the
_x/sgkey-xiao-ble test bed: a 5s watchdog, fed from tickBoard on every
scan, turns the hang into a few seconds' outage followed by an
automatic reconnect with the bond intact. Known limit: a Vial macro
with more than 5s of delays trips it, since RunMacro sleeps inside
Tick.

A watchdog reset also lights the red LED for the rest of the session
(DOG bit in RESETREAS, cleared after reading so the next reset can be
told apart), making hang frequency visible without instrumentation.
Verified live: a hang ~2h after power-on recovered on its own with the
LED lit.
Drive P0.13 low to select the XIAO BLE charger's high charge current
(100mA instead of the default 50mA).
Alongside BT_UNPR (unpair), add the keycodes for switching between BLE
bond profiles: BT_NEXT/BT_PREV cycle, BT_PRF1-5 select directly. Passed
through as-is by keycodeViaToTGK/keycodeTGKtoVia like the other QMK
connection keycodes, so they can also be assigned from Vial's Any key.
No behavior is wired up yet; that follows in the next commit.
Wire up BT_NEXT/BT_PREV/BT_PRF1-5 (see the previous commit) to the
bluetooth fork's new Adapter.SelectBondSlot: Device.Tick dispatches
them to an optional SelectProfile(int)/Profile() int interface on
d.Keyboard, the same optional-interface pattern already used for
Unpair and SetBatteryLevel. The branch has to sit above the
TypeMacroKey one, like the other QMK connection keycodes, since 0x779x
would otherwise also match TypeMacroKey and panic in RunMacro.

SwitchableKeyboard.SelectProfile forwards to BLE and remembers the
requested profile regardless of the active output, so Save()/Init()
can persist and restore it the same way they already do the selected
output (one more trailing byte in the keymap flash block; 0xFF from
older saves means "nothing saved", same convention as the output byte).

BLETxKeyboard.SelectProfile runs the actual switch on its own
goroutine rather than calling SelectBondSlot from Tick directly:
SelectBondSlot blocks until the old central's disconnect completes and
a flash page is rewritten, which can take up to a few seconds and would
otherwise stall the key scan (and trip the watchdog on targets that
have one). profileTarget/profileSwitching track the latest requested
profile and whether a worker is already running for it, so repeated
presses (e.g. cycling BT_NEXT) only ever act on the most recent request
instead of queuing every intermediate one.
Device.Init loads the keymap, macros and combos from flash whenever the
saved block's magic matches, overwriting what main.go defines. That
block is (re)written by Save, which runs not only after a Vial edit but
also when the runtime output (OU_*) or BLE profile (BT_PRF*) is switched,
since both persist their selection through the same block. So switching
output or profile snapshots the current keymap into flash, and from then
on every boot loads that snapshot and ignores later main.go changes -
only --tags reset_to_default (which erases the block) brought them back.

Gate the keymap/macros/combos load on a new keymapValid byte appended to
the block: Save sets it only when the keymap was actually customized
through Vial (tracked by Device.keymapCustomized, set at the three Vial
edit sites, not on output/profile switches). Init loads them only when
it is set, and marks keymapCustomized so a Vial keymap already in flash
keeps being persisted across later output/profile switches. The
output/profile bytes are still restored unconditionally, so their
persistence is unchanged.

Note: the flash block grew by one byte, so a block written by an older
build is not layout-compatible; run once with --tags reset_to_default
after flashing to start clean.
Reach it via KeyMod3 (replacing KeyTo1 in the base layer). It places
OU_USB/OU_BT, BT_UNPR and BT_PRF1-5 directly, as an alternative to the
Esc+ combos for switching BLE output and bond profile.
sago35 and others added 2 commits July 20, 2026 20:58
Unpair called RemoveBond synchronously, which had two problems: it
blocked the key scan for the duration of the flash erase, and it could
overtake a profile switch still in flight (SelectProfile is
asynchronous, waiting out a disconnect for up to a few seconds) and
erase the bond of the profile being switched away from - pressing
BT_PRF4 and then BT_UNPR could wipe profile 1's bond, leaving its
central in a connect/disconnect loop until re-paired.

Route unpair through the same worker goroutine as profile switching:
the worker settles the profile first and unpairs second, so BT_UNPR
after a profile key always unpairs the newly selected profile. With the
bluetooth fork now opening the pairing window automatically when an
empty slot is selected, registering a new central is just BT_PRFn plus
pairing from the host; BT_UNPR is only needed to free a bonded profile.

Co-Authored-By: Claude Fable 5 <[email protected]>
Pick up 9f140b9 (sd: don't let RemoveBond overtake an in-flight bond
slot switch) and a371b45 (sd: open the pairing window when an empty
bond slot is selected).

Co-Authored-By: Claude Fable 5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant