Compare commits

..
1 Commits
Author SHA1 Message Date
kamilkrzywda f640f6658c Send to by node name or broadcast sentinel (GET-free resolution via node DB) (#9)
CI / build + unit (push) Successful in 33s
2026-09-24 16:47:51 +02:00
7 changed files with 111 additions and 17 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
## API ## 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). 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"?: <node number | "!b" | "broadcast"> }`, 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"?: <node number | node name | "!b" | "broadcast"> }` — 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 ## Other
+7
View File
@@ -1,5 +1,12 @@
# Changelog # 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 '<to>'`. 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 ## 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 <n>`), 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. - 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 <n>`), 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.
+5 -2
View File
@@ -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: All API endpoints live under the `/api` prefix:
- `GET /api/status` — `{ connected, myNodeNum, host, port }` - `GET /api/status` — `{ connected, myNodeNum, host, port }`
- `POST /api/send` — body `{ "text": "...", "to"?: <node number | "!b" | "broadcast"> }`, returns `{ id }` - `POST /api/send` — body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast"> }`, 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/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/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? } }[]`, 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 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 All sends always target channel index 2 (`testowy`) — never the primary
channel. Override with `MESH_SEND_CHANNEL`. channel. Override with `MESH_SEND_CHANNEL`.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mesh-bot", "name": "mesh-bot",
"version": "0.3.0", "version": "0.4.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
+9 -12
View File
@@ -13,7 +13,6 @@ import {
ApiResponse, ApiResponse,
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { BROADCAST_ADDR } from './codec.js';
import { MeshService } from './mesh.service.js'; import { MeshService } from './mesh.service.js';
export class SendTextDto { export class SendTextDto {
@@ -22,7 +21,7 @@ export class SendTextDto {
@ApiProperty({ @ApiProperty({
required: false, required: false,
description: 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', example: '!b',
}) })
to?: string | number; to?: string | number;
@@ -58,7 +57,7 @@ export class MeshController {
@ApiOperation({ @ApiOperation({
summary: 'Send a text message over the mesh', summary: 'Send a text message over the mesh',
description: 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({ @ApiResponse({
status: 200, status: 200,
@@ -71,15 +70,13 @@ export class MeshController {
if (typeof body?.text !== 'string' || body.text.length === 0) { if (typeof body?.text !== 'string' || body.text.length === 0) {
throw new BadRequestException('text must be a non-empty string'); throw new BadRequestException('text must be a non-empty string');
} }
let to = BROADCAST_ADDR; let to: number;
const t = body.to; try {
if (t !== undefined && t !== '!b' && t !== 'broadcast') { to = await this.mesh.resolveDest(body.to);
const n = typeof t === 'number' ? t : Number(String(t)); } catch (err) {
if (!Number.isFinite(n)) throw new BadRequestException(
throw new BadRequestException( err instanceof Error ? err.message : String(err),
'to must be a decimal node number, "!b", or "broadcast"', );
);
to = n;
} }
return { id: await this.mesh.sendText(to, body.text) }; return { id: await this.mesh.sendText(to, body.text) };
} }
+59 -1
View File
@@ -3,7 +3,7 @@ import * as P from '@meshtastic/protobufs';
import * as net from 'node:net'; import * as net from 'node:net';
import type { AddressInfo } from 'node:net'; import type { AddressInfo } from 'node:net';
import { describe, expect, it } from 'vitest'; 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'; import { MeshService } from './mesh.service.js';
function startBridge(onSocket?: (s: net.Socket) => void): Promise<{ 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();
}
});
});
+29
View File
@@ -8,6 +8,7 @@ import {
import * as net from 'node:net'; import * as net from 'node:net';
import type * as Proto from '@meshtastic/protobufs'; import type * as Proto from '@meshtastic/protobufs';
import { import {
BROADCAST_ADDR,
PORT_POSITION_APP, PORT_POSITION_APP,
PORT_ROUTING_APP, PORT_ROUTING_APP,
PORT_TEXT_MESSAGE_APP, PORT_TEXT_MESSAGE_APP,
@@ -176,6 +177,34 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
return null; 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 '<to>'` when nothing or more than one matches.
*/
async resolveDest(to?: number | string): Promise<number> {
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'). * 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; * tx records start 'pending' and move to 'delivered'/'failed' when the routing ACK/NACK arrives;