377 lines
13 KiB
TypeScript
377 lines
13 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 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 RespondDto {
|
|
@ApiProperty({
|
|
description:
|
|
'Packet id of the earlier message (from GET /api/messages) that this one replies to. Integer in 1..0xffffffff.',
|
|
example: 456789,
|
|
})
|
|
messageId!: number;
|
|
@ApiProperty({ description: 'Reply text', example: 'on my way' })
|
|
content!: string;
|
|
}
|
|
|
|
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;
|
|
@ApiProperty({
|
|
required: false,
|
|
description:
|
|
'Packet id of an earlier message (from GET /api/messages) that this one replies to — the recipient app renders it as a threaded reply/quote. Integer in 1..0xffffffff.',
|
|
example: 456789,
|
|
})
|
|
replyId?: 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). Optional replyId (integer in 1..0xffffffff) references an earlier message\'s packet id from GET /api/messages — when set, the recipient app renders this message as a threaded reply/quote of that packet. 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');
|
|
}
|
|
const replyId = body.replyId;
|
|
if (
|
|
replyId !== undefined &&
|
|
(!Number.isInteger(replyId) || replyId < 1 || replyId > BROADCAST_ADDR)
|
|
) {
|
|
throw new BadRequestException(
|
|
'replyId must be an integer in 1..0xffffffff',
|
|
);
|
|
}
|
|
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, replyId) };
|
|
}
|
|
|
|
@Post('respond')
|
|
@ApiOperation({
|
|
summary: 'Reply to an earlier message by packet id',
|
|
description:
|
|
"Routing is automatic — the caller never thinks about where it goes: rx records are answered as a DM back to the original sender, tx records go to their destination; broadcast originals (and injected messages without a sender) get a broadcast. The reply always rides the record's channel and carries replyId = messageId, so the recipient app renders it as a threaded quote of that packet. Returns { id } with the new packet id.",
|
|
})
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Packet queued',
|
|
example: { id: 123456789 },
|
|
})
|
|
@ApiResponse({ status: 400, description: 'Invalid messageId or content' })
|
|
@ApiResponse({ status: 404, description: 'No message with the given id' })
|
|
@ApiResponse({ status: 503, description: 'Bridge not connected' })
|
|
async respond(@Body() body: RespondDto) {
|
|
return this.mesh.respond(body?.messageId, body?.content);
|
|
}
|
|
|
|
@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();
|
|
}
|
|
|
|
@Get('forwarded')
|
|
@ApiOperation({
|
|
summary: 'Forwarded message history (last 100)',
|
|
description:
|
|
'The last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as { id, from?, text, channel, at }[] — channel in the MESH_NOTIFY_CHANNEL comma-separated list (ch0: directed DMs only), not a self-echo, notifier wired. The id is the packet id (real rx) or generated record id (injected); `from` is omitted when there is no sender. In-memory only — reset on restart.',
|
|
})
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Forwarded messages newest first',
|
|
example: [
|
|
{
|
|
id: 456789,
|
|
from: 987654321,
|
|
text: 'ping',
|
|
channel: 3,
|
|
at: 1758000000000,
|
|
},
|
|
],
|
|
})
|
|
forwarded() {
|
|
return this.mesh.forwarded();
|
|
}
|
|
|
|
@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;
|
|
}
|
|
}
|