mirror of
https://github.com/meshtastic/web.git
synced 2026-08-02 07:57:52 -04:00
feat(sdk,web): live connect-progress overlay (terminal-style log)
Surfaces what the connection handshake is doing in real time. Per DDD,
the predicate + counters live in the SDK; the web overlay is a thin
consumer of the signal.
SDK
- New `MeshClient.progress: ReadonlySignal<ConnectionProgress>` with the
state machine `idle → configuring → configured`. Counters
(config / modules / channels / nodes + boolean myInfo / metadata)
tally inbound packets each `configure()` cycle.
- `configure()` resets to `configuring` with empty counters.
`onConfigComplete` flips to `configured`.
- `ConnectionProgress` + `ConnectionProgressCounters` exported from the
package surface.
- 6 new tests in `MeshClient.progress.test.ts`.
sdk-react
- `useConnectionProgress()` hook reads the active client's signal,
returns `{ phase: "idle" }` when there is no active client so the
caller can render unconditionally.
apps/web
- `ConnectingOverlay` redesigned as a terminal-style streaming log:
- macOS-window-style header (red/yellow/green dots) with a live
`phase` indicator pulse.
- Monospace event lines with `→` (active) / `✓` (done) / `•` (info)
glyphs and a relative-seconds timestamp column.
- Auto-scrolls to the latest entry; blinking caret on the trailing
active line.
- Lines derive from `device.connectionPhase` transitions and per-
counter bumps on `MeshClient.progress`. Resets on each fresh
connect cycle.
- Mounted at the top of the device tree (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.
- i18n strings under `connections.overlay.log.*`.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<Toaster />
|
||||
<TanStackRouterDevtools position="bottom-right" />
|
||||
<DeviceWrapper deviceId={selectedDeviceId}>
|
||||
{/* 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. */}
|
||||
<ConnectingOverlay />
|
||||
<div
|
||||
className="flex h-screen flex-col bg-background-primary text-text-primary"
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
|
||||
219
apps/web/src/components/ConnectingOverlay.tsx
Normal file
219
apps/web/src/components/ConnectingOverlay.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { Dialog, DialogContent, DialogTitle } from "@components/UI/Dialog.tsx";
|
||||
import { useAppStore, useDeviceStore } from "@core/stores";
|
||||
import type { ConnectionProgressCounters } from "@meshtastic/sdk";
|
||||
import { useConnectionProgress } from "@meshtastic/sdk-react";
|
||||
import type { ReactElement } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type LineKind = "ok" | "active" | "info";
|
||||
interface LogLine {
|
||||
id: number;
|
||||
ts: number;
|
||||
kind: LineKind;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const SYMBOL: Record<LineKind, string> = {
|
||||
ok: "✓",
|
||||
active: "→",
|
||||
info: "•",
|
||||
};
|
||||
|
||||
const COLOR: Record<LineKind, string> = {
|
||||
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<LogLine[]>([]);
|
||||
const idRef = useRef(0);
|
||||
const lastPhaseRef = useRef<typeof phase>("disconnected");
|
||||
const lastCountersRef = useRef<ConnectionProgressCounters | undefined>(undefined);
|
||||
const scrollRef = useRef<HTMLDivElement>(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 (
|
||||
<Dialog open>
|
||||
<DialogContent
|
||||
className="max-w-xl border-zinc-800 bg-zinc-950 p-0 text-zinc-100 shadow-2xl"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-zinc-800 px-4 py-2">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="h-3 w-3 rounded-full bg-red-500/80" />
|
||||
<span className="h-3 w-3 rounded-full bg-yellow-500/80" />
|
||||
<span className="h-3 w-3 rounded-full bg-green-500/80" />
|
||||
</div>
|
||||
<DialogTitle className="ml-2 font-mono text-xs uppercase tracking-wider text-zinc-400">
|
||||
{t("overlay.log.header", { defaultValue: "meshtastic — connect" })}
|
||||
</DialogTitle>
|
||||
<span className="ml-auto inline-flex items-center gap-1.5 font-mono text-[10px] text-zinc-500">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
phase === "configuring" ? "animate-pulse bg-sky-400" : "bg-amber-400"
|
||||
}`}
|
||||
/>
|
||||
{phase}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-[360px] min-h-[200px] overflow-y-auto bg-zinc-950 px-4 py-3 font-mono text-[12px] leading-relaxed"
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<div className="text-zinc-600">…</div>
|
||||
) : (
|
||||
lines.map((line, i) => {
|
||||
const isLast = i === lines.length - 1;
|
||||
const symbol = isLast && line.kind === "active" ? "→" : SYMBOL[line.kind];
|
||||
return (
|
||||
<div key={line.id} className="flex items-start gap-3">
|
||||
<span className="shrink-0 select-none text-zinc-600">
|
||||
{String(((line.ts - start) / 1000).toFixed(2)).padStart(6, " ")}s
|
||||
</span>
|
||||
<span className={`shrink-0 select-none ${COLOR[line.kind]}`}>{symbol}</span>
|
||||
<span
|
||||
className={
|
||||
line.kind === "info"
|
||||
? "text-zinc-300"
|
||||
: line.kind === "ok"
|
||||
? "text-zinc-100"
|
||||
: "text-zinc-100"
|
||||
}
|
||||
>
|
||||
{line.text}
|
||||
{isLast && line.kind === "active" && (
|
||||
<span className="ml-1 inline-block h-3 w-2 animate-pulse bg-zinc-300" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
23
packages/sdk-react/src/hooks/useConnectionProgress.ts
Normal file
23
packages/sdk-react/src/hooks/useConnectionProgress.ts
Normal file
@@ -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<ConnectionProgress> = {
|
||||
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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
78
packages/sdk/src/core/client/MeshClient.progress.test.ts
Normal file
78
packages/sdk/src/core/client/MeshClient.progress.test.ts
Normal file
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ConnectionProgress>;
|
||||
private readonly progressStore = createStore<ConnectionProgress>({ phase: "idle" });
|
||||
|
||||
private _heartbeatIntervalId: ReturnType<typeof setInterval> | 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<number> {
|
||||
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<number> {
|
||||
this.log.debug(Emitter[Emitter.Ping], "❤️ Send heartbeat ping to radio");
|
||||
const toRadio = create(Protobuf.Mesh.ToRadioSchema, {
|
||||
|
||||
Reference in New Issue
Block a user