diff --git a/apps/web/src/core/connections/transports.ts b/apps/web/src/core/connections/transports.ts index e0cf1bd9..58bc965b 100644 --- a/apps/web/src/core/connections/transports.ts +++ b/apps/web/src/core/connections/transports.ts @@ -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 { + 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 }; } diff --git a/apps/web/src/pages/Connections/useConnections.ts b/apps/web/src/pages/Connections/useConnections.ts index 24beb4c0..9036143b 100644 --- a/apps/web/src/pages/Connections/useConnections.ts +++ b/apps/web/src/pages/Connections/useConnections.ts @@ -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; diff --git a/packages/sdk/src/core/logging/logger.ts b/packages/sdk/src/core/logging/logger.ts index 295e71d3..abda1bd7 100644 --- a/packages/sdk/src/core/logging/logger.ts +++ b/packages/sdk/src/core/logging/logger.ts @@ -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 { - 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 } }).process?.env + ?.MESH_DEBUG === "1" + ) { + return 2; + } + return 3; +} + +export function createLogger(name: string, options?: { minLevel?: number }): Logger { + return new Logger({ + name, + prettyLogTemplate, + minLevel: options?.minLevel ?? defaultMinLevel(), + }); } export type { Logger }; diff --git a/packages/transport-web-serial/src/transport.ts b/packages/transport-web-serial/src/transport.ts index fd287151..a40b8d5b 100644 --- a/packages/transport-web-serial/src/transport.ts +++ b/packages/transport-web-serial/src/transport.ts @@ -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> { + 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 { + 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"); } }