Send to by node name or broadcast sentinel (GET-free resolution via node DB) (#9)
CI / build + unit (push) Successful in 33s

This commit was merged in pull request #9.
This commit is contained in:
2026-09-24 16:47:51 +02:00
parent 57ad27d66f
commit f640f6658c
7 changed files with 111 additions and 17 deletions
+9 -12
View File
@@ -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) };
}
+59 -1
View File
@@ -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();
}
});
});
+29
View File
@@ -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 '<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').
* tx records start 'pending' and move to 'delivered'/'failed' when the routing ACK/NACK arrives;