diff --git a/AGENTS.md b/AGENTS.md index d19d8c0..c5f4bcb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,8 +17,8 @@ ## API 1. **Live base URL** — `http://bot.dev.kkhost.pl` (Traefik → docker-compose stack on this host; container port 3000 via external network dev_net, no published ports). -2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: }` — numbers pass through as-is (integer 1..0xffffffff), names resolve like `GET /api/nodes/:id`, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. Returns `{ id }`), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `GET /api/messages` (message history newest first: rx text with `from`, tx sends with `to` + `status` pending→delivered/failed via routing ACKs — broadcasts stay pending because firmware strips want_ack; last 100, in-memory, reset on reconnect), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? } }[]`, from the initial sync plus live POSITION_APP updates; reset on reconnect) and `GET /api/nodes/:id` (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. All `POST /api/send` traffic targets channel index 3 (`test`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `3`). -3. **MCP endpoint** — `POST /mcp` (excluded from the `/api` prefix in main.ts, like Scalar at `/`): an in-process MCP server over the official SDK's Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`, fresh `McpServer` + transport per request). Tools map 1:1 to MeshService methods: `mesh_status`, `send_message(text, to?)` (same resolution as `POST /api/send`; errors surface as tool errors with the same messages), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), `get_messages`. Results are JSON in a text content block; sends ride channel index 3 like everything else. Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (`@modelcontextprotocol/sdk/server/mcp.js` etc.). +2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: }` — numbers pass through as-is (integer 1..0xffffffff), names resolve like `GET /api/nodes/:id`, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. Returns `{ id }`), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `GET /api/device` (device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {
: ...}, moduleConfig: {: ...} }` — DeviceMetadata as-is plus per-section Config/ModuleConfig messages; security publicKey/privateKey/adminKey and network wifiPsk redacted to `"REDACTED"`; in-memory, reset on reconnect), `GET /api/messages` (message history newest first: rx text with `from`, tx sends with `to` + `status` pending→delivered/failed via routing ACKs — broadcasts stay pending because firmware strips want_ack; last 100, in-memory, reset on reconnect), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, from the initial sync plus live POSITION_APP updates; reset on reconnect) and `GET /api/nodes/:id` (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. All `POST /api/send` traffic targets channel index 3 (`test`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `3`). +3. **MCP endpoint** — `POST /mcp` (excluded from the `/api` prefix in main.ts, like Scalar at `/`): an in-process MCP server over the official SDK's Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`, fresh `McpServer` + transport per request). Tools map 1:1 to MeshService methods: `mesh_status`, `send_message(text, to?)` (same resolution as `POST /api/send`; errors surface as tool errors with the same messages), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), `get_messages`, `device_info` (same redaction as `GET /api/device`). Results are JSON in a text content block; sends ride channel index 3 like everything else. Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (`@modelcontextprotocol/sdk/server/mcp.js` etc.). ## Other diff --git a/CHANGELOG.md b/CHANGELOG.md index e3b7d0f..04ed79c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## v0.7.0 — 2026-09-24 20:21 + +- New `GET /api/device` endpoint (and matching MCP tool `device_info`, now seven tools): device info captured during the initial sync — this device's node number, firmware version and metadata (`meshtastic.DeviceMetadata` as-is; it carries no key material), plus per-section config and module-config messages keyed by section name. In-memory only, reset on every bridge reconnect like channels/nodes. +- `MeshService.deviceInfo()` captures the new FromRadio cases during sync: `metadata` (DeviceMetadata), `config` (one `meshtastic.Config` per section — oneof `payloadVariant` cases device/position/power/network/display/lora/bluetooth/security/sessionkey/deviceUi) and `moduleConfig` (one ModuleConfig per module). Verified against the installed `@meshtastic/protobufs` 2.8.0: FromRadio carries `meshtastic.Config`, not `LocalConfig` (that type lives in a separate localonly namespace and is not part of FromRadio). +- **Mandatory redaction (trust boundary):** key material never leaves the process — security section `publicKey`/`privateKey`/`adminKey` and network section `wifiPsk` are replaced with `"REDACTED"` in all output. +- Node summaries (`GET /api/nodes`, `GET /api/nodes/:id`, MCP `list_nodes`/`get_node`) gain optional fields from the NodeInfo: `snr` (dB), `hopsAway`, `viaMqtt` — present only when the device reported them. Note: `meshtastic.NodeInfo` in 2.8.0 has no firmware version field, so per-node firmwareVersion is not captured (device-level firmware version comes from DeviceMetadata via `/api/device`). +- Swagger: `GET /api/device` documented with the redaction note and an example; nodes endpoint example/description extended for the new optional fields. README + AGENTS.md endpoint lists updated. +- Tests: fake-bridge gains `metadataFrame`, `configSectionFrame`, `moduleConfigSectionFrame`; new device-info specs (metadata → firmwareVersion, config/module-config sections captured with security key material absent from the JSON output), NodeInfo snr/hopsAway/viaMqtt in summaries; MCP spec updated to seven tools incl. a `device_info` call check. + ## v0.6.0 — 2026-09-24 18:28 - New MCP (Model Context Protocol) server endpoint at `POST /mcp` (outside the `/api` prefix, like Scalar at `/`), served in-process via the official `@modelcontextprotocol/sdk` Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`; a fresh `McpServer` + transport per request). Six tools map 1:1 to existing `MeshService` methods with no new logic: `mesh_status`, `send_message(text, to?)` (same target resolution as `POST /api/send` — omitted/""/"!b"/"broadcast" → broadcast, numbers pass through, names via the node DB; resolution errors surface as tool errors with the same messages), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), and `get_messages`. Tool results are JSON in a text content block. diff --git a/README.md b/README.md index 29d6784..a6873e0 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,9 @@ All API endpoints live under the `/api` prefix: - `GET /api/status` — `{ connected, myNodeNum, host, port }` - `POST /api/send` — body `{ "text": "...", "to"?: }`, returns `{ id }`. Numbers pass through as-is (integer in 1..0xffffffff); names resolve like `GET /api/nodes/:id` (exact long/short name, then unique substring) — unknown or ambiguous targets are a 400 - `GET /api/channels` — the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` (psk/keys are redacted; empty until the initial sync completes) +- `GET /api/device` — device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {
: ...}, moduleConfig: {: ...} }`. Key material is never exposed (security publicKey/privateKey/adminKey and network wifiPsk are replaced with `"REDACTED"`); in-memory only, reset on every bridge reconnect - `GET /api/messages` — message history, newest first: inbound text (`dir:'rx'`, with `from`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` (with human-readable `ackError`) when the routing ACK/NACK arrives; broadcasts stay `pending` because firmware strips want_ack before TX. In-memory only, last 100, reset on every bridge reconnect -- `GET /api/nodes` — known mesh nodes as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? } }[]`, sorted by node number. Collected during the initial sync plus live position updates; names/hwModel/battery appear only when reported, `lastHeard` is epoch seconds (null = never heard), and `position` only when both coordinates are known +- `GET /api/nodes` — known mesh nodes as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, sorted by node number. Collected during the initial sync plus live position updates; names/hwModel/battery appear only when reported, `lastHeard` is epoch seconds (null = never heard), `position` only when both coordinates are known, and snr/hopsAway/viaMqtt only when carried by the NodeInfo - `GET /api/nodes/:id` — one node looked up by exact node number (numbers or numeric strings), then case-insensitive exact long/short name, else the unique case-insensitive substring match; 404 when nothing or more than one node matches Interactive API docs (Scalar) are served at `/`, with the OpenAPI JSON at @@ -40,6 +41,7 @@ curl http://localhost:3000/api/status curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"text":"hi"}' curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"to":123456789,"text":"hello"}' curl http://localhost:3000/api/channels +curl http://localhost:3000/api/device curl http://localhost:3000/api/messages curl http://localhost:3000/api/nodes curl http://localhost:3000/api/nodes/kamil @@ -66,6 +68,7 @@ endpoints — no new logic, same channel pinning: - `list_nodes` — known nodes as lean summaries - `get_node(id)` — one node by number or name; tool error when missing/ambiguous - `get_messages` — message history, newest first (last 100, tx records carry delivery status) +- `device_info` — device info from the initial sync: node number, firmware version, metadata and per-section config/module-config (key material redacted as `"REDACTED"`) Point any MCP client at it (Streamable HTTP URL): diff --git a/package.json b/package.json index bb36def..32b3277 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.6.0", + "version": "0.7.0", "description": "", "author": "", "private": true, diff --git a/src/mesh/fake-bridge.ts b/src/mesh/fake-bridge.ts index 7de22b4..0737816 100644 --- a/src/mesh/fake-bridge.ts +++ b/src/mesh/fake-bridge.ts @@ -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 = { + [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): 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( diff --git a/src/mesh/mcp.controller.spec.ts b/src/mesh/mcp.controller.spec.ts index 714cf11..f9a2b81 100644 --- a/src/mesh/mcp.controller.spec.ts +++ b/src/mesh/mcp.controller.spec.ts @@ -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: {} }), diff --git a/src/mesh/mcp.controller.ts b/src/mesh/mcp.controller.ts index 4c79684..742047e 100644 --- a/src/mesh/mcp.controller.ts +++ b/src/mesh/mcp.controller.ts @@ -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; } diff --git a/src/mesh/mesh.controller.ts b/src/mesh/mesh.controller.ts index 884df12..4265cf9 100644 --- a/src/mesh/mesh.controller.ts +++ b/src/mesh/mesh.controller.ts @@ -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, }, ], }) diff --git a/src/mesh/mesh.service.spec.ts b/src/mesh/mesh.service.spec.ts index 4e5051a..b4c1b92 100644 --- a/src/mesh/mesh.service.spec.ts +++ b/src/mesh/mesh.service.spec.ts @@ -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(); + } + }); +}); diff --git a/src/mesh/mesh.service.ts b/src/mesh/mesh.service.ts index 53369c4..acf5558 100644 --- a/src/mesh/mesh.service.ts +++ b/src/mesh/mesh.service.ts @@ -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>; + moduleConfig: Record>; +} + +// 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, +): Record { + 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(); + // device metadata + per-section config/module-config streamed during the initial sync + private metadata: Proto.Mesh.DeviceMetadata | null = null; + private configSections = new Map>(); + private moduleConfigSections = new Map>(); // message history (newest kept, capped at HISTORY_LIMIT) + pending tx records awaiting their routing ACK/NACK private history: MessageRecord[] = []; private pendingTx = new Map(); @@ -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 { @@ -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`,