From ec5d3407ab0b4c4d9bd55f3812ea23b1b4764d48 Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Wed, 20 May 2026 16:24:18 +0200 Subject: [PATCH] web-bluetooth: add requestAny + fromDevice for autodetect callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `request(config)` is now factored as `requestAny([config])` over the picker call, plus `fromDevice(device, config)` for the GATT back half. Lets a caller open the picker with a union filter across multiple device configs, then wrap the chosen device with the right config after identifying it — without re-prompting the user. `requestAny` builds the picker filter as a union of every config's {namePrefix, services} pair plus a name-only fallback per namePrefixed config, and unions `optionalServices` so post-pair `getPrimaryService(...)` resolves regardless of which config the autodetect lands on. Used by `@thermal-label/niimbot-web`'s `requestPrinters` to open the BLE picker with every registered niimbot chassis at once, identify via advertised name (`findDeviceByBleName`) or in-protocol probe (`identifyNiimbot`), and wrap the result. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/web-bluetooth.test.ts | 63 +++++++++++++++++ src/web/web-bluetooth.ts | 105 ++++++++++++++++++++++------ 2 files changed, 148 insertions(+), 20 deletions(-) diff --git a/src/__tests__/web-bluetooth.test.ts b/src/__tests__/web-bluetooth.test.ts index e95c90e..0a75d71 100644 --- a/src/__tests__/web-bluetooth.test.ts +++ b/src/__tests__/web-bluetooth.test.ts @@ -322,4 +322,67 @@ describe('WebBluetoothTransport', () => { const result = await transport.read(1); expect(Array.from(result)).toEqual([0xaa]); }); + + it('requestAny() unions filters across configs and unions optionalServices', async () => { + const device = makeDevice(); + const requestDevice = vi.fn().mockResolvedValue(device); + vi.stubGlobal('navigator', { bluetooth: { requestDevice } }); + + const SERVICE_A = '0000aaaa-0000-1000-8000-00805f9b34fb'; + const SERVICE_B = '0000bbbb-0000-1000-8000-00805f9b34fb'; + const configs: BluetoothGattTransport[] = [ + { serviceUuid: SERVICE_A, txCharacteristicUuid: TX_UUID, namePrefix: 'B1-' }, + { serviceUuid: SERVICE_A, txCharacteristicUuid: TX_UUID, namePrefix: 'D110_M-' }, + { serviceUuid: SERVICE_B, txCharacteristicUuid: TX_UUID }, // no namePrefix + ]; + const picked = await WebBluetoothTransport.requestAny(configs); + expect(picked).toBe(device); + // Each name-prefixed config emits two filters (strict + name-only); + // the unprefixed one emits a single service-only filter. + expect(requestDevice).toHaveBeenCalledWith({ + filters: [ + { namePrefix: 'B1-', services: [SERVICE_A] }, + { namePrefix: 'B1-' }, + { namePrefix: 'D110_M-', services: [SERVICE_A] }, + { namePrefix: 'D110_M-' }, + { services: [SERVICE_B] }, + ], + // De-duplicated; preserves first-seen order. + optionalServices: [SERVICE_A, SERVICE_B], + }); + }); + + it('requestAny() rejects when called with no configs', async () => { + vi.stubGlobal('navigator', { bluetooth: { requestDevice: vi.fn() } }); + await expect(WebBluetoothTransport.requestAny([])).rejects.toThrow(/no configs/); + }); + + it('fromDevice() wraps a pre-paired device without opening the picker', async () => { + const tx = makeCharacteristic(); + const rx = makeCharacteristic(); + const device = makeDevice(); + const service = { + getCharacteristic: vi.fn((uuid: string) => { + if (uuid === TX_UUID) return Promise.resolve(tx); + if (uuid === RX_UUID) return Promise.resolve(rx); + return Promise.reject(new Error(`unknown char ${uuid}`)); + }), + }; + const server = { getPrimaryService: vi.fn(() => Promise.resolve(service)) }; + device.gatt.connect.mockResolvedValue(server); + const requestDevice = vi.fn(); + vi.stubGlobal('navigator', { bluetooth: { requestDevice } }); + + const transport = await WebBluetoothTransport.fromDevice(device as unknown as BluetoothDevice, { + serviceUuid: SERVICE_UUID, + txCharacteristicUuid: TX_UUID, + rxCharacteristicUuid: RX_UUID, + }); + expect(requestDevice).not.toHaveBeenCalled(); + expect(rx.startNotifications).toHaveBeenCalledOnce(); + // Sanity-check the wrapped transport reads from the rx characteristic. + rx.fireValue([0x42]); + const read = await transport.read(1); + expect(Array.from(read)).toEqual([0x42]); + }); }); diff --git a/src/web/web-bluetooth.ts b/src/web/web-bluetooth.ts index f2d12d2..27011e6 100644 --- a/src/web/web-bluetooth.ts +++ b/src/web/web-bluetooth.ts @@ -119,29 +119,59 @@ export class WebBluetoothTransport implements Transport { * is used for both directions (DECISIONS.md D6). */ static async request(config: BluetoothGattTransport): Promise { - // Web Bluetooth filters check the device's *advertisement*, not its - // GATT table. Some chassis (e.g. Niimbot B1, 2024+ firmware) host - // the driver's primary service in GATT but only advertise a generic - // BLE-UART service (MCHP 49535343-…) instead. With a service-only - // filter the picker would never see them. - // - // OR-fallback: when `namePrefix` is set, accept name-only matches - // alongside the strict name+service match. The service is kept in - // `optionalServices` so we can still resolve it post-pair via - // `getPrimaryService(config.serviceUuid)`. The filters array is an - // OR; the strict filter is listed first so matching devices show - // higher in the picker on browsers that preserve filter order. - const filters: BluetoothLEScanFilter[] = - config.namePrefix === undefined - ? [{ services: [config.serviceUuid] }] - : [ - { namePrefix: config.namePrefix, services: [config.serviceUuid] }, - { namePrefix: config.namePrefix }, - ]; const device = await navigator.bluetooth.requestDevice({ - filters, + filters: buildFilters([config]), optionalServices: [config.serviceUuid], }); + return WebBluetoothTransport.fromDevice(device, config); + } + + /** + * Open the picker with a *union* of multiple device configs — used + * by transport-agnostic autodetect when the caller hasn't picked a + * device key yet. The picker filters in any chassis whose + * `namePrefix` / `serviceUuid` matches one of the configs; the + * caller then identifies the chosen device (e.g. via + * `identifyNiimbot`) before wrapping it in a transport. + * + * Returns the raw `BluetoothDevice` so the caller can both inspect + * `.name` (the advertised name) and pair it with the right config + * before calling `fromDevice()`. The GATT connection is not opened + * here — that's the next step, gated on which config the autodetect + * resolves to. + * + * `optionalServices` unions every config's service UUID so + * `getPrimaryService(...)` works after pairing regardless of which + * config the autodetect picks. + */ + static async requestAny( + configs: readonly BluetoothGattTransport[], + ): Promise { + if (configs.length === 0) { + throw new Error('WebBluetoothTransport.requestAny: no configs supplied'); + } + const uniqueServices = Array.from(new Set(configs.map(c => c.serviceUuid))); + return navigator.bluetooth.requestDevice({ + filters: buildFilters(configs), + optionalServices: uniqueServices, + }); + } + + /** + * Wrap a `BluetoothDevice` that the caller has already paired with + * (typically via `requestAny` followed by autodetect). Connects + * GATT, resolves TX / RX from the supplied `config`, and starts RX + * notifications. Skips re-opening the picker — the device stays + * the one the user already chose. + * + * Idempotent in the sense that `device.gatt.connect()` is a no-op + * when the GATT server is already connected; safe to call after + * `requestAny` even if the browser eagerly connected. + */ + static async fromDevice( + device: BluetoothDevice, + config: BluetoothGattTransport, + ): Promise { if (!device.gatt) throw new Error('Selected Bluetooth device has no GATT server'); const server = await device.gatt.connect(); const service = await server.getPrimaryService(config.serviceUuid); @@ -220,3 +250,38 @@ export class WebBluetoothTransport implements Transport { waiter.resolve(this.drainBuffer(waiter.needed)); } } + +/** + * Build the browser-picker filter array from one or more + * `BluetoothGattTransport` configs. + * + * Web Bluetooth filters check the device's *advertisement*, not its + * GATT table. Some chassis (e.g. Niimbot B1, 2024+ firmware) host + * the driver's primary service in GATT but only advertise a generic + * BLE-UART service (MCHP 49535343-…) instead. With a service-only + * filter the picker would never see them. + * + * OR-fallback: when `namePrefix` is set on a config, emit both a + * strict `{ namePrefix, services }` filter and a name-only one. The + * picker treats the filter array as an OR; strict filters come + * first so service-advertising chassis rank higher on browsers that + * preserve filter order. + * + * Multi-config callers (`requestAny`) concatenate per-config filters + * — the picker shows every chassis matching any of the configs, + * which is exactly the discovery surface autodetect wants. + */ +function buildFilters( + configs: readonly BluetoothGattTransport[], +): BluetoothLEScanFilter[] { + const out: BluetoothLEScanFilter[] = []; + for (const config of configs) { + if (config.namePrefix === undefined) { + out.push({ services: [config.serviceUuid] }); + } else { + out.push({ namePrefix: config.namePrefix, services: [config.serviceUuid] }); + out.push({ namePrefix: config.namePrefix }); + } + } + return out; +}