From f640f6658c028f66464a77a22a37e197c229e0d8 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Thu, 24 Sep 2026 16:47:51 +0200 Subject: [PATCH] Send to by node name or broadcast sentinel (GET-free resolution via node DB) (#9) --- AGENTS.md | 2 +- CHANGELOG.md | 7 ++++ README.md | 7 ++-- package.json | 2 +- src/mesh/mesh.controller.ts | 21 ++++++------ src/mesh/mesh.service.spec.ts | 60 ++++++++++++++++++++++++++++++++++- src/mesh/mesh.service.ts | 29 +++++++++++++++++ 7 files changed, 111 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f231caf..7bd570b 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 }`), `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 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"?: }` — 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 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 e0b7cca..10d1986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v0.4.0 — 2026-09-24 14:39 + +- `POST /api/send` now accepts node names and explicit broadcast sentinels in `to`: omitted/"" or `"!b"`/`"broadcast"` (case-insensitive, trimmed) → broadcast address `0xffffffff`; numbers pass through as-is (must be an integer in 1..0xffffffff); numeric strings are parsed the same way; anything else is looked up by name via the existing node DB lookup (exact long/short name, then unique substring). Unknown or ambiguous targets are a 400 with `no node matching ''`. New service method `MeshService.resolveDest(to?)` does the resolution — `sendText()` keeps its resolved-number signature. +- Swagger: `SendTextDto.to` documented as `number | string` with the full resolution order and the DM-vs-broadcast caveat (DMs are unicast wantAck-tracked via `GET /api/messages`; broadcasts stay pending because firmware strips want_ack); `@ApiOperation` notes all sends still ride channel index 2 (`testowy`) regardless of DM or broadcast. +- README + AGENTS.md endpoint lists updated for the new `to` semantics. +- Unit tests: `resolveDest` coverage on the fake bridge — omitted/""/"!b"/"BROADCAST" → broadcast, number passthrough, numeric string, exact longName match → num (node DB seeded via framed FromRadio nodeInfo), unknown name → `no node matching`, and invalid numbers (0, negative, non-integer, > 0xffffffff) rejected. + ## v0.3.0 — 2026-09-24 14:08 - Message history with delivery tracking, exposed as `GET /api/messages` (newest first): inbound TEXT_MESSAGE_APP packets (`dir:'rx'`, with `from`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` when the routing ACK/NACK arrives — ROUTING_APP packets carry a serialized `meshtastic.Routing` in `Data.payload` whose oneof variant is `errorReason` (`Routing_Error`: 0 NONE = delivered, anything else failed with a human-readable `ackError`, e.g. NO_ROUTE/GOT_NAK/TIMEOUT, fallback `reason `), matched to our sends via `Data.requestId`. Broadcasts stay `pending` because firmware strips want_ack pre-TX (documented behavior, no timer). History is a bounded in-memory buffer of the last 100 records (newest kept) and resets on every bridge reconnect alongside channels/nodes. diff --git a/README.md b/README.md index 9cc1e05..c6a30d6 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Exposed via Traefik only (no published ports) at `http://bot.dev.kkhost.pl`. All API endpoints live under the `/api` prefix: - `GET /api/status` — `{ connected, myNodeNum, host, port }` -- `POST /api/send` — body `{ "text": "...", "to"?: }`, returns `{ id }` +- `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/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 @@ -45,7 +45,10 @@ curl http://localhost:3000/api/nodes curl http://localhost:3000/api/nodes/kamil ``` -`to` omitted, `"!b"`, or `"broadcast"` sends to the broadcast address `0xffffffff`. +`to` omitted, `"!b"`, or `"broadcast"` (case-insensitive) sends to the +broadcast address `0xffffffff`; anything else is a node number or name. +DMs are unicast and wantAck-tracked via `GET /api/messages`; broadcasts stay +pending because firmware strips want_ack before TX. All sends always target channel index 2 (`testowy`) — never the primary channel. Override with `MESH_SEND_CHANNEL`. diff --git a/package.json b/package.json index 8b7a370..aa35731 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.3.0", + "version": "0.4.0", "description": "", "author": "", "private": true, diff --git a/src/mesh/mesh.controller.ts b/src/mesh/mesh.controller.ts index 2e500bc..f068a51 100644 --- a/src/mesh/mesh.controller.ts +++ b/src/mesh/mesh.controller.ts @@ -13,7 +13,6 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; -import { BROADCAST_ADDR } from './codec.js'; import { MeshService } from './mesh.service.js'; export class SendTextDto { @@ -22,7 +21,7 @@ export class SendTextDto { @ApiProperty({ required: false, description: - 'Decimal node number, "!b", or "broadcast" (defaults to broadcast)', + 'Send target. Resolution order: omitted or "" → broadcast (0xffffffff); number → used as-is (integer in 1..0xffffffff); numeric string → that node number; "!b" or "broadcast" (case-insensitive) → broadcast; anything else is looked up by name — exact long/short name, then unique substring. DMs are unicast and wantAck-tracked via GET /api/messages (pending→delivered/failed); broadcasts stay pending because firmware strips want_ack.', example: '!b', }) to?: string | number; @@ -58,7 +57,7 @@ export class MeshController { @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.', + 'Queues a TEXT_MESSAGE_APP packet to the resolved target (node number, node name, or broadcast). DMs are unicast and wantAck-tracked via GET /api/messages; broadcasts stay pending because firmware strips want_ack. All sends ride the pinned test channel (index 2, "testowy" — never the primary channel) regardless of DM or broadcast, since modern firmware PKI handles unicast encryption/channel anyway. Returns the Meshtastic packet id.', }) @ApiResponse({ status: 200, @@ -71,15 +70,13 @@ export class MeshController { if (typeof body?.text !== 'string' || body.text.length === 0) { throw new BadRequestException('text must be a non-empty string'); } - let to = BROADCAST_ADDR; - const t = body.to; - if (t !== undefined && t !== '!b' && t !== 'broadcast') { - const n = typeof t === 'number' ? t : Number(String(t)); - if (!Number.isFinite(n)) - throw new BadRequestException( - 'to must be a decimal node number, "!b", or "broadcast"', - ); - to = n; + let to: number; + try { + to = await this.mesh.resolveDest(body.to); + } catch (err) { + throw new BadRequestException( + err instanceof Error ? err.message : String(err), + ); } return { id: await this.mesh.sendText(to, body.text) }; } diff --git a/src/mesh/mesh.service.spec.ts b/src/mesh/mesh.service.spec.ts index 39a6c18..659d957 100644 --- a/src/mesh/mesh.service.spec.ts +++ b/src/mesh/mesh.service.spec.ts @@ -3,7 +3,7 @@ import * as P from '@meshtastic/protobufs'; import * as net from 'node:net'; import type { AddressInfo } from 'node:net'; import { describe, expect, it } from 'vitest'; -import { extractFrames, frame } from './codec.js'; +import { BROADCAST_ADDR, extractFrames, frame } from './codec.js'; import { MeshService } from './mesh.service.js'; function startBridge(onSocket?: (s: net.Socket) => void): Promise<{ @@ -612,3 +612,61 @@ describe('MeshService message history', () => { } }); }); + +describe('MeshService resolveDest', () => { + it('resolves broadcast sentinels, numbers and names to node numbers', 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())); + await waitFor(async () => (await svc.nodes()).length === 1); + + expect(await svc.resolveDest(undefined)).toBe(BROADCAST_ADDR); // omitted → broadcast + expect(await svc.resolveDest('')).toBe(BROADCAST_ADDR); // empty → broadcast + expect(await svc.resolveDest('!b')).toBe(BROADCAST_ADDR); + expect(await svc.resolveDest('BROADCAST')).toBe(BROADCAST_ADDR); // case-insensitive + expect(await svc.resolveDest(42)).toBe(42); // number passthrough + expect(await svc.resolveDest(String(KAMIL_NUM))).toBe(KAMIL_NUM); // numeric string + expect(await svc.resolveDest('Kamil SP8MK/P')).toBe(KAMIL_NUM); // exact longName + } finally { + svc.onModuleDestroy(); + bridge.close(); + } + }); + + it('throws for unknown names and invalid node numbers', 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())); + await waitFor(async () => (await svc.nodes()).length === 1); + + await expect(svc.resolveDest('zzz-nope')).rejects.toThrow( + "no node matching 'zzz-nope'", + ); + for (const bad of [0, -1, 42.5, BROADCAST_ADDR + 1]) { + await expect(svc.resolveDest(bad)).rejects.toThrow( + /invalid node number/, + ); + } + await expect(svc.resolveDest('0')).rejects.toThrow(/invalid node number/); + } finally { + svc.onModuleDestroy(); + bridge.close(); + } + }); +}); diff --git a/src/mesh/mesh.service.ts b/src/mesh/mesh.service.ts index 2bb9bd5..72e57b4 100644 --- a/src/mesh/mesh.service.ts +++ b/src/mesh/mesh.service.ts @@ -8,6 +8,7 @@ import { import * as net from 'node:net'; import type * as Proto from '@meshtastic/protobufs'; import { + BROADCAST_ADDR, PORT_POSITION_APP, PORT_ROUTING_APP, PORT_TEXT_MESSAGE_APP, @@ -176,6 +177,34 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { return null; } + /** + * Resolve a send target to a node number for sendText(). + * undefined/"" and "!b"/"broadcast" (case-insensitive, trimmed) → BROADCAST_ADDR; + * numbers pass through as-is (must be an integer in 1..0xffffffff); numeric strings are parsed the same way; + * anything else is looked up via node() — throws `no node matching ''` when nothing or more than one matches. + */ + async resolveDest(to?: number | string): Promise { + if (typeof to === 'number') { + this.assertNodeNum(to); + return to; + } + const s = String(to ?? '').trim(); + if (!s) return BROADCAST_ADDR; // omitted or empty → broadcast + if (s === '!b' || s.toLowerCase() === 'broadcast') return BROADCAST_ADDR; + if (/^\d+$/.test(s)) { + this.assertNodeNum(Number(s)); + return Number(s); + } + const hit = await this.node(s); + if (!hit) throw new Error(`no node matching '${to}'`); + return hit.num; + } + + private assertNodeNum(n: number): void { + if (!Number.isInteger(n) || n < 1 || n > BROADCAST_ADDR) + throw new Error(`invalid node number '${n}'`); + } + /** * Message history, newest first: inbound TEXT_MESSAGE_APP packets (dir 'rx') and our sends (dir 'tx'). * tx records start 'pending' and move to 'delivered'/'failed' when the routing ACK/NACK arrives;