fix(sdk/nodes): seed self-node stub from MyNodeInfo

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.
This commit is contained in:
Dan Ditomaso
2026-05-27 22:29:40 -04:00
parent bf5648cc02
commit f67b8b898e
2 changed files with 72 additions and 0 deletions

View File

@@ -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([]);
});
});

View File

@@ -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 });
});