import { BadRequestException, Body, Controller, Get, NotFoundException, Param, Post, } from '@nestjs/common'; import { ApiOperation, ApiProperty, ApiResponse, ApiTags, } from '@nestjs/swagger'; import { BROADCAST_ADDR } from './codec.js'; import { MeshService } from './mesh.service.js'; export class SendTextDto { @ApiProperty({ description: 'Message text', example: 'hello' }) text!: string; @ApiProperty({ required: false, description: 'Decimal node number, "!b", or "broadcast" (defaults to broadcast)', example: '!b', }) 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', 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', 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'); } 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; } return { id: await this.mesh.sendText(to, body.text) }; } @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('messages') @ApiOperation({ summary: 'Message history (last 100)', description: 'Inbound text messages and our sends, newest first. tx records start "pending" and move to "delivered"/"failed" when the routing ACK/NACK arrives; broadcasts stay "pending" because firmware strips want_ack before TX. History is in-memory only (last 100) and resets on every bridge reconnect.', }) @ApiResponse({ status: 200, description: 'Messages newest first', example: [ { id: 456789, dir: 'tx', to: 42, text: 'hello testowy', channel: 2, at: 1758000123456, status: 'delivered', }, { id: 123456, dir: 'rx', from: 987654321, text: 'ping', channel: 2, at: 1758000000000, }, ], }) messages() { return this.mesh.messages(); } @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; } }