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
+10
View File
@@ -12,6 +12,8 @@ export const BROADCAST_ADDR = 0xffffffff;
export const PORT_TEXT_MESSAGE_APP = 1; export const PORT_TEXT_MESSAGE_APP = 1;
// verified against installed @meshtastic/protobufs Portnums.PortNum.POSITION_APP // verified against installed @meshtastic/protobufs Portnums.PortNum.POSITION_APP
export const PORT_POSITION_APP = 3; 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 // verified against installed @meshtastic/protobufs Portnums.PortNum.ROUTING_APP
export const PORT_ROUTING_APP = 5; export const PORT_ROUTING_APP = 5;
// verified against installed @meshtastic/protobufs Portnums.PortNum.TELEMETRY_APP // verified against installed @meshtastic/protobufs Portnums.PortNum.TELEMETRY_APP
@@ -75,6 +77,14 @@ export async function decodePosition(
return fromBinary(P.Mesh.PositionSchema, bytes); 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<import('@meshtastic/protobufs').Mesh.NodeInfo> {
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). */ /** ROUTING_APP packets carry a serialized meshtastic.Routing in Data.payload (ACK/NACK for want_ack sends). */
export async function decodeRouting( export async function decodeRouting(
bytes: Uint8Array, bytes: Uint8Array,
+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 // inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload
function textPacketFrame( function textPacketFrame(
from: number, 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 () => { it('looks up nodes by exact num, numeric string, exact name and unique substring', async () => {
const bridge = await startBridge(); const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1'; process.env.MESH_HOST = '127.0.0.1';
+19
View File
@@ -11,6 +11,7 @@ import type { DatabaseSync } from 'node:sqlite';
import type * as Proto from '@meshtastic/protobufs'; import type * as Proto from '@meshtastic/protobufs';
import { import {
BROADCAST_ADDR, BROADCAST_ADDR,
PORT_NODEINFO_APP,
PORT_POSITION_APP, PORT_POSITION_APP,
PORT_ROUTING_APP, PORT_ROUTING_APP,
PORT_TELEMETRY_APP, PORT_TELEMETRY_APP,
@@ -18,6 +19,7 @@ import {
buildTextToRadio, buildTextToRadio,
buildWantConfigToRadio, buildWantConfigToRadio,
decodeFromRadio, decodeFromRadio,
decodeNodeInfo,
decodePosition, decodePosition,
decodeRouting, decodeRouting,
decodeTelemetry, decodeTelemetry,
@@ -676,6 +678,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
position: pos, 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) { } else if (decoded && decoded.portnum === PORT_ROUTING_APP) {
// ACK/NACK for our want_ack sends: Routing serialized in Data.payload, // ACK/NACK for our want_ack sends: Routing serialized in Data.payload,
// Data.requestId = the original packet id we sent // Data.requestId = the original packet id we sent