From 8ddd91d4b5ce495e9108703649742b0006897233 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 25 Sep 2026 12:39:45 +0000 Subject: [PATCH] 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. --- src/mesh/codec.ts | 10 +++++ src/mesh/mesh.service.spec.ts | 71 +++++++++++++++++++++++++++++++++++ src/mesh/mesh.service.ts | 19 ++++++++++ 3 files changed, 100 insertions(+) diff --git a/src/mesh/codec.ts b/src/mesh/codec.ts index 68f51e6..cebc3ef 100644 --- a/src/mesh/codec.ts +++ b/src/mesh/codec.ts @@ -12,6 +12,8 @@ export const BROADCAST_ADDR = 0xffffffff; export const PORT_TEXT_MESSAGE_APP = 1; // verified against installed @meshtastic/protobufs Portnums.PortNum.POSITION_APP export const PORT_POSITION_APP = 3; +// verified against installed @meshtastic/protobufs Portnums.PortNum.NODEINFO_APP +export const PORT_NODEINFO_APP = 4; // verified against installed @meshtastic/protobufs Portnums.PortNum.ROUTING_APP export const PORT_ROUTING_APP = 5; // verified against installed @meshtastic/protobufs Portnums.PortNum.TELEMETRY_APP @@ -75,6 +77,14 @@ export async function decodePosition( return fromBinary(P.Mesh.PositionSchema, bytes); } +/** NODEINFO_APP packets carry a serialized meshtastic.NodeInfo in Data.payload (live node info; deviceMetrics carries battery_level). */ +export async function decodeNodeInfo( + bytes: Uint8Array, +): Promise { + const P = await loadProto(); + return fromBinary(P.Mesh.NodeInfoSchema, bytes); +} + /** ROUTING_APP packets carry a serialized meshtastic.Routing in Data.payload (ACK/NACK for want_ack sends). */ export async function decodeRouting( bytes: Uint8Array, diff --git a/src/mesh/mesh.service.spec.ts b/src/mesh/mesh.service.spec.ts index cc0699a..fc10c7c 100644 --- a/src/mesh/mesh.service.spec.ts +++ b/src/mesh/mesh.service.spec.ts @@ -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'; diff --git a/src/mesh/mesh.service.ts b/src/mesh/mesh.service.ts index 3cfeba0..349f4d8 100644 --- a/src/mesh/mesh.service.ts +++ b/src/mesh/mesh.service.ts @@ -11,6 +11,7 @@ import type { DatabaseSync } from 'node:sqlite'; import type * as Proto from '@meshtastic/protobufs'; import { BROADCAST_ADDR, + PORT_NODEINFO_APP, PORT_POSITION_APP, PORT_ROUTING_APP, PORT_TELEMETRY_APP, @@ -18,6 +19,7 @@ import { buildTextToRadio, buildWantConfigToRadio, decodeFromRadio, + decodeNodeInfo, decodePosition, decodeRouting, decodeTelemetry, @@ -676,6 +678,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { position: pos, }); } + } else if (decoded && decoded.portnum === PORT_NODEINFO_APP) { + // live node-info update: NodeInfo serialized in Data.payload; deviceMetrics carries + // battery_level so /api/nodes and the battery alerts see fresh values between syncs + let ni: Proto.Mesh.NodeInfo | undefined; + try { + ni = await decodeNodeInfo(decoded.payload); + } catch { + ni = undefined; // malformed payload — skip, keep whatever we had + } + if (ni?.deviceMetrics !== undefined) { + const num = pkt.from; + this.nodeMap.set(num, { + ...this.nodeMap.get(num), + num, + deviceMetrics: ni.deviceMetrics, + }); + } } else if (decoded && decoded.portnum === PORT_ROUTING_APP) { // ACK/NACK for our want_ack sends: Routing serialized in Data.payload, // Data.requestId = the original packet id we sent