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
+116 -19
View File
@@ -1,12 +1,25 @@
import { Socket } from "node:net";
import { Readable, Writable } from "node:stream";
import type { Types } from "@meshtastic/core";
import { Utils } from "@meshtastic/core";
import { Types, Utils } from "@meshtastic/core";
/**
* Node.js TCP transport for Meshtastic.
*
* Implements {@link Types.Transport} on top of a Node `net.Socket`.
* Use {@link TransportNode.create} to open a new connection, or
* construct directly with an existing socket.
*/
export class TransportNode implements Types.Transport {
private readonly _toDevice: WritableStream<Uint8Array>;
private readonly _fromDevice: ReadableStream<Types.DeviceOutput>;
private fromDeviceController?: ReadableStreamDefaultController<Types.DeviceOutput>;
private socket: Socket | 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.
@@ -38,47 +51,131 @@ export class TransportNode implements Types.Transport {
*/
constructor(connection: Socket) {
this.socket = connection;
this.socket.on("error", (err) => {
console.error("Socket connection error:", err);
if (!this.closingByUser) {
this.emitStatus(
Types.DeviceStatusEnum.DeviceDisconnected,
"socket-error",
);
}
});
this.socket.on("close", () => {
if (this.closingByUser) {
return; // suppress close-derived disconnect in user flow
}
this.emitStatus(
Types.DeviceStatusEnum.DeviceDisconnected,
"socket-closed",
);
});
this.abortController = new AbortController();
const abortController = this.abortController;
const fromDeviceSource = Readable.toWeb(
connection,
) as ReadableStream<Uint8Array>;
this._fromDevice = fromDeviceSource.pipeThrough(Utils.fromDeviceStream());
const transformed = fromDeviceSource.pipeThrough(Utils.fromDeviceStream());
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();
} 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;
// The readable end of the transform is then piped to the Node.js socket.
// A similar assertion is needed here because `Writable.toWeb` also returns
// a generically typed stream (`WritableStream<any>`).
toDeviceTransform.readable
.pipeTo(Writable.toWeb(connection) as WritableStream<Uint8Array>)
this.pipePromise = toDeviceTransform.readable
.pipeTo(Writable.toWeb(connection) as WritableStream<Uint8Array>, {
signal: abortController.signal,
})
.catch((err) => {
if (abortController.signal.aborted) {
return;
}
console.error("Error piping data to socket:", err);
this.socket.destroy(err as Error);
const error = err instanceof Error ? err : new Error(String(err));
this.socket?.destroy(error);
});
}
/**
* The WritableStream to send data to the Meshtastic device.
*/
/** WritableStream to send data to the device. */
public get toDevice(): WritableStream<Uint8Array> {
return this._toDevice;
}
/**
* The ReadableStream to receive data from the Meshtastic device.
*/
/** ReadableStream to receive data from the device. */
public get fromDevice(): ReadableStream<Types.DeviceOutput> {
return this._fromDevice;
}
disconnect() {
this.socket.destroy();
this.socket = undefined;
return Promise.resolve();
/**
* Disconnect from the TCP socket and emit `DeviceDisconnected("user")`.
* Safe to call multiple times.
*/
async disconnect(): Promise<void> {
try {
this.closingByUser = true;
this.emitStatus(Types.DeviceStatusEnum.DeviceDisconnected, "user");
this.abortController.abort();
if (this.pipePromise) {
await this.pipePromise;
}
this.socket?.destroy();
} finally {
this.socket = 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 },
});
}
}