From 529230378668df35744f17b2dfc996eeb2f316d8 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Thu, 24 Sep 2026 15:37:03 +0200 Subject: [PATCH] Node database: collect nodes during sync, expose GET /api/nodes (#7) --- AGENTS.md | 2 +- CHANGELOG.md | 8 ++ README.md | 4 + package.json | 2 +- src/main.ts | 3 +- src/mesh/codec.ts | 25 ++++ src/mesh/mesh.controller.ts | 106 ++++++++++++++++- src/mesh/mesh.service.spec.ts | 211 +++++++++++++++++++++++++++++++++- src/mesh/mesh.service.ts | 119 ++++++++++++++++++- 9 files changed, 468 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 53cf6da..ed93c6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ ## 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"?: }`, returns `{ id }`), and `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted). 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 2 (`testowy`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `2`). +2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: }`, returns `{ id }`), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `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 2 (`testowy`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `2`). ## Other diff --git a/CHANGELOG.md b/CHANGELOG.md index ee6c384..50612bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v0.2.0 — 2026-09-24 13:08 + +- Node database collected during the initial sync (`FromRadio.nodeInfo` frames, upserted by node number) and kept fresh by live `POSITION_APP` packets (the Position is serialized in `Data.payload`; unknown senders get a stub entry). The map resets on every bridge reconnect alongside channels. +- New endpoints: `GET /api/nodes` — lean summaries sorted by node number as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard (epoch secs | null), position? { lat, lon, alt? } }[]`; names/hwModel/battery appear only when the device reported them, `hwModel` is the enum's string name (UNSET omitted), and `position` only when both coordinates are known. `GET /api/nodes/:id` — lookup by exact node number (numbers/numeric strings), then case-insensitive exact long/short name, else unique case-insensitive substring; 404 with a clear message when nothing or more than one node matches. +- Codec: new `PORT_POSITION_APP = 3` constant (verified against the installed `@meshtastic/protobufs` PortNum enum), `decodePosition()` for POSITION_APP payloads, and `loadHwModelNames()` reverse lookup of the HardwareModel enum. +- Swagger docs made genuinely useful: `Mesh` tag on the controller (description via `DocumentBuilder.addTag` — v12's `ApiTagOptions` has no description field) plus `@ApiOperation` summary/description and `@ApiResponse` examples on all endpoints, existing and new. +- Unit tests build real framed FromRadio bytes for nodeInfo/POSITION_APP: sync collection + summary shape (lat/lon = value × 1e-7, battery), stub entry from an unknown POSITION_APP sender, lookup ladder (exact num / numeric string / exact name / unique substring) with ambiguous and missing → null, and reconnect reset. + ## v0.1.4 — 2026-09-23 20:33 - Send a `ToRadio` with `want_config_id` (random non-zero uint32 nonce) on every bridge connect, so the firmware streams its initial sync data (`myInfo`, channels, `config_complete_id`) — previously nothing was written on connect, which is why `/api/channels` stayed empty and `myNodeNum` stayed null in production. New codec helper `buildWantConfigToRadio`; unit test captures the outbound frame on the fake bridge, asserts the `wantConfigId` oneof case with a non-zero value, then feeds back `myInfo` + `configCompleteId` frames and checks service state. diff --git a/README.md b/README.md index 6d9b799..2b74701 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ All API endpoints live under the `/api` prefix: - `GET /api/status` — `{ connected, myNodeNum, host, port }` - `POST /api/send` — body `{ "text": "...", "to"?: }`, returns `{ id }` - `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/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/: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 `/docs-json`. @@ -37,6 +39,8 @@ 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/nodes +curl http://localhost:3000/api/nodes/kamil ``` `to` omitted, `"!b"`, or `"broadcast"` sends to the broadcast address `0xffffffff`. diff --git a/package.json b/package.json index 2234d9b..f8aee71 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.1.4", + "version": "0.2.0", "description": "", "author": "", "private": true, diff --git a/src/main.ts b/src/main.ts index f5cde61..efe0f44 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,8 +15,9 @@ async function bootstrap() { const docConfig = new DocumentBuilder() .setTitle('MeshBot API') .setDescription( - 'Meshtastic bridge bot: status, send text messages, list device channels.', + 'Meshtastic bridge bot: status, send text messages, list device channels and known nodes.', ) + .addTag('Mesh', 'Meshtastic bridge control and telemetry') .setVersion(pkg.version) .build(); const document = SwaggerModule.createDocument(app, docConfig); diff --git a/src/mesh/codec.ts b/src/mesh/codec.ts index cc3689f..a51bba6 100644 --- a/src/mesh/codec.ts +++ b/src/mesh/codec.ts @@ -10,6 +10,8 @@ async function loadProto(): Promise { export const BROADCAST_ADDR = 0xffffffff; // official portnums.proto: TEXT_MESSAGE_APP=1 (task spec draft said 3 — the enum value wins) export const PORT_TEXT_MESSAGE_APP = 1; +// verified against installed @meshtastic/protobufs Portnums.PortNum.POSITION_APP +export const PORT_POSITION_APP = 3; const START1 = 0x94; const START2 = 0xc3; @@ -61,6 +63,29 @@ export async function decodeFromRadio( return fromBinary(P.Mesh.FromRadioSchema, bytes); } +/** POSITION_APP packets carry a serialized meshtastic.Position in Data.payload. */ +export async function decodePosition( + bytes: Uint8Array, +): Promise { + const P = await loadProto(); + return fromBinary(P.Mesh.PositionSchema, bytes); +} + +let hwModelNames: Map | null = null; +/** Reverse lookup of meshtastic.HardwareModel (enum value -> name), loaded once. */ +export async function loadHwModelNames(): Promise> { + if (!hwModelNames) { + const P = await loadProto(); + hwModelNames = new Map( + Object.entries(P.Mesh.HardwareModel).map(([k, v]) => [ + Number(k), + String(v), + ]), + ); + } + return hwModelNames; +} + export async function buildTextToRadio( to: number, text: string, diff --git a/src/mesh/mesh.controller.ts b/src/mesh/mesh.controller.ts index a274915..1e49d49 100644 --- a/src/mesh/mesh.controller.ts +++ b/src/mesh/mesh.controller.ts @@ -3,9 +3,16 @@ import { Body, Controller, Get, + NotFoundException, + Param, Post, } from '@nestjs/common'; -import { ApiOperation, ApiProperty } from '@nestjs/swagger'; +import { + ApiOperation, + ApiProperty, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { BROADCAST_ADDR } from './codec.js'; import { MeshService } from './mesh.service.js'; @@ -21,18 +28,45 @@ export class SendTextDto { to?: string | number; } +// tag description is set via DocumentBuilder.addTag in main.ts (v12 ApiTagOptions has no description) +@ApiTags('Mesh') @Controller() export class MeshController { constructor(private readonly mesh: MeshService) {} @Get('status') - @ApiOperation({ summary: 'Bridge connection status' }) + @ApiOperation({ + summary: 'Bridge connection status', + description: + 'Whether the bot is connected to the meshtastic-serial-bridge and which node number this device has.', + }) + @ApiResponse({ + status: 200, + description: 'Connection status', + example: { + connected: true, + myNodeNum: 123456789, + host: '192.168.1.4', + port: 4403, + }, + }) status() { return this.mesh.status(); } @Post('send') - @ApiOperation({ summary: 'Send a text message over the mesh' }) + @ApiOperation({ + summary: 'Send a text message over the mesh', + description: + 'Queues a TEXT_MESSAGE_APP packet on the pinned test channel (index 2, "testowy" — never the primary channel). Returns the Meshtastic packet id.', + }) + @ApiResponse({ + status: 200, + description: 'Packet queued', + example: { id: 123456789 }, + }) + @ApiResponse({ status: 400, description: 'Invalid text or target' }) + @ApiResponse({ status: 503, description: 'Bridge not connected' }) async send(@Body() body: SendTextDto) { if (typeof body?.text !== 'string' || body.text.length === 0) { throw new BadRequestException('text must be a non-empty string'); @@ -53,8 +87,74 @@ export class MeshController { @Get('channels') @ApiOperation({ summary: "The device's Meshtastic channels (keys redacted)", + description: + 'Channel table streamed during the initial sync; empty until it completes. psk/keys are never exposed.', + }) + @ApiResponse({ + status: 200, + description: 'Channels sorted by index', + example: [ + { index: 0, name: 'Default', uplinkEnabled: true, downlinkEnabled: true }, + { + index: 2, + name: 'testowy', + uplinkEnabled: false, + downlinkEnabled: false, + }, + ], }) channels() { return this.mesh.channels(); } + + @Get('nodes') + @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.', + }) + @ApiResponse({ + status: 200, + description: 'Nodes sorted by node number', + example: [ + { + num: 123456789, + longName: 'Kamil SP8MK/P', + shortName: 'KK', + hwModel: 'TBEAM', + batteryLevel: 87, + lastHeard: 1758000000, + position: { lat: 52.1049637, lon: 20.9833152, alt: 123 }, + }, + ], + }) + async nodes() { + return this.mesh.nodes(); + } + + @Get('nodes/:id') + @ApiOperation({ + summary: 'Look up one node by number or name', + description: + 'Exact node number first (numbers and numeric strings), then case-insensitive exact long/short name, else the unique case-insensitive substring match across names. 404 when nothing or more than one node matches.', + }) + @ApiResponse({ + status: 200, + description: 'The matching node', + example: { + num: 123456789, + longName: 'Kamil SP8MK/P', + shortName: 'KK', + lastHeard: null, + }, + }) + @ApiResponse({ + status: 404, + description: 'No node (or more than one) matching the given id or name', + }) + async node(@Param('id') id: string) { + const n = await this.mesh.node(id); + if (!n) throw new NotFoundException(`no node matching '${id}'`); + return n; + } } diff --git a/src/mesh/mesh.service.spec.ts b/src/mesh/mesh.service.spec.ts index 851b060..d5d504e 100644 --- a/src/mesh/mesh.service.spec.ts +++ b/src/mesh/mesh.service.spec.ts @@ -27,14 +27,74 @@ function startBridge(onSocket?: (s: net.Socket) => void): Promise<{ }); } -async function waitFor(cond: () => boolean, timeoutMs = 5000): Promise { +async function waitFor( + cond: () => boolean | Promise, + timeoutMs = 5000, +): Promise { const t0 = Date.now(); - while (!cond()) { + while (!(await cond())) { if (Date.now() - t0 > timeoutMs) throw new Error('timed out waiting'); await new Promise((r) => setTimeout(r, 10)); } } +function nodeInfoFrame( + num: number, + fields?: Partial, +): Uint8Array { + const ni = create(P.Mesh.NodeInfoSchema, { num, ...fields }); + return frame( + toBinary( + P.Mesh.FromRadioSchema, + create(P.Mesh.FromRadioSchema, { + payloadVariant: { case: 'nodeInfo', value: ni }, + }), + ), + ); +} + +// live position update: POSITION_APP packet with a serialized Position in Data.payload +function positionPacketFrame( + from: number, + pos: Partial, +): Uint8Array { + const data = create(P.Mesh.DataSchema, { + portnum: P.Portnums.PortNum.POSITION_APP, + payload: toBinary( + P.Mesh.PositionSchema, + create(P.Mesh.PositionSchema, pos), + ), + }); + 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 }, + }), + ), + ); +} + +const KAMIL_NUM = 123456789; +function kamilNodeInfo(): Partial { + return { + user: { + id: '!kamil', + longName: 'Kamil SP8MK/P', + shortName: 'KK', + hwModel: P.Mesh.HardwareModel.TBEAM, + }, + position: { latitudeI: 521049637, longitudeI: 209833152, altitude: 123 }, + lastHeard: 1758000000, + deviceMetrics: { batteryLevel: 87 }, + }; +} + function channelFrame( index: number, settings?: { name?: string; uplinkEnabled?: boolean }, @@ -230,3 +290,150 @@ describe('MeshService send', () => { } }); }); + +describe('MeshService node database', () => { + it('collects NodeInfo during sync and summarizes 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, + ); + + bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); + bridge.sockets[0].write(nodeInfoFrame(987654321)); // bare entry: no user/position/metrics yet + + await waitFor(async () => (await svc.nodes()).length === 2); + expect(await svc.nodes()).toEqual([ + { + num: KAMIL_NUM, + longName: 'Kamil SP8MK/P', + shortName: 'KK', + hwModel: 'TBEAM', + batteryLevel: 87, + lastHeard: 1758000000, + position: { lat: 521049637 * 1e-7, lon: 209833152 * 1e-7, alt: 123 }, + }, + { num: 987654321, lastHeard: null }, // names/hwModel/battery/position omitted when absent + ]); + } finally { + svc.onModuleDestroy(); + bridge.close(); + } + }); + + it('creates a stub entry for POSITION_APP packets from 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, + ); + + bridge.sockets[0].write( + positionPacketFrame(555, { + latitudeI: 10000000, + longitudeI: -20000000, + }), + ); + + await waitFor(async () => (await svc.nodes()).length === 1); + expect(await svc.nodes()).toEqual([ + { num: 555, lastHeard: null, position: { lat: 1, lon: -2 } }, // no alt sent -> omitted + ]); + } 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'; + 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', shortName: 'OT' }, + }), + ); + await waitFor(async () => (await svc.nodes()).length === 2); + + expect((await svc.node(KAMIL_NUM))?.longName).toBe('Kamil SP8MK/P'); // exact num + expect((await svc.node(String(KAMIL_NUM)))?.num).toBe(KAMIL_NUM); // numeric string + expect((await svc.node('kamil sp8mk/p'))?.shortName).toBe('KK'); // case-insensitive exact name + expect((await svc.node('kamil'))?.num).toBe(KAMIL_NUM); // unique substring + expect(await svc.node('zzz-nope')).toBeNull(); // no match + } finally { + svc.onModuleDestroy(); + bridge.close(); + } + }); + + it('returns null for ambiguous name matches', 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, + ); + + // two nodes whose names both contain "kamil" and share the short name "KK" + bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); + bridge.sockets[0].write( + nodeInfoFrame(777, { + user: { id: '!kj', longName: 'Kamil Jr.', shortName: 'KK' }, + }), + ); + await waitFor(async () => (await svc.nodes()).length === 2); + + expect(await svc.node('kamil')).toBeNull(); // substring matches two nodes + expect(await svc.node('kk')).toBeNull(); // exact name matches two nodes + } finally { + svc.onModuleDestroy(); + bridge.close(); + } + }); + + it('resets the node database on reconnect (no stale data)', async () => { + const bridge = await startBridge(); + process.env.MESH_HOST = '127.0.0.1'; + process.env.MESH_PORT = String(bridge.port); + process.env.MESH_RECONNECT_MS = '50'; + const svc = new MeshService(); + try { + svc.onModuleInit(); + await waitFor( + () => bridge.sockets.length === 1 && svc.status().connected, + ); + + bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); + await waitFor(async () => (await svc.nodes()).length === 1); + + // drop the connection; bot reconnects and must start with an empty node DB + bridge.sockets[0].destroy(); + await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected); + await waitFor(async () => (await svc.nodes()).length === 0); + } finally { + delete process.env.MESH_RECONNECT_MS; + svc.onModuleDestroy(); + bridge.close(); + } + }); +}); diff --git a/src/mesh/mesh.service.ts b/src/mesh/mesh.service.ts index 52fb390..bbb4a52 100644 --- a/src/mesh/mesh.service.ts +++ b/src/mesh/mesh.service.ts @@ -6,15 +6,36 @@ import { ServiceUnavailableException, } from '@nestjs/common'; import * as net from 'node:net'; +import type * as Proto from '@meshtastic/protobufs'; import { + PORT_POSITION_APP, PORT_TEXT_MESSAGE_APP, buildTextToRadio, buildWantConfigToRadio, decodeFromRadio, + decodePosition, extractFrames, frame, + loadHwModelNames, } from './codec.js'; +/** What we know about a node so far: full NodeInfo after sync, possibly just {num}+position for live-only nodes. */ +type NodeEntry = Pick & + Partial>; + +export interface NodeSummary { + num: number; + longName?: string; + shortName?: string; + /** meshtastic.HardwareModel enum name (e.g. "TBEAM"); omitted when unknown or UNSET */ + hwModel?: string; + /** 0-100 = battery percent, >100 = mains powered (device convention) */ + batteryLevel?: number; + /** epoch seconds of the last heard packet; null when never heard */ + lastHeard: number | null; + position?: { lat: number; lon: number; alt?: number }; +} + @Injectable() export class MeshService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(MeshService.name); @@ -40,6 +61,8 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { number, import('@meshtastic/protobufs').Channel.Channel >(); + // node database: NodeInfo frames from the initial sync + live POSITION_APP updates + private nodeMap = new Map(); private reconnectTimer: NodeJS.Timeout | null = null; private shuttingDown = false; @@ -53,6 +76,7 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { this.socket?.destroy(); } + /** Bridge connection state: `{ connected, myNodeNum, host, port }`. */ status() { return { connected: this.connected, @@ -63,6 +87,7 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { } // psk/keys are deliberately not included — never expose them over HTTP + /** Device channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` sorted by index; empty until the initial sync completes. */ channels(): { index: number; name: string; @@ -79,6 +104,52 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { })); } + /** + * Lean summaries of all known nodes, sorted by node number. + * Names/hwModel/batteryLevel appear only when the device reported them; + * `lastHeard` is epoch seconds (null = never heard); `position` only when both coordinates are known. + */ + async nodes(): Promise { + const names = await loadHwModelNames(); + return [...this.nodeMap.values()] + .sort((a, b) => a.num - b.num) + .map((n) => this.summarize(n, names)); + } + + /** + * Look up one node: exact node number first (numbers and numeric strings), + * then case-insensitive EXACT long/short name, else the unique case-insensitive + * SUBSTRING match across long/short names. Returns null when nothing or more + * than one node matches. Reused by send-by-name lookups. + */ + async node(idOrName: string | number): Promise { + const q = String(idOrName).trim().toLowerCase(); + if (!q) return null; + const names = await loadHwModelNames(); + if (/^\d+$/.test(q)) { + const hit = this.nodeMap.get(Number(q)); + if (hit) return this.summarize(hit, names); + } + const all = [...this.nodeMap.values()]; + // exact name match wins over substring; a string always contains itself, so an ambiguous exact set can never become unique below + const exact = all.filter( + (n) => + n.user && + (n.user.longName.toLowerCase() === q || + n.user.shortName.toLowerCase() === q), + ); + if (exact.length === 1) return this.summarize(exact[0], names); + const sub = all.filter( + (n) => + n.user && + (n.user.longName.toLowerCase().includes(q) || + n.user.shortName.toLowerCase().includes(q)), + ); + if (sub.length === 1) return this.summarize(sub[0], names); + return null; + } + + /** Send `text` to node `to` on the pinned test channel; returns the packet id. Throws ServiceUnavailable when the bridge is down. */ async sendText(to: number, text: string): Promise { if (!this.connected || !this.socket) throw new ServiceUnavailableException('bridge not connected'); @@ -87,14 +158,34 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { return id; } + private summarize(n: NodeEntry, names: Map): NodeSummary { + const s: NodeSummary = { num: n.num, lastHeard: n.lastHeard || null }; + if (n.user?.longName) s.longName = n.user.longName; + if (n.user?.shortName) s.shortName = n.user.shortName; + // 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 + const battery = n.deviceMetrics?.batteryLevel; + if (battery !== undefined) s.batteryLevel = battery; + 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 + s.position = { lat: p.latitudeI * 1e-7, lon: p.longitudeI * 1e-7 }; + if (p.altitude !== undefined) s.position.alt = p.altitude; + } + return s; + } + private connect(): void { if (this.shuttingDown) return; const socket = net.createConnection({ host: this.host, port: this.port }); this.socket = socket; socket.on('connect', () => { this.connected = true; - // fresh sync on every (re)connect — drop stale channels from the old session + // fresh sync on every (re)connect — drop stale channels/nodes from the old session this.channelMap = new Map(); + this.nodeMap = new Map(); this.logger.log(`bridge connected ${this.host}:${this.port}`); const nonce = Math.floor(Math.random() * 0xffffffff) || 1; buildWantConfigToRadio(nonce) @@ -136,9 +227,9 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectMs); } - private handleFromRadio( + private async handleFromRadio( fr: import('@meshtastic/protobufs').Mesh.FromRadio, - ): void { + ): Promise { const v = fr.payloadVariant; switch (v?.case) { case 'myInfo': @@ -149,9 +240,13 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { // device streams one Channel per table slot during initial sync this.channelMap.set(v.value.index, v.value); break; + case 'nodeInfo': + // device streams the whole node DB during initial sync + this.nodeMap.set(v.value.num, v.value); + break; case 'configCompleteId': this.logger.log( - `bridge ready (config complete), ${this.channelMap.size} channels`, + `bridge ready (config complete), ${this.channelMap.size} channels, ${this.nodeMap.size} nodes`, ); break; case 'packet': { @@ -164,6 +259,22 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { this.logger.log( `[mesh] ${pkt.from} -> ${pkt.to}: "${new TextDecoder().decode(decoded.payload)}"`, ); + } else if (decoded && decoded.portnum === PORT_POSITION_APP) { + // live position update: the Position is serialized in Data.payload + let pos: Proto.Mesh.Position | undefined; + try { + pos = await decodePosition(decoded.payload); + } catch { + pos = undefined; // malformed payload — skip, keep whatever we had + } + if (pos?.latitudeI !== undefined && pos.longitudeI !== undefined) { + const num = pkt.from; + this.nodeMap.set(num, { + ...this.nodeMap.get(num), + num, + position: pos, + }); + } } break; }