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,225 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { TransportWebSerial } from "./transport";
|
||||
import { Types, Utils } from "@meshtastic/core";
|
||||
import { runTransportContract } from "../../../tests/utils/transportContract";
|
||||
|
||||
function stubCoreTransforms() {
|
||||
const toDevice = new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
// maps raw bytes -> DeviceOutput.packet
|
||||
const fromDeviceFactory = () =>
|
||||
new TransformStream<Uint8Array, Types.DeviceOutput>({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue({ type: "packet", data: chunk });
|
||||
},
|
||||
});
|
||||
|
||||
const restoreTo = vi
|
||||
.spyOn(Utils, "toDeviceStream", "get")
|
||||
.mockReturnValue(toDevice as unknown as typeof Utils.toDeviceStream);
|
||||
|
||||
const restoreFrom = vi
|
||||
.spyOn(Utils, "fromDeviceStream")
|
||||
.mockImplementation(
|
||||
() =>
|
||||
fromDeviceFactory() as unknown as TransformStream<
|
||||
Uint8Array,
|
||||
Types.DeviceOutput
|
||||
>,
|
||||
);
|
||||
|
||||
return {
|
||||
restore: () => {
|
||||
restoreTo.mockRestore();
|
||||
restoreFrom.mockRestore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stubNavigatorSerial() {
|
||||
type SerialDisconnectHandler = (ev: { port?: any }) => void;
|
||||
const handlers = new Set<SerialDisconnectHandler>();
|
||||
|
||||
const serialStub = {
|
||||
addEventListener: (type: string, handler: EventListenerOrEventListenerObject) => {
|
||||
if (type === "disconnect") handlers.add(handler as any as SerialDisconnectHandler);
|
||||
},
|
||||
removeEventListener: (type: string, handler: EventListenerOrEventListenerObject) => {
|
||||
if (type === "disconnect") handlers.delete(handler as any as SerialDisconnectHandler);
|
||||
},
|
||||
dispatchDisconnect(port: any) {
|
||||
for (const h of handlers) h({ port });
|
||||
},
|
||||
requestPort: vi.fn(async () => new FakeSerialPort()),
|
||||
};
|
||||
|
||||
const nav: any = (globalThis as any).navigator ?? {};
|
||||
const hadNavigator = !!(globalThis as any).navigator;
|
||||
const originalSerial = nav.serial;
|
||||
|
||||
if (!hadNavigator) {
|
||||
Object.defineProperty(globalThis as any, "navigator", {
|
||||
value: nav,
|
||||
configurable: true,
|
||||
writable: false,
|
||||
});
|
||||
}
|
||||
|
||||
Object.defineProperty(nav, "serial", {
|
||||
value: serialStub,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
return {
|
||||
serialStub,
|
||||
restore: () => {
|
||||
if (hadNavigator) {
|
||||
if (originalSerial === undefined) {
|
||||
delete (globalThis as any).navigator.serial;
|
||||
} else {
|
||||
Object.defineProperty((globalThis as any).navigator, "serial", {
|
||||
value: originalSerial,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
delete (globalThis as any).navigator;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeSerialPort {
|
||||
readable: ReadableStream<Uint8Array>;
|
||||
writable: WritableStream<Uint8Array>;
|
||||
lastWritten?: Uint8Array;
|
||||
|
||||
private _readController!: ReadableStreamDefaultController<Uint8Array>;
|
||||
|
||||
constructor() {
|
||||
this.readable = new ReadableStream<Uint8Array>({
|
||||
start: (controller) => {
|
||||
this._readController = controller;
|
||||
},
|
||||
});
|
||||
|
||||
this.writable = new WritableStream<Uint8Array>({
|
||||
write: async (chunk) => {
|
||||
this.lastWritten = chunk;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
open(_options?: { baudRate?: number }): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
try {
|
||||
this._readController.close();
|
||||
} catch {}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
pushIncoming(bytes: Uint8Array) {
|
||||
this._readController.enqueue(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
describe("TransportWebSerial (contract)", () => {
|
||||
let transforms: { restore(): void } | undefined;
|
||||
let navSerial: { serialStub: any; restore(): void } | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
transforms = stubCoreTransforms();
|
||||
navSerial = stubNavigatorSerial();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
transforms?.restore();
|
||||
navSerial?.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
runTransportContract({
|
||||
name: "TransportWebSerial",
|
||||
setup: () => {},
|
||||
teardown: () => {},
|
||||
create: async () => {
|
||||
const fake = new FakeSerialPort();
|
||||
const transport = await TransportWebSerial.createFromPort(fake as any);
|
||||
(globalThis as any).__ws = { fake, serial: navSerial!.serialStub };
|
||||
await Promise.resolve();
|
||||
return transport;
|
||||
},
|
||||
pushIncoming: async (bytes) => {
|
||||
(globalThis as any).__ws.fake.pushIncoming(bytes);
|
||||
await Promise.resolve();
|
||||
},
|
||||
assertLastWritten: (bytes) => {
|
||||
expect((globalThis as any).__ws.fake.lastWritten).toEqual(bytes);
|
||||
},
|
||||
triggerDisconnect: async () => {
|
||||
(globalThis as any).__ws.serial.dispatchDisconnect(
|
||||
(globalThis as any).__ws.fake,
|
||||
);
|
||||
await Promise.resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("TransportWebSerial (extras)", () => {
|
||||
let transforms: { restore(): void } | undefined;
|
||||
let navSerial: { serialStub: any; restore(): void } | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
transforms = stubCoreTransforms();
|
||||
navSerial = stubNavigatorSerial();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
transforms?.restore();
|
||||
navSerial?.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("emits DeviceDisconnected('serial-disconnected') on OS disconnect event", async () => {
|
||||
const fake = new FakeSerialPort();
|
||||
const transport = await TransportWebSerial.createFromPort(fake as any);
|
||||
(globalThis as any).__ws = { fake, serial: navSerial!.serialStub };
|
||||
|
||||
const reader = transport.fromDevice.getReader();
|
||||
|
||||
// drain statuses until connected
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const { value } = await reader.read();
|
||||
if (!value || value.type !== "status") break;
|
||||
if (value.data.status === Types.DeviceStatusEnum.DeviceConnected) break;
|
||||
}
|
||||
|
||||
// fire OS-level disconnect
|
||||
navSerial!.serialStub.dispatchDisconnect(fake as any);
|
||||
await Promise.resolve();
|
||||
|
||||
let saw = false;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const { value } = await reader.read();
|
||||
if (value?.type === "status" && value.data.reason === "serial-disconnected") {
|
||||
saw = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(saw).toBe(true);
|
||||
|
||||
reader.releaseLock();
|
||||
await transport.disconnect();
|
||||
});
|
||||
});
|
||||
@@ -1,72 +1,165 @@
|
||||
import type { Types } from "@meshtastic/core";
|
||||
import { Utils } from "@meshtastic/core";
|
||||
import { Types, Utils } from "@meshtastic/core";
|
||||
|
||||
/**
|
||||
* Provides Web Serial transport for Meshtastic devices.
|
||||
*
|
||||
* Implements the {@link Types.Transport} contract using the Web Serial API.
|
||||
* Use {@link TransportWebSerial.create} or {@link TransportWebSerial.createFromPort}
|
||||
* to construct an instance.
|
||||
*/
|
||||
export class TransportWebSerial implements Types.Transport {
|
||||
private _toDevice: WritableStream<Uint8Array>;
|
||||
private _fromDevice: ReadableStream<Types.DeviceOutput>;
|
||||
private fromDeviceController?: ReadableStreamDefaultController<Types.DeviceOutput>;
|
||||
private connection: SerialPort;
|
||||
private pipePromise: Promise<void> | null = null;
|
||||
private abortController: AbortController;
|
||||
private portReadable: ReadableStream<Uint8Array>;
|
||||
|
||||
private lastStatus: Types.DeviceStatusEnum =
|
||||
Types.DeviceStatusEnum.DeviceDisconnected;
|
||||
private closingByUser = false;
|
||||
|
||||
/**
|
||||
* Create a new TransportWebSerial instance using a serial port.
|
||||
*/
|
||||
public static async create(baudRate?: number): Promise<TransportWebSerial> {
|
||||
const port = await navigator.serial.requestPort();
|
||||
await port.open({ baudRate: baudRate || 115200 });
|
||||
return new TransportWebSerial(port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new TransportWebSerial instance from an existing, provided {@link SerialPort}.
|
||||
* Opens it if not already open.
|
||||
*/
|
||||
public static async createFromPort(
|
||||
port: SerialPort,
|
||||
baudRate?: number,
|
||||
): Promise<TransportWebSerial> {
|
||||
await port.open({ baudRate: baudRate || 115200 });
|
||||
if (!port.readable || !port.writable) {
|
||||
await port.open({ baudRate: baudRate || 115200 });
|
||||
}
|
||||
return new TransportWebSerial(port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a transport around a given {@link SerialPort}.
|
||||
* @throws If the port lacks readable or writable streams.
|
||||
*/
|
||||
constructor(connection: SerialPort) {
|
||||
if (!connection.readable || !connection.writable) {
|
||||
throw new Error("Stream not accessible");
|
||||
}
|
||||
|
||||
this.connection = connection;
|
||||
this.portReadable = connection.readable;
|
||||
this.abortController = new AbortController();
|
||||
const abortController = this.abortController;
|
||||
|
||||
// Set up the pipe with abort signal for clean cancellation
|
||||
this.pipePromise = Utils.toDeviceStream.readable.pipeTo(
|
||||
connection.writable,
|
||||
{ signal: this.abortController.signal },
|
||||
);
|
||||
this.pipePromise = Utils.toDeviceStream.readable
|
||||
.pipeTo(connection.writable, { signal: this.abortController.signal })
|
||||
.catch((err) => {
|
||||
// Ignore expected rejection when we cancel it via the AbortController.
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
console.error("Error piping data to serial port:", err);
|
||||
this.connection.close().catch(() => {});
|
||||
this.emitStatus(
|
||||
Types.DeviceStatusEnum.DeviceDisconnected,
|
||||
"write-error",
|
||||
);
|
||||
});
|
||||
|
||||
this._toDevice = Utils.toDeviceStream.writable;
|
||||
this._fromDevice = connection.readable.pipeThrough(
|
||||
Utils.fromDeviceStream(),
|
||||
);
|
||||
|
||||
// Wrap + capture controller to inject status packets
|
||||
this._fromDevice = new ReadableStream<Types.DeviceOutput>({
|
||||
start: async (ctrl) => {
|
||||
this.fromDeviceController = ctrl;
|
||||
|
||||
this.emitStatus(Types.DeviceStatusEnum.DeviceConnecting);
|
||||
|
||||
const transformed = this.portReadable.pipeThrough(
|
||||
Utils.fromDeviceStream(),
|
||||
);
|
||||
const reader = transformed.getReader();
|
||||
|
||||
const onOsDisconnect = (ev: Event) => {
|
||||
const { port } = ev as unknown as { port?: SerialPort };
|
||||
if (port && port === this.connection) {
|
||||
this.emitStatus(
|
||||
Types.DeviceStatusEnum.DeviceDisconnected,
|
||||
"serial-disconnected",
|
||||
);
|
||||
}
|
||||
};
|
||||
navigator.serial.addEventListener("disconnect", onOsDisconnect);
|
||||
|
||||
this.emitStatus(Types.DeviceStatusEnum.DeviceConnected);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
ctrl.enqueue(value);
|
||||
}
|
||||
ctrl.close();
|
||||
} catch (error) {
|
||||
if (!this.closingByUser) {
|
||||
this.emitStatus(
|
||||
Types.DeviceStatusEnum.DeviceDisconnected,
|
||||
"read-error",
|
||||
);
|
||||
}
|
||||
ctrl.error(error instanceof Error ? error : new Error(String(error)));
|
||||
try {
|
||||
await transformed.cancel();
|
||||
} catch {}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
navigator.serial.removeEventListener("disconnect", onOsDisconnect);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get toDevice(): WritableStream<Uint8Array> {
|
||||
/** Writable stream of bytes to the device. */
|
||||
public get toDevice(): WritableStream<Uint8Array> {
|
||||
return this._toDevice;
|
||||
}
|
||||
|
||||
get fromDevice(): ReadableStream<Types.DeviceOutput> {
|
||||
/** Readable stream of {@link Types.DeviceOutput} from the device. */
|
||||
public get fromDevice(): ReadableStream<Types.DeviceOutput> {
|
||||
return this._fromDevice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely disconnects the serial port, following best practices from
|
||||
* https://github.com/WICG/serial/. Cancels any active pipe
|
||||
* operations and only closes the port after streams are unlocked.
|
||||
*/
|
||||
async disconnect() {
|
||||
try {
|
||||
this.abortController.abort();
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the serial port and emits `DeviceDisconnected("user")`.
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
try {
|
||||
this.closingByUser = true;
|
||||
|
||||
// Stop outbound piping
|
||||
this.abortController.abort();
|
||||
if (this.pipePromise) {
|
||||
try {
|
||||
await this.pipePromise;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name !== "AbortError") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await this.pipePromise;
|
||||
}
|
||||
|
||||
// Cancel any remaining streams
|
||||
@@ -82,6 +175,9 @@ export class TransportWebSerial implements Types.Transport {
|
||||
} catch (error) {
|
||||
// If we can't close cleanly, let the browser handle cleanup
|
||||
console.warn("Could not cleanly disconnect serial port:", error);
|
||||
} finally {
|
||||
this.emitStatus(Types.DeviceStatusEnum.DeviceDisconnected, "user");
|
||||
this.closingByUser = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,14 +185,43 @@ export class TransportWebSerial implements Types.Transport {
|
||||
* Reconnects the transport by creating a new AbortController and re-establishing
|
||||
* the pipe connection. Only call this after disconnect() or if the connection failed.
|
||||
*/
|
||||
async reconnect() {
|
||||
// Create a new AbortController for the new connection
|
||||
this.abortController = new AbortController();
|
||||
public async reconnect() {
|
||||
this.emitStatus(Types.DeviceStatusEnum.DeviceConnecting, "reconnect");
|
||||
|
||||
// Re-establish the pipe connection
|
||||
this.pipePromise = Utils.toDeviceStream.readable.pipeTo(
|
||||
this.connection.writable,
|
||||
{ signal: this.abortController.signal },
|
||||
);
|
||||
try {
|
||||
if (!this.connection.readable || !this.connection.writable) {
|
||||
throw new Error("Stream not accessible");
|
||||
}
|
||||
this.portReadable = this.connection.readable;
|
||||
|
||||
// Create a new AbortController for the new connection
|
||||
this.abortController = new AbortController();
|
||||
const abortController = this.abortController;
|
||||
|
||||
// Re-establish the pipe connection
|
||||
this.pipePromise = Utils.toDeviceStream.readable
|
||||
.pipeTo(this.connection.writable, {
|
||||
signal: this.abortController.signal,
|
||||
})
|
||||
.catch((error) => {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
console.error("Error piping data to serial port (reconnect):", error);
|
||||
this.emitStatus(
|
||||
Types.DeviceStatusEnum.DeviceDisconnected,
|
||||
"write-error",
|
||||
);
|
||||
});
|
||||
|
||||
this.emitStatus(Types.DeviceStatusEnum.DeviceConnected, "reconnected");
|
||||
} catch (error) {
|
||||
// Couldn’t re-pipe
|
||||
this.emitStatus(
|
||||
Types.DeviceStatusEnum.DeviceDisconnected,
|
||||
"reconnect-failed",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user