Back to logs
2026-03-18 bleuiuxfeature

BLE UX polish, starter profiles, and live role reassignment

Today was a housekeeping-and-polish session rather than an epic-level feature push. Several small but noticeable rough edges in the device-management flow got addressed, a quality-of-life improvement was shipped for connected device handling, and the reference docs got a long-overdue rename pass.

BLE scan UX: cached devices, stale RSSI, and the "Paired" state

The scan list was showing stale data in a few scenarios that were easy to reproduce but annoying to diagnose. Three distinct device states needed to be tracked and displayed differently:

  • Seen in scan: the device was actively advertising during the scan window. Shows a signal-strength badge (Strong / Good / Fair / Weak) and the Connect button is enabled.
  • Paired: the device has an active BLE connection to the OS (BlueZ, Windows Bluetooth stack, etc.). The OS clears RSSI for connected peripherals and they stop advertising, so no signal reading is available. These show a "Paired" badge and Connect remains enabled so the user can take over the connection from the OS.
  • Cached: the device is in the OS Bluetooth cache from a previous scan but was not seen this time. It is likely off or out of range. Shows a greyed-out "Cached" badge and Connect is disabled.

The Linux path had an extra wrinkle. After a device is connected and then disconnected, BlueZ clears its RSSI entry. On the next scan the device's first PropertiesChanged signal can arrive after the scan event-loop deadline, leaving the RSSI map empty even though the device is genuinely present. The fix is a pre-scan RSSI snapshot taken via get_devices() before start_discovery. If RSSI was absent pre-scan (cleared by the prior connection) and is present post-scan, the device counts as seen.

Two new fields were added to TapDeviceInfo (seen_in_scan and is_connected_to_os) and propagated through the DTO, TypeScript types, and Svelte UI.

Connected device name display

Previously the connected-devices panel showed only the role and address. The human-readable device name (e.g. "TapXR_A0363") was lost the moment you clicked Connect.

The fix has two parts. First, the name is captured from the scan result before the await connectDevice(...) call, not after. After the await, the Tauri device-connected event has already fired, which reactively removes the device from availableDevices (the filtered scan list). Looking up the name after the await therefore finds nothing.

Second, names are persisted to localStorage under mapxr:device-names as an address → name map. On app restart, the auto-reconnect loop fires device-connected events with no UI interaction, so setName is never called. Loading from storage on DeviceStore construction means reconnect events immediately resolve the correct name.

Starter profile seeding on first launch

New installations had no profiles and no obvious way to get started. A starter-right.json profile with 15 right-hand single-finger mappings (copy, paste, undo, save, arrow navigation, etc.) is now embedded in the binary via include_str! and seeded into the OS config directory on first launch.

Seeding is a no-op if any .json file already exists in the config dir, so existing users and developers with their own profiles are unaffected.

Reference docs reorganisation

The docs/reference/ directory had accumulated six files with names that didn't reflect their contents. Most notably, other-uuids.txt (which is actually the best annotated GATT characteristic map in the project) got renamed to gatt-characteristics.txt. The full rename list:

  • api-doc.txttap-ble-api.txt
  • other-uuids.txtgatt-characteristics.txt
  • gatt-output.txtgatt-probe-output.txt
  • windows-sdk-guid-reference.txtwindows-sdk-guids.cs (it's C# source; now syntax-highlighted in editors)
  • gettingfirmware.txtfirmware-update-aws.txt

Live role reassignment without disconnecting

The most substantial change today: you can now change a connected device's role (solo → left, left → right, etc.) without disconnecting and reconnecting. Previously this was the only way to reassign, which meant exiting controller mode, dropping the BLE connection, waiting for the OS to release the connection slot, scanning again, and reconnecting.

The key insight is that DeviceId (the role) is just a label. It gets stamped onto RawTapEvents and onto BleStatusEvent notifications, but the underlying BLE connection, GATT characteristic handles, controller mode, and notification subscription are all properties of the btleplug Peripheral object and don't care about the role at all.

The complication is that the role is captured by value in three background tasks spawned at connect time: keepalive_task, notification_task, and connection_monitor_task. A new TapDevice::reassign() method handles this by cancelling the existing tasks via the watch channel, writing ENTER_CONTROLLER_MODE immediately to reset the device's 10-second keepalive timer, then respawning all three tasks with the new DeviceId.

BleManager::reassign_role() moves the entry in the connected map, calls reassign(), then emits BleStatusEvent::Disconnected (old role) followed by BleStatusEvent::Connected (new role). The existing Tauri event pump picks these up and pushes device-disconnected / device-connected events to the frontend. No new plumbing required.

On the UI side, the connected-devices table now shows solo / left / right role buttons inline on each row. A role button is disabled if it is the device's current role, or if that role is already occupied by another connected device. This prevents conflicts entirely in the UI rather than returning an error from the backend.