mirror of
https://github.com/meshtastic/web.git
synced 2026-08-01 15:36:28 -04:00
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:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user