mesh: update node batteryLevel from live NODEINFO_APP packets

battery_level was only populated during the initial sync; NodeInfo
frames streamed as NODEINFO_APP (port 4) now refresh deviceMetrics in
the node database so /api/nodes and downstream consumers see current
values between syncs. decodeNodeInfo + PORT_NODEINFO_APP added to the
codec, one branch in the existing packet-handling path.
This commit is contained in:
2026-09-25 12:39:45 +00:00
parent 6ce0b1ae7b
commit 8ddd91d4b5
3 changed files with 100 additions and 0 deletions
+71
View File
@@ -44,6 +44,36 @@ function positionPacketFrame(
);
}
// live node-info update: NODEINFO_APP packet with a serialized NodeInfo in Data.payload
function nodeInfoPacketFrame(
from: number,
fields: {
user?: { id?: string; longName?: string };
deviceMetrics?: { batteryLevel?: number };
},
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.NODEINFO_APP,
payload: toBinary(
P.Mesh.NodeInfoSchema,
create(P.Mesh.NodeInfoSchema, { num: from, ...fields }),
),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from,
id: 1,
payloadVariant: { case: 'decoded', value: data },
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'packet', value: pkt },
}),
),
);
}
// inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload
function textPacketFrame(
from: number,
@@ -418,6 +448,47 @@ describe('MeshService node database', () => {
}
});
it('updates batteryLevel from live NODEINFO_APP packets (and stubs unknown nodes)', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
// sync entry with a stale battery, then a live NODEINFO_APP update
bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
await waitFor(async () => (await svc.nodes()).length === 1);
expect((await svc.nodes())[0]?.batteryLevel).toBe(87);
bridge.sockets[0].write(
nodeInfoPacketFrame(KAMIL_NUM, { deviceMetrics: { batteryLevel: 9 } }),
);
await waitFor(async () => (await svc.nodes())[0]?.batteryLevel === 9);
// other fields of the sync entry survive the live update
expect(await svc.node(KAMIL_NUM)).toMatchObject({
longName: 'Kamil SP8MK/P',
shortName: 'KK',
batteryLevel: 9,
});
// unknown node: a NODEINFO_APP with deviceMetrics creates a stub entry
bridge.sockets[0].write(
nodeInfoPacketFrame(555, { deviceMetrics: { batteryLevel: 3 } }),
);
await waitFor(async () => (await svc.nodes()).length === 2);
expect((await svc.node(555))?.batteryLevel).toBe(3);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('looks up nodes by exact num, numeric string, exact name and unique substring', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';