From f67b8b898ecb913d52c9bbba4e838c967f8c30aa Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Wed, 27 May 2026 22:29:40 -0400 Subject: [PATCH] fix(sdk/nodes): seed self-node stub from MyNodeInfo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If a session's config bundle doesn't include a NodeInfo for the device itself (fresh flash, cleared nodeDB, some firmware builds), the web sidebar's `useMyNodeAsProto()` selector — which does `nodes.list.find(n => n.num === myNodeNum)` — never resolves and the "loading" spinner sticks indefinitely after the overlay closes. NodesClient now subscribes to onMyNodeInfo. If no entry exists for the incoming myNodeNum, it seeds a stub Node with just `num` set; subsequent NodeInfo / UserPacket / PositionPacket events patch real data on top. Guards against myNodeNum === 0 and against overwriting an existing entry. DeviceInfoPanel already wraps user-dependent rendering in `{user && …}`, so no app-layer change is needed — the panel quietly omits the avatar block until a real UserPacket arrives. Tests cover: stub seeded when only MyNodeInfo arrives, real NodeInfo wins over a later MyNodeInfo, real NodeInfo patches over an existing stub, and myNodeNum=0 is ignored. --- .../src/features/nodes/NodesClient.test.ts | 53 +++++++++++++++++++ .../sdk/src/features/nodes/NodesClient.ts | 19 +++++++ 2 files changed, 72 insertions(+) diff --git a/packages/sdk/src/features/nodes/NodesClient.test.ts b/packages/sdk/src/features/nodes/NodesClient.test.ts index bb37d4a4..90eb3112 100644 --- a/packages/sdk/src/features/nodes/NodesClient.test.ts +++ b/packages/sdk/src/features/nodes/NodesClient.test.ts @@ -25,4 +25,57 @@ describe("NodesClient", () => { const client = new MeshClient({ transport }); expect(client.nodes.byNum(999)).toBeUndefined(); }); + + it("seeds a self-node stub from onMyNodeInfo when no NodeInfo has arrived", () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + + client.events.onMyNodeInfo.dispatch(create(Protobuf.Mesh.MyNodeInfoSchema, { myNodeNum: 42 })); + + const seeded = client.nodes.byNum(42); + expect(seeded).toBeDefined(); + expect(seeded?.num).toBe(42); + expect(seeded?.user).toBeUndefined(); + expect(seeded?.isFavorite).toBe(false); + expect(seeded?.isIgnored).toBe(false); + }); + + it("does not overwrite a richer NodeInfo entry with a later MyNodeInfo seed", () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + + client.events.onNodeInfoPacket.dispatch( + create(Protobuf.Mesh.NodeInfoSchema, { + num: 42, + user: create(Protobuf.Mesh.UserSchema, { id: "!000a", longName: "Alice" }), + }), + ); + client.events.onMyNodeInfo.dispatch(create(Protobuf.Mesh.MyNodeInfoSchema, { myNodeNum: 42 })); + + const node = client.nodes.byNum(42); + expect(node?.user?.longName).toBe("Alice"); + }); + + it("lets a real NodeInfo patch over a seeded self-node stub", () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + + client.events.onMyNodeInfo.dispatch(create(Protobuf.Mesh.MyNodeInfoSchema, { myNodeNum: 42 })); + client.events.onNodeInfoPacket.dispatch( + create(Protobuf.Mesh.NodeInfoSchema, { + num: 42, + user: create(Protobuf.Mesh.UserSchema, { id: "!000a", longName: "Alice" }), + }), + ); + + const node = client.nodes.byNum(42); + expect(node?.user?.longName).toBe("Alice"); + }); + + it("ignores onMyNodeInfo with myNodeNum=0", () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + client.events.onMyNodeInfo.dispatch(create(Protobuf.Mesh.MyNodeInfoSchema, { myNodeNum: 0 })); + expect(client.nodes.list.value).toEqual([]); + }); }); diff --git a/packages/sdk/src/features/nodes/NodesClient.ts b/packages/sdk/src/features/nodes/NodesClient.ts index 9ef7e5d3..08d1425c 100644 --- a/packages/sdk/src/features/nodes/NodesClient.ts +++ b/packages/sdk/src/features/nodes/NodesClient.ts @@ -44,6 +44,25 @@ export class NodesClient { client.events.onNodeInfoPacket.subscribe((info) => this.handleIncoming(info)); + // Seed a self-node stub from MyNodeInfo. Firmware always emits MyNodeInfo + // during the wantConfigId handshake, but not every session includes a + // NodeInfo for the device itself in the config bundle (fresh flash, + // cleared nodeDB, etc.). Without this seed, consumers that look up + // `nodes.list.find(n => n.num === myNodeNum)` — notably the web sidebar — + // sit empty forever. The real NodeInfo / UserPacket / PositionPacket + // handlers above patch the stub when they arrive. + client.events.onMyNodeInfo.subscribe((info) => { + if (info.myNodeNum === 0) return; + if (this.store.has(info.myNodeNum)) return; + const seeded: Node = { + num: info.myNodeNum, + isFavorite: false, + isIgnored: false, + }; + this.store.set(info.myNodeNum, seeded); + void this.repository.upsert(seeded).catch(() => {}); + }); + client.events.onUserPacket.subscribe((packet) => { this.patch(packet.from, { user: packet.data }); });