chore(logging): tslog-gated debug logs across the serial connect path

createLogger now reads `localStorage["mesh-debug"]==="1"` (browser) or
`MESH_DEBUG=1` (node) on each invocation. When set, default minLevel
drops to debug; otherwise stays at info. Existing callers
(MeshClient + co.) are unaffected.

Logs added (all `[name]`-prefixed via tslog) covering the connect
lifecycle the user just hit:

- TransportWebSerial.preparePort: enter, close-needed branch,
  close result, each open() attempt, retry decisions, final error.
- TransportWebSerial constructor: pipe wiring, toDevice pipe abort
  vs reject path.
- TransportWebSerial read loop: start, done, throw (with
  closingByUser flag), reader-lock release.
- TransportWebSerial.disconnect: each cleanup step (abort, pipe
  settled, fromDevice cancel, connection.close).
- transports.ts openSerial: cached-port flag, getPorts count,
  picker invocation, resolved-port stream state, createFromPort
  Result outcome (kind + userMessage on Err).
- useConnections.connect / setupMeshDevice / teardown: phase
  transitions, configure() resolve / reject, onConfigComplete fire,
  BT gattserverdisconnected, transport.disconnect rejection.

Flip on:
  localStorage.setItem("mesh-debug", "1"); location.reload();
This commit is contained in:
Dan Ditomaso
2026-04-27 22:06:55 -04:00
parent 97c41046da
commit 2984488c85
4 changed files with 157 additions and 14 deletions

View File

@@ -1,11 +1,14 @@
import type { Connection } from "@core/stores/deviceStore/types";
import { testHttpReachable } from "@pages/Connections/utils";
import { createLogger } from "@meshtastic/sdk";
import { TransportHTTP } from "@meshtastic/transport-http";
import { BluetoothConnectError, TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
import { SerialConnectError, TransportWebSerial } from "@meshtastic/transport-web-serial";
import { Result } from "better-result";
import type { AnyTransport } from "./sdkClient.ts";
const log = createLogger("transports");
/**
* Per-transport-type factories. Each resolves a Transport from a saved
* Connection record + an optional cached BluetoothDevice/SerialPort the
@@ -121,6 +124,12 @@ async function openSerial(
},
opts: OpenTransportOptions,
): Promise<OpenTransportResult> {
log.debug("openSerial: enter", {
hasCached: !!opts.cachedSerialPort,
allowPrompt: !!opts.allowPrompt,
vid: conn.usbVendorId,
pid: conn.usbProductId,
});
if (!("serial" in navigator)) {
throw new Error("Web Serial not supported");
}
@@ -137,6 +146,7 @@ async function openSerial(
let port = opts.cachedSerialPort;
if (!port) {
const ports = await serial.getPorts();
log.debug("openSerial: getPorts", { count: ports.length });
if (ports && conn.usbVendorId && conn.usbProductId) {
port = ports.find((p: SerialPort) => {
const info =
@@ -150,17 +160,31 @@ async function openSerial(
}
}
if (!port && opts.allowPrompt) {
log.debug("openSerial: requesting port via picker");
port = await serial.requestPort({});
}
if (!port) {
log.warn("openSerial: no port resolved");
throw new Error("Serial port not available. Re-select the port.");
}
log.debug("openSerial: resolved port", {
readable: !!(port as SerialPort).readable,
writable: !!(port as SerialPort).writable,
});
// Port-state hygiene (force-close + retry open + busy detection) lives
// inside the transport package — see SerialConnectError. We just unwrap
// the Result and surface the user-facing message on failure.
const result = await TransportWebSerial.createFromPort(port);
if (Result.isError(result)) throw result.error;
if (Result.isError(result)) {
log.error("openSerial: createFromPort returned Err", {
kind: result.error.kind,
userMessage: result.error.userMessage,
});
throw result.error;
}
log.info("openSerial: transport ready");
return { transport: result.value, serialPort: port };
}

View File

@@ -9,6 +9,9 @@ import {
openTransport,
probeConnection,
} from "@app/core/connections/transports.ts";
import { createLogger } from "@meshtastic/sdk";
const log = createLogger("useConnections");
import type {
Connection,
ConnectionId,
@@ -50,6 +53,7 @@ export function useConnections() {
);
const teardown = useCallback(async (id: ConnectionId, conn?: Connection) => {
log.debug("teardown: enter", { id });
stopHeartbeat(id);
configSubscriptions.get(id)?.();
configSubscriptions.delete(id);
@@ -61,10 +65,18 @@ export function useConnections() {
// released before the user clicks reconnect — otherwise port.open()
// can race the close and throw "port already open".
await device?.connection?.disconnect();
} catch {}
log.debug("teardown: transport disconnect awaited");
} catch (e) {
const err = e as Error;
log.warn("teardown: transport disconnect threw", {
name: err?.name,
message: err?.message,
});
}
}
closeTransport(cachedTransports.get(id));
cachedTransports.delete(id);
log.debug("teardown: done", { id });
}, []);
const removeConnection = useCallback(
@@ -114,8 +126,10 @@ export function useConnections() {
// while persistence is spinning up.
device.setConnectionPhase("configuring");
updateStatus(id, "configuring");
log.debug("setupMeshDevice: building MeshDevice", { id, deviceId });
const meshDevice = await buildMeshDevice(id, deviceId, transport);
log.debug("setupMeshDevice: MeshDevice built", { id });
if (!meshRegistry.has(id)) {
meshRegistry.register(id, meshDevice.meshClient);
@@ -133,17 +147,27 @@ export function useConnections() {
device.setConnectionId(id);
const unsubConfigComplete = meshDevice.events.onConfigComplete.subscribe(() => {
log.info("onConfigComplete fired", { id });
device.setConnectionPhase("configured");
updateStatus(id, "configured");
startMaintenanceHeartbeat(id, meshDevice);
});
configSubscriptions.set(id, unsubConfigComplete);
log.debug("setupMeshDevice: calling configure()", { id });
meshDevice
.configure()
.then(() => meshDevice.heartbeat().then(() => startConfigHeartbeat(id, meshDevice)))
.then(() => {
log.debug("setupMeshDevice: configure() resolved, sending heartbeat", { id });
return meshDevice.heartbeat().then(() => startConfigHeartbeat(id, meshDevice));
})
.catch((error) => {
console.error("[useConnections] configure failed:", error);
const e = error as Error;
log.error("setupMeshDevice: configure() rejected", {
id,
name: e?.name,
message: e?.message,
});
updateStatus(id, "error", error?.message ?? String(error));
});
@@ -163,9 +187,16 @@ export function useConnections() {
const connect = useCallback(
async (id: ConnectionId, opts?: { allowPrompt?: boolean }) => {
const conn = connections.find((c) => c.id === id);
if (!conn) return false;
if (conn.status === "configured" || conn.status === "connected") return true;
if (!conn) {
log.warn("connect: unknown connection id", { id });
return false;
}
if (conn.status === "configured" || conn.status === "connected") {
log.debug("connect: already connected", { id, status: conn.status });
return true;
}
log.info("connect: enter", { id, type: conn.type, allowPrompt: !!opts?.allowPrompt });
updateStatus(id, "connecting");
try {
const cached = cachedTransports.get(id);
@@ -175,15 +206,19 @@ export function useConnections() {
conn.type === "bluetooth" ? (cached as BluetoothDevice | undefined) : undefined,
cachedSerialPort: conn.type === "serial" ? (cached as SerialPort | undefined) : undefined,
});
log.debug("connect: openTransport ok", { id });
await setupMeshDevice(id, result.transport, result.bluetoothDevice, result.serialPort);
log.info("connect: setupMeshDevice resolved, awaiting onConfigComplete", { id });
// BT-specific: catch device-side disconnect to flip status.
result.bluetoothDevice?.addEventListener("gattserverdisconnected", () => {
log.warn("BT gattserverdisconnected", { id });
updateStatus(id, "disconnected");
});
return true;
} catch (err) {
console.error("[useConnections] connect failed:", err);
const e = err as Error;
log.error("connect: failed", { id, name: e?.name, message: e?.message });
const message = err instanceof Error ? err.message : String(err);
updateStatus(id, "error", message);
return false;

View File

@@ -2,8 +2,43 @@ import { Logger } from "tslog";
const prettyLogTemplate = "{{hh}}:{{MM}}:{{ss}}:{{ms}}\t{{logLevelName}}\t[{{name}}]\t";
export function createLogger(name: string): Logger<unknown> {
return new Logger({ name, prettyLogTemplate });
/**
* Minimum log level. Default is `info` (3). Lifts to `debug` (2) when:
* - `localStorage["mesh-debug"] === "1"` in a browser context, or
* - `process.env.MESH_DEBUG === "1"` in a Node / Bun / Deno context.
*
* Read once per `createLogger` call so the flag can be flipped at any
* time and new sub-loggers will pick it up; existing logger instances
* keep their level unless the caller explicitly resets it.
*/
function defaultMinLevel(): number {
try {
if (
typeof globalThis !== "undefined" &&
typeof (globalThis as { localStorage?: Storage }).localStorage !== "undefined" &&
(globalThis as { localStorage: Storage }).localStorage.getItem("mesh-debug") === "1"
) {
return 2;
}
} catch {
// localStorage access can throw in private mode / sandboxed iframes.
}
if (
typeof globalThis !== "undefined" &&
(globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env
?.MESH_DEBUG === "1"
) {
return 2;
}
return 3;
}
export function createLogger(name: string, options?: { minLevel?: number }): Logger<unknown> {
return new Logger({
name,
prettyLogTemplate,
minLevel: options?.minLevel ?? defaultMinLevel(),
});
}
export type { Logger };

View File

@@ -1,4 +1,5 @@
import {
createLogger,
DeviceStatusEnum,
type DeviceOutput,
fromDeviceStream,
@@ -7,6 +8,8 @@ import {
} from "@meshtastic/sdk";
import { Result, type ResultType } from "better-result";
const log = createLogger("TransportWebSerial");
/**
* Typed error produced when preparing a `SerialPort` for use fails. `kind`
* lets callers distinguish recoverable cases (port held briefly during
@@ -114,10 +117,20 @@ export class TransportWebSerial implements Transport {
port: SerialPort,
baudRate: number,
): Promise<ResultType<true, SerialConnectError>> {
log.debug("preparePort: enter", {
readable: !!port.readable,
writable: !!port.writable,
baudRate,
});
if (port.readable || port.writable) {
log.debug("preparePort: port has live streams, closing");
try {
await port.close();
log.debug("preparePort: close() ok");
} catch (cause) {
const err = cause as Error;
log.warn("preparePort: close() threw", { name: err?.name, message: err?.message });
return Result.err(
new SerialConnectError(
"in-use",
@@ -132,15 +145,27 @@ export class TransportWebSerial implements Transport {
let lastErr: unknown;
for (let attempt = 0; attempt <= PORT_OPEN_RETRY_DELAYS_MS.length; attempt++) {
try {
log.debug("preparePort: open() attempt", { attempt });
await port.open({ baudRate });
log.debug("preparePort: open() ok", { attempt });
return Result.ok(true);
} catch (err) {
lastErr = err;
const e = err as Error;
log.warn("preparePort: open() threw", {
attempt,
name: e?.name,
message: e?.message,
willRetry: isPortBusyError(err) && attempt < PORT_OPEN_RETRY_DELAYS_MS.length,
});
if (!isPortBusyError(err) || attempt === PORT_OPEN_RETRY_DELAYS_MS.length) break;
await new Promise((r) => setTimeout(r, PORT_OPEN_RETRY_DELAYS_MS[attempt]));
}
}
const e = lastErr as Error | undefined;
log.error("preparePort: failed", { name: e?.name, message: e?.message });
if (isPortBusyError(lastErr)) {
return Result.err(
new SerialConnectError(
@@ -173,6 +198,8 @@ export class TransportWebSerial implements Transport {
this.abortController = new AbortController();
const abortController = this.abortController;
log.debug("constructor: wiring pipe + reader");
// Set up the pipe with abort signal for clean cancellation
const toDeviceTransform = toDeviceStream();
this.pipePromise = toDeviceTransform.readable
@@ -180,9 +207,11 @@ export class TransportWebSerial implements Transport {
.catch((err) => {
// Ignore expected rejection when we cancel it via the AbortController.
if (abortController.signal.aborted) {
log.debug("toDevice pipe aborted (expected)");
return;
}
console.error("Error piping data to serial port:", err);
const e = err as Error;
log.error("toDevice pipe rejected", { name: e?.name, message: e?.message });
this.connection.close().catch(() => {});
this.emitStatus(DeviceStatusEnum.DeviceDisconnected, "write-error");
});
@@ -202,23 +231,32 @@ export class TransportWebSerial implements Transport {
const onOsDisconnect = (ev: Event) => {
const { port } = ev as unknown as { port?: SerialPort };
if (port && port === this.connection) {
log.warn("OS-level disconnect event");
this.emitStatus(DeviceStatusEnum.DeviceDisconnected, "serial-disconnected");
}
};
navigator.serial.addEventListener("disconnect", onOsDisconnect);
log.debug("read loop starting");
this.emitStatus(DeviceStatusEnum.DeviceConnected);
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
log.debug("read loop: done=true");
break;
}
ctrl.enqueue(value);
}
ctrl.close();
} catch (error) {
const e = error as Error;
log.warn("read loop threw", {
closingByUser: this.closingByUser,
name: e?.name,
message: e?.message,
});
if (!this.closingByUser) {
this.emitStatus(DeviceStatusEnum.DeviceDisconnected, "read-error");
}
@@ -229,6 +267,7 @@ export class TransportWebSerial implements Transport {
} finally {
reader.releaseLock();
navigator.serial.removeEventListener("disconnect", onOsDisconnect);
log.debug("read loop: released reader lock + listener");
}
},
});
@@ -259,31 +298,41 @@ export class TransportWebSerial implements Transport {
* Closes the serial port and emits `DeviceDisconnected("user")`.
*/
public async disconnect(): Promise<void> {
log.debug("disconnect: enter");
try {
this.closingByUser = true;
// Stop outbound piping
this.abortController.abort();
log.debug("disconnect: aborted toDevice pipe");
if (this.pipePromise) {
await this.pipePromise;
log.debug("disconnect: pipePromise settled");
}
// Cancel any remaining streams
if (this._fromDevice?.locked) {
try {
await this._fromDevice.cancel();
} catch {
// Stream cancellation might fail if already cancelled
log.debug("disconnect: cancelled fromDevice");
} catch (e) {
const err = e as Error;
log.warn("disconnect: fromDevice.cancel() threw", {
name: err?.name,
message: err?.message,
});
}
}
await this.connection.close();
log.debug("disconnect: connection.close() ok");
} catch (error) {
// If we can't close cleanly, let the browser handle cleanup
console.warn("Could not cleanly disconnect serial port:", error);
const e = error as Error;
log.warn("disconnect: cleanup failed", { name: e?.name, message: e?.message });
} finally {
this.emitStatus(DeviceStatusEnum.DeviceDisconnected, "user");
this.closingByUser = false;
log.debug("disconnect: done");
}
}