Conversation
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.
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.
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]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.