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
@@ -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