import { BadRequestException, Body, Controller, Get, NotFoundException, Param, Post, } from '@nestjs/common'; import { ApiOperation, ApiProperty, ApiResponse, ApiTags, } from '@nestjs/swagger'; import type { DeviceInfo } from './mesh.service.js'; import { MeshService } from './mesh.service.js'; export class InjectMessageDto { @ApiProperty({ description: 'Message text', example: 'external trigger' }) text!: string; @ApiProperty({ required: false, description: 'Sender node number (integer in 1..0xffffffff); omitted when the external source has no mesh identity.', example: 987654321, }) from?: number; } export class SendTextDto { @ApiProperty({ description: 'Message text', example: 'hello' }) text!: string; @ApiProperty({ required: false, description: '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; } // 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 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 3, "test" — 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, 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: 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) }; } @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: 3, name: 'test', uplinkEnabled: false, downlinkEnabled: false, }, ], }) channels() { return this.mesh.channels(); } @Get('device') @ApiOperation({ summary: 'Device info (metadata + config, keys redacted)', description: 'This device\'s node number, firmware version and metadata, plus per-section config and module-config messages captured during the initial sync; empty until it completes. Key material is never exposed: security publicKey/privateKey/adminKey and network wifiPsk are replaced with "REDACTED". In-memory only, reset on every bridge reconnect.', }) @ApiResponse({ status: 200, description: 'Device info', example: { myNodeNum: 123456789, firmwareVersion: '2.7.0.1', metadata: { firmwareVersion: '2.7.0.1', deviceStateVersion: 1, canShutdown: true, hasWifi: false, hasBluetooth: true, hasEthernet: false, role: 3, positionFlags: 65, hwModel: 42, hasRemoteHardware: false, hasPKC: true, excludedModules: 0, }, localConfig: { device: { role: 3, buttonGpio: 13 }, security: { publicKey: 'REDACTED', privateKey: 'REDACTED', adminKey: 'REDACTED', isManaged: true, }, network: { wifiEnabled: false, wifiSsid: '', wifiPsk: 'REDACTED' }, }, moduleConfig: { telemetry: { environmentUpdateInterval: 900 }, }, }, }) device(): DeviceInfo { return this.mesh.deviceInfo(); } @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 backed by SQLite: the in-memory buffer (last 100) is seeded from the database on startup and re-upserted every second, so it survives process restarts and bridge reconnects; the database keeps the newest 1000 rows.', }) @ApiResponse({ status: 200, description: 'Messages newest first', example: [ { id: 456789, dir: 'tx', to: 42, text: 'hello test', channel: 3, at: 1758000123456, status: 'delivered', }, { id: 123456, dir: 'rx', from: 987654321, text: 'ping', channel: 2, at: 1758000000000, }, ], }) messages() { return this.mesh.messages(); } @Post('messages') @ApiOperation({ summary: 'Inject a synthetic inbound message into the history', description: 'Synthetic inbox injection for external triggers: records an rx entry in the message history as if received on the mesh, so external systems can push messages to trigger communication later. NOT transmitted over RF — nothing is sent to the bridge or air. The record appears in GET /api/messages like any other rx message (dir "rx", channel 0). Returns { id } with a random uint32 packet id.', }) @ApiResponse({ status: 200, description: 'Record injected', example: { id: 123456789 }, }) @ApiResponse({ status: 400, description: 'Invalid text or from' }) injectMessage(@Body() body: InjectMessageDto) { try { return this.mesh.injectMessage(body?.text, body?.from); } catch (err) { throw new BadRequestException( err instanceof Error ? err.message : String(err), ); } } @Get('telemetry') @ApiOperation({ summary: 'Telemetry history (last 100)', description: "TELEMETRY_APP packets from any node, newest first. Device metrics (uptime/battery/voltage/channel+air utilization) and environment sensor readings where nodes have them; kind is the meshtastic.Telemetry oneof case name — deviceMetrics, environmentMetrics, airQualityMetrics, powerMetrics, localStats, healthMetrics, hostMetrics or trafficManagementStats — and data carries that variant's payload (proto uint64 fields as decimal strings). In-memory only (last 100), reset on every bridge reconnect.", }) @ApiResponse({ status: 200, description: 'Telemetry records newest first', example: [ { from: 987654321, at: 1758000123456, kind: 'deviceMetrics', data: { batteryLevel: 87, voltage: 4.1, channelUtilization: 12.5, airUtilTx: 3.2, uptimeSeconds: 86400, }, }, { from: 123456789, at: 1758000000000, kind: 'environmentMetrics', data: { temperature: 21.5, relativeHumidity: 48 }, }, ], }) telemetry() { return this.mesh.telemetry(); } @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; snr/hopsAway/viaMqtt only when carried by the NodeInfo.', }) @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 }, snr: -2.5, hopsAway: 1, viaMqtt: true, }, ], }) 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; } }