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,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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user