Add transport status events (#790)
* Transport status events Add symbol docs Emit transport status events Transport test suite * Review fixes * Remove core dependency * HTTP transport use AbortSignal, error handling in TransportNode * Improve stream handling * Update packages/transport-web-serial/src/transport.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix linting --------- Co-authored-by: philon- <philon-@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
philon-
parent
449fb3ac36
commit
d7e32e9b03
@@ -0,0 +1,166 @@
|
||||
import { describe, vi, expect, beforeEach, afterEach } from "vitest";
|
||||
import { runTransportContract } from "../../../tests/utils/transportContract";
|
||||
import { TransportWebBluetooth } from "./transport";
|
||||
|
||||
class MiniEmitter {
|
||||
private listeners = new Map<string, Set<(e: Event) => void>>();
|
||||
addEventListener(type: string, listener: (e: Event) => void) {
|
||||
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
||||
this.listeners.get(type)!.add(listener);
|
||||
}
|
||||
removeEventListener(type: string, listener: (e: Event) => void) {
|
||||
this.listeners.get(type)?.delete(listener);
|
||||
}
|
||||
dispatchEvent(event: Event) {
|
||||
this.listeners.get(event.type)?.forEach((l) => l(event));
|
||||
}
|
||||
}
|
||||
|
||||
function stubWebBluetooth() {
|
||||
const incomingQueue: Uint8Array[] = [];
|
||||
let lastWritten: Uint8Array | undefined;
|
||||
|
||||
// fromRadioCharacteristic: read bytes from queue, one buffer per read
|
||||
const fromRadioCharacteristic: BluetoothRemoteGATTCharacteristic = {
|
||||
async readValue() {
|
||||
const next = incomingQueue.shift() ?? new Uint8Array();
|
||||
return new DataView(
|
||||
next.buffer,
|
||||
next.byteOffset,
|
||||
next.byteLength,
|
||||
) as unknown as DataView;
|
||||
},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
} as unknown as BluetoothRemoteGATTCharacteristic;
|
||||
|
||||
// characteristicvaluechanged event plumbing (fromNumCharacteristic)
|
||||
const charEmitter = new MiniEmitter();
|
||||
|
||||
const fromNumCharacteristic: BluetoothRemoteGATTCharacteristic = {
|
||||
async startNotifications() {
|
||||
return this;
|
||||
},
|
||||
addEventListener(type: string, listener: (e: Event) => void) {
|
||||
charEmitter.addEventListener(type, listener);
|
||||
},
|
||||
removeEventListener(type: string, listener: (e: Event) => void) {
|
||||
charEmitter.removeEventListener(type, listener);
|
||||
},
|
||||
} as unknown as BluetoothRemoteGATTCharacteristic;
|
||||
|
||||
const toRadioCharacteristic: BluetoothRemoteGATTCharacteristic = {
|
||||
async writeValue(bufferSource: BufferSource) {
|
||||
const u8 =
|
||||
bufferSource instanceof ArrayBuffer
|
||||
? new Uint8Array(bufferSource)
|
||||
: new Uint8Array(
|
||||
bufferSource.buffer,
|
||||
bufferSource.byteOffset,
|
||||
bufferSource.byteLength,
|
||||
);
|
||||
lastWritten = new Uint8Array(u8);
|
||||
},
|
||||
} as unknown as BluetoothRemoteGATTCharacteristic;
|
||||
|
||||
// Primary service returns our three characteristics by UUID
|
||||
const primaryService: BluetoothRemoteGATTService = {
|
||||
async getCharacteristic(uuid: string) {
|
||||
if (uuid === TransportWebBluetooth.ToRadioUuid) return toRadioCharacteristic;
|
||||
if (uuid === TransportWebBluetooth.FromRadioUuid) return fromRadioCharacteristic;
|
||||
if (uuid === TransportWebBluetooth.FromNumUuid) return fromNumCharacteristic;
|
||||
throw new Error("Unknown characteristic: " + uuid);
|
||||
},
|
||||
} as unknown as BluetoothRemoteGATTService;
|
||||
|
||||
// Device-level emitter to deliver gattserverdisconnected
|
||||
const deviceEmitter = new MiniEmitter();
|
||||
|
||||
// GATT server with readonly connected
|
||||
let isConnected = true;
|
||||
const gattServer: BluetoothRemoteGATTServer = {
|
||||
get connected() {
|
||||
return isConnected;
|
||||
},
|
||||
async connect() {
|
||||
isConnected = true;
|
||||
return gattServer;
|
||||
},
|
||||
disconnect() {
|
||||
isConnected = false;
|
||||
deviceEmitter.dispatchEvent(new Event("gattserverdisconnected"));
|
||||
},
|
||||
async getPrimaryService() {
|
||||
return primaryService;
|
||||
},
|
||||
device: {
|
||||
addEventListener: (...args: Parameters<EventTarget["addEventListener"]>) =>
|
||||
deviceEmitter.addEventListener(args[0] as string, args[1] as (e: Event) => void),
|
||||
removeEventListener: (...args: Parameters<EventTarget["removeEventListener"]>) =>
|
||||
deviceEmitter.removeEventListener(args[0] as string, args[1] as (e: Event) => void),
|
||||
} as unknown as BluetoothDevice,
|
||||
} as unknown as BluetoothRemoteGATTServer;
|
||||
|
||||
const fakeDevice: BluetoothDevice = {
|
||||
async watchAdvertisements() {},
|
||||
gatt: gattServer,
|
||||
} as unknown as BluetoothDevice;
|
||||
|
||||
const fakeNavigator = {
|
||||
bluetooth: {
|
||||
async requestDevice() {
|
||||
return fakeDevice;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.stubGlobal("navigator", Object.assign({}, globalThis.navigator, fakeNavigator));
|
||||
|
||||
// helper actions for tests/contract
|
||||
return {
|
||||
pushIncoming: (u8: Uint8Array) => {
|
||||
incomingQueue.push(u8);
|
||||
charEmitter.dispatchEvent(new Event("characteristicvaluechanged"));
|
||||
},
|
||||
assertLastWritten: (u8: Uint8Array) => {
|
||||
expect(lastWritten).toBeDefined();
|
||||
expect(lastWritten).toEqual(u8);
|
||||
},
|
||||
// simulate underlying link drop (OS-level disconnect)
|
||||
triggerGattDisconnect: () => {
|
||||
isConnected = false;
|
||||
deviceEmitter.dispatchEvent(new Event("gattserverdisconnected"));
|
||||
},
|
||||
cleanup: () => {
|
||||
vi.unstubAllGlobals();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("TransportWebBluetooth (contract)", () => {
|
||||
runTransportContract({
|
||||
name: "TransportWebBluetooth",
|
||||
setup: () => {},
|
||||
teardown: () => {
|
||||
(globalThis as unknown as { __ble?: ReturnType<typeof stubWebBluetooth> }).__ble?.cleanup();
|
||||
(globalThis as unknown as { __ble?: ReturnType<typeof stubWebBluetooth> }).__ble = undefined;
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
},
|
||||
create: async () => {
|
||||
(globalThis as unknown as { __ble: ReturnType<typeof stubWebBluetooth> }).__ble = stubWebBluetooth();
|
||||
return await TransportWebBluetooth.create();
|
||||
},
|
||||
pushIncoming: async (bytes) => {
|
||||
(globalThis as unknown as { __ble: ReturnType<typeof stubWebBluetooth> }).__ble.pushIncoming(bytes);
|
||||
await Promise.resolve();
|
||||
},
|
||||
assertLastWritten: (bytes) => {
|
||||
(globalThis as unknown as { __ble: ReturnType<typeof stubWebBluetooth> }).__ble.assertLastWritten(bytes);
|
||||
},
|
||||
triggerDisconnect: async () => {
|
||||
(globalThis as unknown as { __ble: ReturnType<typeof stubWebBluetooth> }).__ble.triggerGattDisconnect();
|
||||
await Promise.resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,63 @@
|
||||
import type { Types } from "@meshtastic/core";
|
||||
import { Types } from "@meshtastic/core";
|
||||
|
||||
function toArrayBuffer(uint8array: Uint8Array): ArrayBuffer {
|
||||
if (
|
||||
uint8array.buffer instanceof ArrayBuffer &&
|
||||
uint8array.byteOffset === 0 &&
|
||||
uint8array.byteLength === uint8array.buffer.byteLength
|
||||
) {
|
||||
return uint8array.buffer;
|
||||
}
|
||||
return uint8array.slice().buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides Web Bluetooth transport for Meshtastic devices.
|
||||
*
|
||||
* Implements the {@link Types.Transport} contract using the Web Bluetooth API.
|
||||
* Use {@link TransportWebBluetooth.create} or {@link TransportWebBluetooth.createFromDevice}
|
||||
* to construct an instance.
|
||||
*/
|
||||
export class TransportWebBluetooth implements Types.Transport {
|
||||
private _toDevice: WritableStream<Uint8Array>;
|
||||
private _fromDevice: ReadableStream<Types.DeviceOutput>;
|
||||
private _fromDeviceController?: ReadableStreamDefaultController<Types.DeviceOutput>;
|
||||
private _isFirstWrite = true;
|
||||
private fromDeviceController?: ReadableStreamDefaultController<Types.DeviceOutput>;
|
||||
|
||||
private toRadioCharacteristic: BluetoothRemoteGATTCharacteristic;
|
||||
private fromRadioCharacteristic: BluetoothRemoteGATTCharacteristic;
|
||||
private fromNumCharacteristic: BluetoothRemoteGATTCharacteristic;
|
||||
private gattServer: BluetoothRemoteGATTServer;
|
||||
|
||||
private lastStatus: Types.DeviceStatusEnum =
|
||||
Types.DeviceStatusEnum.DeviceDisconnected;
|
||||
|
||||
private closingByUser = false;
|
||||
private reading = false;
|
||||
/** UUID for the "toRadio" write characteristic. */
|
||||
static ToRadioUuid = "f75c76d2-129e-4dad-a1dd-7866124401e7";
|
||||
/** UUID for the "fromRadio" read characteristic. */
|
||||
static FromRadioUuid = "2c55e69e-4993-11ed-b878-0242ac120002";
|
||||
/** UUID for the "fromNum" notification characteristic. */
|
||||
static FromNumUuid = "ed9da18c-a800-4f66-a670-aa7547e34453";
|
||||
/** UUID for the Meshtastic GATT service. */
|
||||
static ServiceUuid = "6ba1b218-15a8-461f-9fa8-5dcae273eafd";
|
||||
|
||||
private onGattDisconnected = () => {
|
||||
if (this.closingByUser) {
|
||||
return;
|
||||
}
|
||||
this.emitStatus(
|
||||
Types.DeviceStatusEnum.DeviceDisconnected,
|
||||
"gatt-disconnected",
|
||||
);
|
||||
};
|
||||
private onFromNumChanged = () => {
|
||||
void this.readFromRadio();
|
||||
};
|
||||
|
||||
/**
|
||||
* Prompts the user to select a Bluetooth device, connects it, and returns a transport.
|
||||
*/
|
||||
public static async create(): Promise<TransportWebBluetooth> {
|
||||
const device = await navigator.bluetooth.requestDevice({
|
||||
filters: [{ services: [TransportWebBluetooth.ServiceUuid] }],
|
||||
@@ -23,17 +65,25 @@ export class TransportWebBluetooth implements Types.Transport {
|
||||
return await TransportWebBluetooth.prepareConnection(device);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a transport from an existing, user-provided {@link BluetoothDevice}.
|
||||
*/
|
||||
public static async createFromDevice(
|
||||
device: BluetoothDevice,
|
||||
): Promise<TransportWebBluetooth> {
|
||||
return await TransportWebBluetooth.prepareConnection(device);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares and connects to a {@link BluetoothDevice}, resolving its GATT server
|
||||
* and characteristics, then returning a transport.
|
||||
*
|
||||
* @throws if required services or characteristics are missing.
|
||||
*/
|
||||
public static async prepareConnection(
|
||||
device: BluetoothDevice,
|
||||
): Promise<TransportWebBluetooth> {
|
||||
const gattServer = await device.gatt?.connect();
|
||||
|
||||
if (!gattServer) {
|
||||
throw new Error("Failed to connect to GATT server");
|
||||
}
|
||||
@@ -41,7 +91,6 @@ export class TransportWebBluetooth implements Types.Transport {
|
||||
const service = await gattServer.getPrimaryService(
|
||||
TransportWebBluetooth.ServiceUuid,
|
||||
);
|
||||
|
||||
const toRadioCharacteristic = await service.getCharacteristic(
|
||||
TransportWebBluetooth.ToRadioUuid,
|
||||
);
|
||||
@@ -60,8 +109,6 @@ export class TransportWebBluetooth implements Types.Transport {
|
||||
throw new Error("Failed to find required characteristics");
|
||||
}
|
||||
|
||||
console.log("Connected to device", device.name);
|
||||
|
||||
return new TransportWebBluetooth(
|
||||
toRadioCharacteristic,
|
||||
fromRadioCharacteristic,
|
||||
@@ -70,6 +117,10 @@ export class TransportWebBluetooth implements Types.Transport {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a transport from resolved GATT characteristics and server.
|
||||
* Prefer using the static factory methods instead.
|
||||
*/
|
||||
constructor(
|
||||
toRadioCharacteristic: BluetoothRemoteGATTCharacteristic,
|
||||
fromRadioCharacteristic: BluetoothRemoteGATTCharacteristic,
|
||||
@@ -81,66 +132,135 @@ export class TransportWebBluetooth implements Types.Transport {
|
||||
this.fromNumCharacteristic = fromNumCharacteristic;
|
||||
this.gattServer = gattServer;
|
||||
|
||||
this._fromDevice = new ReadableStream({
|
||||
start: (ctrl) => {
|
||||
this._fromDeviceController = ctrl;
|
||||
this._fromDevice = new ReadableStream<Types.DeviceOutput>({
|
||||
start: async (ctrl) => {
|
||||
this.fromDeviceController = ctrl;
|
||||
this.emitStatus(Types.DeviceStatusEnum.DeviceConnecting);
|
||||
|
||||
this.gattServer.device.addEventListener(
|
||||
"gattserverdisconnected",
|
||||
this.onGattDisconnected,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.fromNumCharacteristic.startNotifications();
|
||||
this.fromNumCharacteristic.addEventListener(
|
||||
"characteristicvaluechanged",
|
||||
this.onFromNumChanged,
|
||||
);
|
||||
this.emitStatus(Types.DeviceStatusEnum.DeviceConnected);
|
||||
// prime once in case data already queued
|
||||
void this.readFromRadio();
|
||||
} catch {
|
||||
this.emitStatus(
|
||||
Types.DeviceStatusEnum.DeviceDisconnected,
|
||||
"notify-failed",
|
||||
);
|
||||
this.gattServer.device.removeEventListener(
|
||||
"gattserverdisconnected",
|
||||
this.onGattDisconnected,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this._toDevice = new WritableStream({
|
||||
this._toDevice = new WritableStream<Uint8Array>({
|
||||
write: async (chunk) => {
|
||||
await this.toRadioCharacteristic.writeValue(chunk);
|
||||
|
||||
if (this._isFirstWrite && this._fromDeviceController) {
|
||||
this._isFirstWrite = false;
|
||||
|
||||
setTimeout(() => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: we know this will be set
|
||||
this.readFromRadio(this._fromDeviceController!);
|
||||
}, 50);
|
||||
try {
|
||||
const ab = toArrayBuffer(chunk);
|
||||
await this.toRadioCharacteristic.writeValue(ab);
|
||||
void this.readFromRadio(); // ensure we read any response
|
||||
} catch (error) {
|
||||
this.emitStatus(
|
||||
Types.DeviceStatusEnum.DeviceDisconnected,
|
||||
"write-error",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.fromNumCharacteristic.addEventListener(
|
||||
"characteristicvaluechanged",
|
||||
() => {
|
||||
if (this._fromDeviceController) {
|
||||
this.readFromRadio(this._fromDeviceController);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.fromNumCharacteristic.startNotifications();
|
||||
}
|
||||
|
||||
/** Writable stream of bytes to the device. */
|
||||
get toDevice(): WritableStream<Uint8Array> {
|
||||
return this._toDevice;
|
||||
}
|
||||
|
||||
/** Readable stream of {@link Types.DeviceOutput} from the device. */
|
||||
get fromDevice(): ReadableStream<Types.DeviceOutput> {
|
||||
return this._fromDevice;
|
||||
}
|
||||
|
||||
protected async readFromRadio(
|
||||
controller: ReadableStreamDefaultController<Types.DeviceOutput>,
|
||||
): Promise<void> {
|
||||
let hasMoreData = true;
|
||||
while (hasMoreData && this.fromRadioCharacteristic) {
|
||||
const value = await this.fromRadioCharacteristic.readValue();
|
||||
if (value.byteLength === 0) {
|
||||
hasMoreData = false;
|
||||
continue;
|
||||
/**
|
||||
* Closes the GATT connection and emits `DeviceDisconnected("user")`.
|
||||
*/
|
||||
disconnect(): Promise<void> {
|
||||
try {
|
||||
this.closingByUser = true;
|
||||
this.emitStatus(Types.DeviceStatusEnum.DeviceDisconnected, "user");
|
||||
try {
|
||||
this.fromNumCharacteristic.stopNotifications?.();
|
||||
} catch {}
|
||||
this.fromNumCharacteristic.removeEventListener(
|
||||
"characteristicvaluechanged",
|
||||
this.onFromNumChanged,
|
||||
);
|
||||
this.gattServer.device.removeEventListener(
|
||||
"gattserverdisconnected",
|
||||
this.onGattDisconnected,
|
||||
);
|
||||
|
||||
this.gattServer.disconnect();
|
||||
} finally {
|
||||
this.closingByUser = false;
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
private async readFromRadio(): Promise<void> {
|
||||
if (this.reading) {
|
||||
return;
|
||||
}
|
||||
this.reading = true;
|
||||
|
||||
try {
|
||||
let hasMoreData = true;
|
||||
while (hasMoreData && this.fromRadioCharacteristic) {
|
||||
const value = await this.fromRadioCharacteristic.readValue();
|
||||
if (value.byteLength === 0) {
|
||||
hasMoreData = false;
|
||||
continue;
|
||||
}
|
||||
this.enqueue({
|
||||
type: "packet",
|
||||
data: new Uint8Array(value.buffer),
|
||||
});
|
||||
}
|
||||
controller.enqueue({
|
||||
type: "packet",
|
||||
data: new Uint8Array(value.buffer),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!this.closingByUser) {
|
||||
this.emitStatus(
|
||||
Types.DeviceStatusEnum.DeviceDisconnected,
|
||||
"read-error",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
this.reading = false;
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): Promise<void> {
|
||||
this.gattServer.disconnect();
|
||||
return Promise.resolve();
|
||||
private emitStatus(next: Types.DeviceStatusEnum, reason?: string): void {
|
||||
if (next === this.lastStatus) {
|
||||
return;
|
||||
}
|
||||
this.lastStatus = next;
|
||||
this.fromDeviceController?.enqueue({
|
||||
type: "status",
|
||||
data: { status: next, reason },
|
||||
});
|
||||
}
|
||||
|
||||
private enqueue(output: Types.DeviceOutput): void {
|
||||
this.fromDeviceController?.enqueue(output);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user