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:
Jeremy Gallant
2025-08-23 20:44:03 -04:00
committed by GitHub
co-authored by Copilot philon-
parent 449fb3ac36
commit d7e32e9b03
15 changed files with 1884 additions and 181 deletions
+160 -35
View File
@@ -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;
}
}
}