194 lines
5.3 KiB
TypeScript
194 lines
5.3 KiB
TypeScript
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;
|
|
}
|
|
}
|