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
@@ -0,0 +1,187 @@
import { describe, vi, expect, beforeEach, afterEach, it } from "vitest";
import { Duplex } from "node:stream";
import type { SerialPort } from "serialport";
import { Types, Utils } from "@meshtastic/core";
import { runTransportContract } from "../../../tests/utils/transportContract";
import { TransportNodeSerial } from "./transport";
function isStatusEvent(
output: Types.DeviceOutput | undefined,
): output is Extract<Types.DeviceOutput, { type: "status" }> {
return output !== undefined && output.type === "status";
}
class FakeSerialPort extends Duplex {
public lastWritten: Uint8Array | undefined;
constructor() {
super({ objectMode: false });
}
_read() {}
_write(
chunk: Buffer,
_encoding: BufferEncoding,
callback: (error?: Error | null) => void,
) {
this.lastWritten = new Uint8Array(
chunk.buffer,
chunk.byteOffset,
chunk.byteLength,
);
callback();
}
pushIncoming(data: Uint8Array) {
const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
this.push(buf);
}
emitErrorOnce(message = "simulated serial error") {
this.emit("error", new Error(message));
}
emitClose() {
this.emit("close");
}
close() {
this.destroy();
this.emit("close");
}
}
function stubCoreTransforms() {
const toDevice = new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
controller.enqueue(chunk);
},
});
const fromDeviceFactory = () =>
new TransformStream<Uint8Array, Types.DeviceOutput>({
transform(chunk, controller) {
controller.enqueue({ type: "packet", data: chunk });
},
});
// Utils.toDeviceStream is a getter
vi.spyOn(Utils, "toDeviceStream", "get").mockReturnValue(
toDevice as unknown as typeof Utils.toDeviceStream,
);
vi.spyOn(Utils, "fromDeviceStream").mockImplementation(
() =>
fromDeviceFactory() as unknown as TransformStream<
Uint8Array,
Types.DeviceOutput
>,
);
return {
restore: () => vi.restoreAllMocks(),
};
}
describe("TransportNodeSerial (contract)", () => {
let transformsStub: { restore: () => void } | undefined;
beforeEach(() => {
transformsStub = stubCoreTransforms();
});
afterEach(() => {
transformsStub?.restore();
});
runTransportContract({
name: "TransportNodeSerial",
setup: () => {},
teardown: () => {
vi.restoreAllMocks();
},
create: async () => {
const fakePort = new FakeSerialPort();
const transport = new TransportNodeSerial(
fakePort as unknown as SerialPort,
);
await Promise.resolve();
(globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort =
fakePort;
return transport;
},
pushIncoming: async (bytes) => {
(globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort.pushIncoming(
bytes,
);
await Promise.resolve();
},
assertLastWritten: (bytes) => {
const port =
(globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort;
expect(port.lastWritten).toBeDefined();
expect(port.lastWritten).toEqual(bytes);
},
triggerDisconnect: async () => {
(globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort.emitErrorOnce(
"test-disconnect",
);
await Promise.resolve();
},
});
});
describe("TransportNodeSerial (extras)", () => {
let transformsStub: { restore: () => void } | undefined;
beforeEach(() => {
transformsStub = stubCoreTransforms();
});
afterEach(() => {
transformsStub?.restore();
});
it("emits DeviceDisconnected with reason 'port-closed' on close event", async () => {
const fakePort = new FakeSerialPort();
const transport = new TransportNodeSerial(
fakePort as unknown as SerialPort,
);
const reader = transport.fromDevice.getReader();
await Promise.resolve();
const first = await reader.read();
expect(isStatusEvent(first.value)).toBe(true);
if (isStatusEvent(first.value)) {
expect(first.value.data.status).toBe(
Types.DeviceStatusEnum.DeviceConnecting,
);
}
const second = await reader.read();
expect(isStatusEvent(second.value)).toBe(true);
if (isStatusEvent(second.value)) {
expect(second.value.data.status).toBe(
Types.DeviceStatusEnum.DeviceConnected,
);
}
fakePort.emitClose();
await Promise.resolve();
let sawClosed = false;
for (let i = 0; i < 6; i++) {
const { value } = await reader.read();
if (isStatusEvent(value) && value.data.reason === "port-closed") {
sawClosed = true;
break;
}
}
expect(sawClosed).toBe(true);
reader.releaseLock();
await transport.disconnect();
});
});
+111 -18
View File
@@ -1,17 +1,29 @@
import { Readable, Writable } from "node:stream";
import type { Types } from "@meshtastic/core";
import { Utils } from "@meshtastic/core";
import { Types, Utils } from "@meshtastic/core";
import { SerialPort } from "serialport";
/**
* Node.js Serial transport for Meshtastic.
*
* Implements {@link Types.Transport} on top of a Node `SerialPort`.
* Use {@link TransportNodeSerial.create} for a convenient factory, or
* `new TransportNodeSerial(port)` if you already have an open port.
*/
export class TransportNodeSerial implements Types.Transport {
private readonly _toDevice: WritableStream<Uint8Array>;
private readonly _fromDevice: ReadableStream<Types.DeviceOutput>;
private fromDeviceController?: ReadableStreamDefaultController<Types.DeviceOutput>;
private port: SerialPort | undefined;
private pipePromise?: Promise<void>;
private abortController: AbortController;
private lastStatus: Types.DeviceStatusEnum =
Types.DeviceStatusEnum.DeviceDisconnected;
private closingByUser = false;
/**
* Creates and connects a new TransportNode instance.
* @param path - Path to the serial device
* @param baudRate - The port number for the TCP connection (defaults to 4403).
* @param baudRate - Baud rate for the serial connection (default is 115200).
* @returns A promise that resolves with a connected TransportNode instance.
*/
public static create(
@@ -46,23 +58,78 @@ export class TransportNodeSerial implements Types.Transport {
this.port = port;
this.port.on("error", (err) => {
console.error("Serial port connection error:", err);
this.emitStatus(Types.DeviceStatusEnum.DeviceDisconnected, "port-error");
});
this.port.on("close", () => {
if (this.closingByUser) {
return;
}
this.emitStatus(Types.DeviceStatusEnum.DeviceDisconnected, "port-closed");
});
const fromDeviceSource = Readable.toWeb(port) as ReadableStream<Uint8Array>;
this._fromDevice = fromDeviceSource.pipeThrough(Utils.fromDeviceStream());
const transformed = fromDeviceSource.pipeThrough(Utils.fromDeviceStream());
this.abortController = new AbortController();
const controller = this.abortController;
this._fromDevice = new ReadableStream<Types.DeviceOutput>({
start: async (ctrl) => {
this.fromDeviceController = ctrl;
this.emitStatus(Types.DeviceStatusEnum.DeviceConnecting);
this.emitStatus(Types.DeviceStatusEnum.DeviceConnected);
const reader = transformed.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
ctrl.enqueue(value);
}
ctrl.close();
} catch (error) {
if (this.closingByUser) {
ctrl.close(); // graceful EOF on user
} else {
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();
}
},
});
// Stream for data going FROM the application TO the Meshtastic device.
const toDeviceTransform = Utils.toDeviceStream;
this._toDevice = toDeviceTransform.writable;
this._toDevice = Utils.toDeviceStream.writable;
// The readable end of the transform is then piped to the Node.js SerialPort connection.
// A similar assertion is needed here because `Writable.toWeb` also returns
// a generically typed stream (`WritableStream<any>`).
toDeviceTransform.readable
.pipeTo(Writable.toWeb(port) as WritableStream<Uint8Array>)
.catch((err) => {
console.error("Error piping data to serial port:", err);
this.port.close(err as Error);
this.pipePromise = Utils.toDeviceStream.readable
.pipeTo(Writable.toWeb(port) as WritableStream<Uint8Array>, {
signal: controller.signal,
})
.catch((error) => {
if (controller.signal.aborted || this.closingByUser) {
return;
}
console.error("Error piping data to serial port:", error);
this.emitStatus(
Types.DeviceStatusEnum.DeviceDisconnected,
"write-error",
);
try {
this.port?.close();
} catch {}
});
}
@@ -80,9 +147,35 @@ export class TransportNodeSerial implements Types.Transport {
return this._fromDevice;
}
disconnect() {
this.port.close();
this.port = undefined;
return Promise.resolve();
/**
* Disconnect from the serial port and emit `DeviceDisconnected("user")`.
* Safe to call multiple times.
*/
async disconnect() {
try {
this.closingByUser = true;
this.emitStatus(Types.DeviceStatusEnum.DeviceDisconnected, "user");
this.abortController?.abort();
await this.pipePromise?.catch(() => {});
try {
this.port?.close();
} catch {}
} finally {
this.port = undefined;
this.closingByUser = false;
}
}
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 },
});
}
}