diff --git a/apps/web/public/i18n/locales/en/connections.json b/apps/web/public/i18n/locales/en/connections.json index 790a05f8..375acee0 100644 --- a/apps/web/public/i18n/locales/en/connections.json +++ b/apps/web/public/i18n/locales/en/connections.json @@ -54,5 +54,20 @@ "renamedByName": "Connection renamed to {{name}}.", "updated": "Updated", "updatedByName": "{{name}} has been updated." + }, + "overlay": { + "log": { + "header": "meshtastic — connect", + "transportOpening": "Opening transport…", + "transportReady": "Transport ready", + "requestingConfig": "Requesting configuration", + "configReceived": "Received config section ({{n}})", + "moduleReceived": "Received module config ({{n}})", + "channelReceived": "Received channel ({{n}})", + "nodeReceived": "Received node info ({{n}})", + "myInfo": "Identity received", + "metadata": "Metadata received", + "configured": "Configuration complete" + } } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6df57138..67f0b89c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,5 +1,6 @@ import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; import { CommandPalette } from "@components/CommandPalette/index.tsx"; +import { ConnectingOverlay } from "@components/ConnectingOverlay.tsx"; import { DialogManager } from "@components/Dialog/DialogManager.tsx"; import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx"; import { RegionSetupReminder } from "@components/RegionSetupReminder.tsx"; @@ -27,6 +28,10 @@ export function App() { + {/* Overlay sits outside the device-conditional branch so it shows + during a first-time connect from the Connections screen as + well as reconnects from inside the app. */} +
= { + ok: "✓", + active: "→", + info: "•", +}; + +const COLOR: Record = { + ok: "text-emerald-400", + active: "text-sky-300", + info: "text-zinc-400", +}; + +/** + * Full-screen terminal-style log overlay shown while the active device's + * connection phase is "connecting" or "configuring". Lines are derived + * from `device.connectionPhase` transitions and `MeshClient.progress` + * counter bumps — pure consumer of domain signals. + * + * Auto-scrolls to the latest line. Auto-dismisses once phase flips to + * "configured" / "disconnected" / "error". + */ +export const ConnectingOverlay = (): ReactElement | null => { + const { selectedDeviceId } = useAppStore(); + const { getDevice } = useDeviceStore(); + const device = getDevice(selectedDeviceId); + const phase = device?.connectionPhase ?? "disconnected"; + const progress = useConnectionProgress(); + const { t } = useTranslation("connections"); + + const [lines, setLines] = useState([]); + const idRef = useRef(0); + const lastPhaseRef = useRef("disconnected"); + const lastCountersRef = useRef(undefined); + const scrollRef = useRef(null); + + const visible = phase === "connecting" || phase === "configuring"; + + // Reset the log when a fresh connect cycle starts (idle → connecting). + useEffect(() => { + if (phase === "connecting" && lastPhaseRef.current !== "connecting") { + setLines([]); + idRef.current = 0; + lastCountersRef.current = undefined; + } + lastPhaseRef.current = phase; + }, [phase]); + + // High-level phase transitions → log lines. + useEffect(() => { + if (phase === "connecting") { + append("active", t("overlay.log.transportOpening", { defaultValue: "Opening transport…" })); + } else if (phase === "configuring") { + append("ok", t("overlay.log.transportReady", { defaultValue: "Transport ready" })); + append( + "active", + t("overlay.log.requestingConfig", { defaultValue: "Requesting configuration" }), + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [phase]); + + // Counter bumps → per-event lines. + useEffect(() => { + if (progress.phase !== "configuring") { + if (progress.phase === "configured") { + append("ok", t("overlay.log.configured", { defaultValue: "Configuration complete" })); + } + return; + } + const cur = progress.received; + const prev = lastCountersRef.current ?? { + config: 0, + modules: 0, + channels: 0, + nodes: 0, + myInfo: false, + metadata: false, + }; + if (cur.config > prev.config) { + append( + "info", + t("overlay.log.configReceived", { + n: cur.config, + defaultValue: "Received config section ({{n}})", + }), + ); + } + if (cur.modules > prev.modules) { + append( + "info", + t("overlay.log.moduleReceived", { + n: cur.modules, + defaultValue: "Received module config ({{n}})", + }), + ); + } + if (cur.channels > prev.channels) { + append( + "info", + t("overlay.log.channelReceived", { + n: cur.channels, + defaultValue: "Received channel ({{n}})", + }), + ); + } + if (cur.nodes > prev.nodes) { + append( + "info", + t("overlay.log.nodeReceived", { + n: cur.nodes, + defaultValue: "Received node info ({{n}})", + }), + ); + } + if (cur.myInfo && !prev.myInfo) { + append("ok", t("overlay.log.myInfo", { defaultValue: "Identity received" })); + } + if (cur.metadata && !prev.metadata) { + append("ok", t("overlay.log.metadata", { defaultValue: "Metadata received" })); + } + lastCountersRef.current = cur; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [progress]); + + // Auto-scroll to bottom on new line. + useEffect(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [lines]); + + function append(kind: LineKind, text: string): void { + setLines((prev) => [...prev, { id: ++idRef.current, ts: Date.now(), kind, text }]); + } + + if (!visible) return null; + + const start = lines[0]?.ts ?? Date.now(); + + return ( + + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > +
+
+ + + +
+ + {t("overlay.log.header", { defaultValue: "meshtastic — connect" })} + + + + {phase} + +
+ +
+ {lines.length === 0 ? ( +
+ ) : ( + lines.map((line, i) => { + const isLast = i === lines.length - 1; + const symbol = isLast && line.kind === "active" ? "→" : SYMBOL[line.kind]; + return ( +
+ + {String(((line.ts - start) / 1000).toFixed(2)).padStart(6, " ")}s + + {symbol} + + {line.text} + {isLast && line.kind === "active" && ( + + )} + +
+ ); + }) + )} +
+
+
+ ); +}; diff --git a/packages/sdk-react/mod.ts b/packages/sdk-react/mod.ts index 22a3bfdb..4d8b5de4 100644 --- a/packages/sdk-react/mod.ts +++ b/packages/sdk-react/mod.ts @@ -28,6 +28,7 @@ export { useNode } from "./src/hooks/useNode.ts"; export { useChannels, useChannel } from "./src/hooks/useChannels.ts"; export { useConfig, useIsRegionUnset, useModuleConfig } from "./src/hooks/useConfig.ts"; export { useConfigEditor } from "./src/hooks/useConfigEditor.ts"; +export { useConnectionProgress } from "./src/hooks/useConnectionProgress.ts"; export { useTotalUnread, useUnreadByKey, useUnreadCount } from "./src/hooks/useUnread.ts"; export { useTelemetry } from "./src/hooks/useTelemetry.ts"; export type { UseTelemetryResult } from "./src/hooks/useTelemetry.ts"; diff --git a/packages/sdk-react/src/hooks/useConnectionProgress.ts b/packages/sdk-react/src/hooks/useConnectionProgress.ts new file mode 100644 index 00000000..6371dc99 --- /dev/null +++ b/packages/sdk-react/src/hooks/useConnectionProgress.ts @@ -0,0 +1,23 @@ +import type { ConnectionProgress, ReadonlySignal } from "@meshtastic/sdk"; +import { useActiveClient } from "../adapters/useActiveClient.ts"; +import { useSignal } from "../adapters/useSignal.ts"; + +const IDLE_PROGRESS: ConnectionProgress = { phase: "idle" }; +const IDLE_SIGNAL: ReadonlySignal = { + value: IDLE_PROGRESS, + peek: () => IDLE_PROGRESS, + subscribe: () => () => {}, +}; + +/** + * Live view of `MeshClient.progress` for the active registry client. + * Returns `{ phase: "idle" }` when no client is active or before + * `configure()` runs; flips through `configuring` (with per-section + * counters) and lands on `configured` once the device finishes streaming + * its config bundle. UI surfaces use this to drive a connecting overlay + * with live "received N nodes / X channels" feedback. + */ +export function useConnectionProgress(): ConnectionProgress { + const client = useActiveClient(); + return useSignal(client?.progress ?? IDLE_SIGNAL); +} diff --git a/packages/sdk/mod.ts b/packages/sdk/mod.ts index 54e6bb88..40ec499b 100644 --- a/packages/sdk/mod.ts +++ b/packages/sdk/mod.ts @@ -1,6 +1,10 @@ // Main entry export { MeshClient } from "./src/core/client/MeshClient.ts"; -export type { MeshClientOptions } from "./src/core/client/MeshClient.ts"; +export type { + ConnectionProgress, + ConnectionProgressCounters, + MeshClientOptions, +} from "./src/core/client/MeshClient.ts"; export { MeshRegistry } from "./src/core/registry/MeshRegistry.ts"; export type { ConnectionId, RegistryEntry } from "./src/core/registry/MeshRegistry.ts"; diff --git a/packages/sdk/src/core/client/MeshClient.progress.test.ts b/packages/sdk/src/core/client/MeshClient.progress.test.ts new file mode 100644 index 00000000..3a125037 --- /dev/null +++ b/packages/sdk/src/core/client/MeshClient.progress.test.ts @@ -0,0 +1,78 @@ +import { create } from "@bufbuild/protobuf"; +import * as Protobuf from "@meshtastic/protobufs"; +import { describe, expect, it } from "vitest"; +import { createFakeTransport } from "../testing/createFakeTransport.ts"; +import { MeshClient } from "./MeshClient.ts"; + +describe("MeshClient.progress", () => { + it("starts in the idle phase before configure() is called", () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + expect(client.progress.value.phase).toBe("idle"); + }); + + it("flips to configuring with empty counters when configure() runs", async () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + void client.configure(); + expect(client.progress.value.phase).toBe("configuring"); + expect(client.progress.value).toEqual({ + phase: "configuring", + received: { config: 0, modules: 0, channels: 0, nodes: 0, myInfo: false, metadata: false }, + }); + }); + + it("tallies inbound packets onto the configuring counters", () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + void client.configure(); + + client.events.onConfigPacket.dispatch(create(Protobuf.Config.ConfigSchema, {})); + client.events.onConfigPacket.dispatch(create(Protobuf.Config.ConfigSchema, {})); + client.events.onChannelPacket.dispatch(create(Protobuf.Channel.ChannelSchema, {})); + client.events.onNodeInfoPacket.dispatch(create(Protobuf.Mesh.NodeInfoSchema, {})); + client.events.onMyNodeInfo.dispatch(create(Protobuf.Mesh.MyNodeInfoSchema, {})); + + const cur = client.progress.value; + if (cur.phase !== "configuring") throw new Error("expected configuring phase"); + expect(cur.received.config).toBe(2); + expect(cur.received.channels).toBe(1); + expect(cur.received.nodes).toBe(1); + expect(cur.received.myInfo).toBe(true); + expect(cur.received.modules).toBe(0); + }); + + it("flips to configured when onConfigComplete fires", () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + void client.configure(); + client.events.onConfigPacket.dispatch(create(Protobuf.Config.ConfigSchema, {})); + client.events.onConfigComplete.dispatch(0); + + const cur = client.progress.value; + expect(cur.phase).toBe("configured"); + if (cur.phase !== "configured") throw new Error("unreachable"); + expect(cur.received.config).toBe(1); + }); + + it("ignores packets that arrive while idle (post-completion)", () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + // not calling configure() — phase is idle + client.events.onConfigPacket.dispatch(create(Protobuf.Config.ConfigSchema, {})); + expect(client.progress.value.phase).toBe("idle"); + }); + + it("resets counters when configure() runs again", () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + void client.configure(); + client.events.onConfigPacket.dispatch(create(Protobuf.Config.ConfigSchema, {})); + client.events.onConfigComplete.dispatch(0); + void client.configure(); + expect(client.progress.value).toEqual({ + phase: "configuring", + received: { config: 0, modules: 0, channels: 0, nodes: 0, myInfo: false, metadata: false }, + }); + }); +}); diff --git a/packages/sdk/src/core/client/MeshClient.ts b/packages/sdk/src/core/client/MeshClient.ts index 5bb4227e..9e9e88d1 100644 --- a/packages/sdk/src/core/client/MeshClient.ts +++ b/packages/sdk/src/core/client/MeshClient.ts @@ -8,6 +8,7 @@ import { generatePacketId } from "../identifiers/PacketId.ts"; import { createLogger } from "../logging/logger.ts"; import { decodePacket } from "../packet-codec/decodePacket.ts"; import { Queue } from "../queue/Queue.ts"; +import { createStore, type ReadonlySignal } from "../signals/createStore.ts"; import type { Transport } from "../transport/Transport.ts"; import { DeviceStatusEnum } from "../transport/Transport.ts"; import { ChannelNumber, type Destination, Emitter, type PacketMetadata } from "../types.ts"; @@ -35,6 +36,39 @@ export interface MeshClientOptions { telemetry?: TelemetryClientOptions; } +/** + * Per-section / per-event tally of what has streamed in since the most + * recent `configure()` call. UI surfaces use this for live "received X + * channels, Y nodes" feedback during the handshake. + */ +export interface ConnectionProgressCounters { + config: number; + modules: number; + channels: number; + nodes: number; + myInfo: boolean; + metadata: boolean; +} + +/** + * Connection progress signal shape. The state machine moves + * `idle → configuring → configured` once `configure()` runs and the + * device finishes streaming its config bundle. + */ +export type ConnectionProgress = + | { phase: "idle" } + | { phase: "configuring"; received: ConnectionProgressCounters } + | { phase: "configured"; received: ConnectionProgressCounters }; + +const EMPTY_COUNTERS: ConnectionProgressCounters = { + config: 0, + modules: 0, + channels: 0, + nodes: 0, + myInfo: false, + metadata: false, +}; + /** * Orchestrator for a single connected Meshtastic device. * @@ -60,6 +94,15 @@ export class MeshClient { public readonly traceroute: TraceRouteClient; public readonly files: FilesClient; + /** + * Live connection-handshake progress. Resets to `configuring` with empty + * counters when `configure()` is called; tallies each inbound config / + * module / channel / node packet; flips to `configured` when the device + * sends `configCompleteId`. + */ + public readonly progress: ReadonlySignal; + private readonly progressStore = createStore({ phase: "idle" }); + private _heartbeatIntervalId: ReturnType | undefined; constructor(options: MeshClientOptions) { @@ -80,6 +123,9 @@ export class MeshClient { this.traceroute = new TraceRouteClient(this); this.files = new FilesClient(this); + this.progress = this.progressStore.read; + this.wireProgressTracking(); + this.events.onDeviceStatus.subscribe((status) => { if (status === DeviceStatusEnum.DeviceDisconnected) { if (this._heartbeatIntervalId !== undefined) { @@ -109,6 +155,10 @@ export class MeshClient { public configure(): Promise { this.log.debug(Emitter[Emitter.Configure], "⚙️ Requesting device configuration"); this.updateDeviceStatus(DeviceStatusEnum.DeviceConfiguring); + this.progressStore.write.value = { + phase: "configuring", + received: { ...EMPTY_COUNTERS }, + }; const toRadio = create(Protobuf.Mesh.ToRadioSchema, { payloadVariant: { case: "wantConfigId", value: this.configId }, @@ -122,6 +172,39 @@ export class MeshClient { }); } + /** + * Subscribe progress counters to the relevant inbound events. Each + * subscriber bumps the matching field on the current `configuring` + * snapshot; `onConfigComplete` flips the phase to `configured`. + * + * Outside of an active handshake (phase=idle) inbound packets are + * ignored — they belong to a later session that re-runs configure(). + */ + private wireProgressTracking(): void { + const bump = (field: keyof ConnectionProgressCounters): void => { + const cur = this.progressStore.read.value; + if (cur.phase !== "configuring") return; + const next: ConnectionProgressCounters = { + ...cur.received, + [field]: typeof cur.received[field] === "number" ? cur.received[field] + 1 : true, + } as ConnectionProgressCounters; + this.progressStore.write.value = { phase: "configuring", received: next }; + }; + + this.events.onConfigPacket.subscribe(() => bump("config")); + this.events.onModuleConfigPacket.subscribe(() => bump("modules")); + this.events.onChannelPacket.subscribe(() => bump("channels")); + this.events.onNodeInfoPacket.subscribe(() => bump("nodes")); + this.events.onMyNodeInfo.subscribe(() => bump("myInfo")); + this.events.onDeviceMetadataPacket.subscribe(() => bump("metadata")); + + this.events.onConfigComplete.subscribe(() => { + const cur = this.progressStore.read.value; + const received = cur.phase === "configuring" ? cur.received : { ...EMPTY_COUNTERS }; + this.progressStore.write.value = { phase: "configured", received }; + }); + } + public heartbeat(): Promise { this.log.debug(Emitter[Emitter.Ping], "❤️ Send heartbeat ping to radio"); const toRadio = create(Protobuf.Mesh.ToRadioSchema, {