Device info endpoint + richer node summaries (GET /api/device, MCP device_info) (#13)
CI / build + unit (push) Successful in 36s

This commit was merged in pull request #13.
This commit is contained in:
2026-09-24 22:46:40 +02:00
parent b31fb35464
commit 4beaa377c4
10 changed files with 369 additions and 10 deletions
+58
View File
@@ -46,6 +46,64 @@ export interface NodeInfoFields {
position?: { latitudeI?: number; longitudeI?: number; altitude?: number };
lastHeard?: number;
deviceMetrics?: { batteryLevel?: number };
snr?: number;
viaMqtt?: boolean;
hopsAway?: number;
}
// init shape for create(): all fields optional (protobuf-es' MessageInit is not exported)
type Init<T> = {
[P in keyof T as P extends '$unknown' | '$typeName' ? never : P]?: T[P];
};
/** DeviceMetadata frame as streamed once during the initial sync. */
export function metadataFrame(md: Init<P.Mesh.DeviceMetadata>): Uint8Array {
const fr = create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'metadata', value: md },
});
return frame(toBinary(P.Mesh.FromRadioSchema, fr));
}
// Config/ModuleConfig section frames as streamed one-per-section during the initial sync.
// (meshtastic.Config / meshtastic.ModuleConfig live in their own root namespaces, not under Mesh)
export function configSectionFrame(
sectionCase: P.Config.Config['payloadVariant']['case'],
value: unknown,
): Uint8Array {
const cfg = create(P.Config.ConfigSchema, {
payloadVariant: {
case: sectionCase,
value,
} as P.Config.Config['payloadVariant'],
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'config', value: cfg },
}),
),
);
}
export function moduleConfigSectionFrame(
sectionCase: P.ModuleConfig.ModuleConfig['payloadVariant']['case'],
value: unknown,
): Uint8Array {
const mc = create(P.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: sectionCase,
value,
} as P.ModuleConfig.ModuleConfig['payloadVariant'],
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'moduleConfig', value: mc },
}),
),
);
}
export function nodeInfoFrame(
+12 -2
View File
@@ -64,13 +64,14 @@ async function connectMcp(svc: MeshService): Promise<{
}
describe('MCP server tools', () => {
it('lists all six tools with descriptions and input schemas', async () => {
it('lists all seven tools with descriptions and input schemas', async () => {
const env = await connectedService();
try {
const mcp = await connectMcp(env.svc);
try {
const { tools } = await mcp.client.listTools();
expect(tools.map((t) => t.name).sort()).toEqual([
'device_info',
'get_messages',
'get_node',
'list_channels',
@@ -215,7 +216,7 @@ describe('MCP server tools', () => {
}
});
it('list_channels, list_nodes and get_messages return the service data', async () => {
it('list_channels, list_nodes, get_messages and device_info return the service data', async () => {
const env = await connectedService();
try {
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
@@ -234,6 +235,15 @@ describe('MCP server tools', () => {
),
).toEqual([]); // no channel frames fed yet
// no metadata/config frames fed yet → empty sections, null node num
expect(
JSON.parse(
toolText(
await mcp.client.callTool({ name: 'device_info', arguments: {} }),
),
),
).toEqual({ myNodeNum: null, localConfig: {}, moduleConfig: {} });
const nodes = JSON.parse(
toolText(
await mcp.client.callTool({ name: 'list_nodes', arguments: {} }),
+8
View File
@@ -77,6 +77,14 @@ export function createMeshMcpServer(mesh: MeshService): McpServer {
async () => jsonResult(mesh.messages()),
);
server.registerTool(
'device_info',
{
description: `Device info captured during the initial sync: this device's node number, firmware version and metadata, plus per-section config/module-config (security publicKey/privateKey/adminKey and network wifiPsk redacted as "REDACTED"). In-memory only, reset on every bridge reconnect.${CHANNEL_NOTE}`,
},
async () => jsonResult(mesh.deviceInfo()),
);
return server;
}
+50 -1
View File
@@ -13,6 +13,7 @@ import {
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import type { DeviceInfo } from './mesh.service.js';
import { MeshService } from './mesh.service.js';
export class SendTextDto {
@@ -104,6 +105,51 @@ export class MeshController {
return this.mesh.channels();
}
@Get('device')
@ApiOperation({
summary: 'Device info (metadata + config, keys redacted)',
description:
'This device\'s node number, firmware version and metadata, plus per-section config and module-config messages captured during the initial sync; empty until it completes. Key material is never exposed: security publicKey/privateKey/adminKey and network wifiPsk are replaced with "REDACTED". In-memory only, reset on every bridge reconnect.',
})
@ApiResponse({
status: 200,
description: 'Device info',
example: {
myNodeNum: 123456789,
firmwareVersion: '2.7.0.1',
metadata: {
firmwareVersion: '2.7.0.1',
deviceStateVersion: 1,
canShutdown: true,
hasWifi: false,
hasBluetooth: true,
hasEthernet: false,
role: 3,
positionFlags: 65,
hwModel: 42,
hasRemoteHardware: false,
hasPKC: true,
excludedModules: 0,
},
localConfig: {
device: { role: 3, buttonGpio: 13 },
security: {
publicKey: 'REDACTED',
privateKey: 'REDACTED',
adminKey: 'REDACTED',
isManaged: true,
},
network: { wifiEnabled: false, wifiSsid: '', wifiPsk: 'REDACTED' },
},
moduleConfig: {
telemetry: { environmentUpdateInterval: 900 },
},
},
})
device(): DeviceInfo {
return this.mesh.deviceInfo();
}
@Get('messages')
@ApiOperation({
summary: 'Message history (last 100)',
@@ -141,7 +187,7 @@ export class MeshController {
@ApiOperation({
summary: 'Known mesh nodes (lean summaries)',
description:
'Node database collected during the initial sync plus live POSITION_APP updates; reset on every bridge reconnect. Names, hwModel and batteryLevel appear only when the device reported them.',
'Node database collected during the initial sync plus live POSITION_APP updates; reset on every bridge reconnect. Names, hwModel and batteryLevel appear only when the device reported them; snr/hopsAway/viaMqtt only when carried by the NodeInfo.',
})
@ApiResponse({
status: 200,
@@ -155,6 +201,9 @@ export class MeshController {
batteryLevel: 87,
lastHeard: 1758000000,
position: { lat: 52.1049637, lon: 20.9833152, alt: 123 },
snr: -2.5,
hopsAway: 1,
viaMqtt: true,
},
],
})
+143
View File
@@ -4,7 +4,10 @@ import { describe, expect, it } from 'vitest';
import { BROADCAST_ADDR, extractFrames, frame } from './codec.js';
import {
KAMIL_NUM,
configSectionFrame,
kamilNodeInfo,
metadataFrame,
moduleConfigSectionFrame,
nodeInfoFrame,
startBridge,
waitFor,
@@ -614,3 +617,143 @@ describe('MeshService resolveDest', () => {
}
});
});
describe('MeshService device info', () => {
it('captures DeviceMetadata and exposes firmwareVersion', 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,
);
// before the sync: only the node number (null until myInfo arrives)
expect(svc.deviceInfo()).toEqual({
myNodeNum: null,
localConfig: {},
moduleConfig: {},
});
bridge.sockets[0].write(
metadataFrame({ firmwareVersion: '2.7.0.1', hasWifi: false, role: 3 }),
);
await waitFor(() => svc.deviceInfo().firmwareVersion === '2.7.0.1');
const info = svc.deviceInfo();
expect(info.firmwareVersion).toBe('2.7.0.1');
expect(info.metadata?.firmwareVersion).toBe('2.7.0.1'); // metadata stored as-is
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('captures config/module-config sections and redacts key material', 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,
);
const secret = 'super-secret-wifi-psk';
bridge.sockets[0].write(
configSectionFrame('security', {
publicKey: new Uint8Array([1, 2, 3]),
privateKey: new Uint8Array([4, 5, 6]),
adminKey: [new Uint8Array([7])],
isManaged: true,
}),
);
bridge.sockets[0].write(
configSectionFrame('network', {
wifiEnabled: false,
wifiSsid: 'home',
wifiPsk: secret,
}),
);
bridge.sockets[0].write(configSectionFrame('device', { role: 3 }));
bridge.sockets[0].write(
moduleConfigSectionFrame('telemetry', {
environmentUpdateInterval: 900,
powerMeasurementEnabled: true,
}),
);
await waitFor(
() =>
Object.keys(svc.deviceInfo().localConfig).length === 3 &&
Object.keys(svc.deviceInfo().moduleConfig).length === 1,
);
const info = svc.deviceInfo();
expect(Object.keys(info.localConfig).sort()).toEqual([
'device',
'network',
'security',
]);
expect(info.moduleConfig.telemetry).toMatchObject({
environmentUpdateInterval: 900,
powerMeasurementEnabled: true,
});
// key material is replaced with a marker — never exposed
expect(info.localConfig.security).toMatchObject({
publicKey: 'REDACTED',
privateKey: 'REDACTED',
adminKey: 'REDACTED',
isManaged: true, // non-secret fields pass through
});
expect(info.localConfig.network?.wifiPsk).toBe('REDACTED');
const json = JSON.stringify(info);
expect(json).not.toContain(secret);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('summarizes NodeInfo snr/hopsAway/viaMqtt when present', 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,
);
bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
bridge.sockets[0].write(
nodeInfoFrame(42, {
user: { id: '!ot', longName: 'Other Node' },
snr: -2.5,
hopsAway: 1,
viaMqtt: true,
}),
);
await waitFor(async () => (await svc.nodes()).length === 2);
const nodes = await svc.nodes();
expect(nodes.find((n) => n.num === 42)).toEqual({
num: 42,
longName: 'Other Node',
lastHeard: null,
snr: -2.5,
hopsAway: 1,
viaMqtt: true,
});
// absent in the proto → omitted from the summary
expect(nodes.find((n) => n.num === KAMIL_NUM)).not.toHaveProperty('snr');
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
});
+82 -3
View File
@@ -59,6 +59,35 @@ export interface NodeSummary {
/** epoch seconds of the last heard packet; null when never heard */
lastHeard: number | null;
position?: { lat: number; lon: number; alt?: number };
/** signal-to-noise ratio of the last received packet, dB (NodeInfo.snr) */
snr?: number;
/** hops from this node to the one that reported it (optional in proto) */
hopsAway?: number;
/** true when the node info arrived via MQTT rather than LoRa */
viaMqtt?: boolean;
}
/** Shape of deviceInfo() output: sync-captured device data, key material redacted. */
export interface DeviceInfo {
myNodeNum: number | null;
firmwareVersion?: string;
metadata?: Proto.Mesh.DeviceMetadata;
localConfig: Record<string, Record<string, unknown>>;
moduleConfig: Record<string, Record<string, unknown>>;
}
// key material must never leave the process — replaced with a marker so callers can see the field exists
const REDACTED = 'REDACTED';
/** Config sections that carry secrets: security (PKI keys) and network (wifi psk). */
function redactSection(
name: string,
value: Record<string, unknown>,
): Record<string, unknown> {
if (name !== 'security' && name !== 'network') return value;
const out = { ...value };
for (const key of ['publicKey', 'privateKey', 'adminKey', 'wifiPsk'])
if (out[key] !== undefined) out[key] = REDACTED;
return out;
}
@Injectable()
@@ -88,6 +117,10 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
>();
// node database: NodeInfo frames from the initial sync + live POSITION_APP updates
private nodeMap = new Map<number, NodeEntry>();
// device metadata + per-section config/module-config streamed during the initial sync
private metadata: Proto.Mesh.DeviceMetadata | null = null;
private configSections = new Map<string, Record<string, unknown>>();
private moduleConfigSections = new Map<string, Record<string, unknown>>();
// message history (newest kept, capped at HISTORY_LIMIT) + pending tx records awaiting their routing ACK/NACK
private history: MessageRecord[] = [];
private pendingTx = new Map<number, MessageRecord>();
@@ -132,9 +165,33 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
}));
}
/**
* Device info captured during the initial sync: this device's node number, firmware version and
* metadata (DeviceMetadata as-is — it carries no key material), plus per-section config and
* module-config messages keyed by section name. In-memory only; empty until the sync completes,
* reset on every bridge reconnect. Key material is redacted: security publicKey/privateKey/adminKey
* and network wifiPsk are replaced with "REDACTED" — never exposed over HTTP.
*/
deviceInfo(): DeviceInfo {
const out: DeviceInfo = {
myNodeNum: this.myNodeNum,
localConfig: {},
moduleConfig: {},
};
if (this.metadata) {
out.firmwareVersion = this.metadata.firmwareVersion;
out.metadata = this.metadata;
}
for (const [name, value] of this.configSections)
out.localConfig[name] = redactSection(name, value);
for (const [name, value] of this.moduleConfigSections)
out.moduleConfig[name] = value;
return out;
}
/**
* Lean summaries of all known nodes, sorted by node number.
* Names/hwModel/batteryLevel appear only when the device reported them;
* Names/hwModel/batteryLevel/snr/hopsAway/viaMqtt appear only when the device reported them;
* `lastHeard` is epoch seconds (null = never heard); `position` only when both coordinates are known.
*/
async nodes(): Promise<NodeSummary[]> {
@@ -247,9 +304,13 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
// omit UNSET (enum value 0) rather than report "UNSET"
const hwName = n.user?.hwModel ? names.get(n.user.hwModel) : undefined;
if (hwName && hwName !== 'UNSET') s.hwModel = hwName;
// optional proto field: undefined when the device never reported metrics
// optional proto fields: undefined when the device never reported them
const battery = n.deviceMetrics?.batteryLevel;
if (battery !== undefined) s.batteryLevel = battery;
// non-optional proto scalars decode with their defaults (snr=0, viaMqtt=false), so a default value means "not reported"
if (n.snr !== undefined && n.snr !== 0) s.snr = n.snr;
if (n.hopsAway !== undefined) s.hopsAway = n.hopsAway; // optional in proto → truly absent when not reported
if (n.viaMqtt === true) s.viaMqtt = true;
const p = n.position;
if (p?.latitudeI !== undefined && p.longitudeI !== undefined) {
// alt included only when the device sent it — protobuf-es leaves absent optionals undefined, so an explicit 0 (sea level) is kept
@@ -265,9 +326,12 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
this.socket = socket;
socket.on('connect', () => {
this.connected = true;
// fresh sync on every (re)connect — drop stale channels/nodes/history from the old session
// fresh sync on every (re)connect — drop stale channels/nodes/device info/history from the old session
this.channelMap = new Map();
this.nodeMap = new Map();
this.metadata = null;
this.configSections.clear();
this.moduleConfigSections.clear();
this.history = [];
this.pendingTx.clear();
this.logger.log(`bridge connected ${this.host}:${this.port}`);
@@ -328,6 +392,21 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
// device streams the whole node DB during initial sync
this.nodeMap.set(v.value.num, v.value);
break;
case 'metadata':
// DeviceMetadata streamed once during initial sync (no key material in it)
this.metadata = v.value;
break;
case 'config': {
// device streams one Config per section during initial sync
const sec = v.value.payloadVariant;
if (sec?.case) this.configSections.set(sec.case, sec.value);
break;
}
case 'moduleConfig': {
const mod = v.value.payloadVariant;
if (mod?.case) this.moduleConfigSections.set(mod.case, mod.value);
break;
}
case 'configCompleteId':
this.logger.log(
`bridge ready (config complete), ${this.channelMap.size} channels, ${this.nodeMap.size} nodes`,