Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/__tests__/web-bluetooth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
});
105 changes: 85 additions & 20 deletions src/web/web-bluetooth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,29 +119,59 @@ export class WebBluetoothTransport implements Transport {
* is used for both directions (DECISIONS.md D6).
*/
static async request(config: BluetoothGattTransport): Promise<WebBluetoothTransport> {
// 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<BluetoothDevice> {
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<WebBluetoothTransport> {
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);
Expand Down Expand Up @@ -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;
}
Loading