Node database: collect nodes during sync, expose GET /api/nodes (#7)
CI / build + unit (push) Successful in 39s

This commit was merged in pull request #7.
This commit is contained in:
2026-09-24 15:37:03 +02:00
parent adfbc9800b
commit 5292303786
9 changed files with 468 additions and 12 deletions
+103 -3
View File
@@ -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;
}
}