feat: /api/channels endpoint, /api namespace, Scalar at root (v0.1.0) (#2)
feat: /api/channels endpoint, /api namespace, Scalar at root (v0.1.0) — closes #1
This commit was merged in pull request #2.
This commit is contained in:
+23
-1
@@ -1,9 +1,31 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import type { RequestHandler } from 'express';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { apiReference } from '@scalar/nestjs-api-reference';
|
||||
import * as pkg from '../package.json';
|
||||
import { AppModule } from './app.module.js';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.enableShutdownHooks();
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
// Scalar API docs at / (OpenAPI JSON at /docs-json), outside the api prefix.
|
||||
const docConfig = new DocumentBuilder()
|
||||
.setTitle('MeshBot API')
|
||||
.setDescription(
|
||||
'Meshtastic bridge bot: status, send text messages, list device channels.',
|
||||
)
|
||||
.setVersion(pkg.version)
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, docConfig);
|
||||
const http = app.getHttpAdapter().getInstance();
|
||||
http.get('/docs-json', (_req, res) => {
|
||||
res.json(document);
|
||||
});
|
||||
http.get('/', apiReference({ url: '/docs-json' }) as RequestHandler);
|
||||
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -5,20 +5,35 @@ import {
|
||||
Get,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiProperty } 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;
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class MeshController {
|
||||
constructor(private readonly mesh: MeshService) {}
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({ summary: 'Bridge connection status' })
|
||||
status() {
|
||||
return this.mesh.status();
|
||||
}
|
||||
|
||||
@Post('send')
|
||||
async send(@Body() body: { to?: string | number; text: string }) {
|
||||
@ApiOperation({ summary: 'Send a text message over the mesh' })
|
||||
async send(@Body() body: SendTextDto) {
|
||||
if (typeof body?.text !== 'string' || body.text.length === 0) {
|
||||
throw new BadRequestException('text must be a non-empty string');
|
||||
}
|
||||
@@ -34,4 +49,12 @@ export class MeshController {
|
||||
}
|
||||
return { id: await this.mesh.sendText(to, body.text) };
|
||||
}
|
||||
|
||||
@Get('channels')
|
||||
@ApiOperation({
|
||||
summary: "The device's Meshtastic channels (keys redacted)",
|
||||
})
|
||||
channels() {
|
||||
return this.mesh.channels();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { create, toBinary } from '@bufbuild/protobuf';
|
||||
import * as P from '@meshtastic/protobufs';
|
||||
import * as net from 'node:net';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { frame } from './codec.js';
|
||||
import { MeshService } from './mesh.service.js';
|
||||
|
||||
function startBridge(): Promise<{
|
||||
port: number;
|
||||
sockets: net.Socket[];
|
||||
close: () => void;
|
||||
}> {
|
||||
const sockets: net.Socket[] = [];
|
||||
const server = net.createServer((socket) => sockets.push(socket));
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve({
|
||||
port: (server.address() as AddressInfo).port,
|
||||
sockets,
|
||||
close: () => server.close(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(cond: () => boolean, timeoutMs = 5000): Promise<void> {
|
||||
const t0 = Date.now();
|
||||
while (!cond()) {
|
||||
if (Date.now() - t0 > timeoutMs) throw new Error('timed out waiting');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
}
|
||||
|
||||
function channelFrame(
|
||||
index: number,
|
||||
settings?: { name?: string; uplinkEnabled?: boolean },
|
||||
): Uint8Array {
|
||||
const ch = create(P.Channel.ChannelSchema, {
|
||||
index,
|
||||
...(settings
|
||||
? {
|
||||
// psk must never leak into channels() output
|
||||
settings: {
|
||||
name: settings.name ?? '',
|
||||
psk: new Uint8Array(16).fill(7),
|
||||
uplinkEnabled: settings.uplinkEnabled ?? false,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const fr = create(P.Mesh.FromRadioSchema, {
|
||||
payloadVariant: { case: 'channel', value: ch },
|
||||
});
|
||||
return frame(toBinary(P.Mesh.FromRadioSchema, fr));
|
||||
}
|
||||
|
||||
describe('MeshService channel pipeline', () => {
|
||||
it('collects channels from FromRadio frames and redacts keys', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
const svc = new MeshService();
|
||||
try {
|
||||
expect(svc.channels()).toEqual([]);
|
||||
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||
);
|
||||
|
||||
bridge.sockets[0].write(
|
||||
channelFrame(2, { name: 'LongFast', uplinkEnabled: true }),
|
||||
);
|
||||
bridge.sockets[0].write(channelFrame(0)); // channel with no settings at all
|
||||
|
||||
await waitFor(() => svc.channels().length === 2);
|
||||
expect(svc.channels()).toEqual([
|
||||
{ index: 0, name: '', uplinkEnabled: false, downlinkEnabled: false },
|
||||
{
|
||||
index: 2,
|
||||
name: 'LongFast',
|
||||
uplinkEnabled: true,
|
||||
downlinkEnabled: false,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('resets channels on reconnect (no stale data)', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
process.env.MESH_RECONNECT_MS = '50';
|
||||
const svc = new MeshService();
|
||||
try {
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||
);
|
||||
|
||||
bridge.sockets[0].write(channelFrame(1, { name: 'Stale' }));
|
||||
await waitFor(() => svc.channels().length === 1);
|
||||
|
||||
// drop the connection; bot reconnects and must start with an empty table
|
||||
bridge.sockets[0].destroy();
|
||||
await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected);
|
||||
await waitFor(() => svc.channels().length === 0);
|
||||
} finally {
|
||||
delete process.env.MESH_RECONNECT_MS;
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -18,11 +18,21 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(MeshService.name);
|
||||
private readonly host = process.env.MESH_HOST ?? '192.168.1.4';
|
||||
private readonly port = parseInt(process.env.MESH_PORT ?? '4003', 10);
|
||||
// ponytail: fixed-interval retry, no backoff — fine for single-bridge use
|
||||
private readonly reconnectMs = parseInt(
|
||||
process.env.MESH_RECONNECT_MS ?? '5000',
|
||||
10,
|
||||
);
|
||||
|
||||
private socket: net.Socket | null = null;
|
||||
private buffer: Uint8Array = new Uint8Array(0);
|
||||
private connected = false;
|
||||
private myNodeNum: number | null = null;
|
||||
// channels streamed by the device during initial sync (before config_complete_id)
|
||||
private channelMap = new Map<
|
||||
number,
|
||||
import('@meshtastic/protobufs').Channel.Channel
|
||||
>();
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private shuttingDown = false;
|
||||
|
||||
@@ -45,6 +55,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
};
|
||||
}
|
||||
|
||||
// psk/keys are deliberately not included — never expose them over HTTP
|
||||
channels(): {
|
||||
index: number;
|
||||
name: string;
|
||||
uplinkEnabled: boolean;
|
||||
downlinkEnabled: boolean;
|
||||
}[] {
|
||||
return [...this.channelMap.values()]
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map(({ index, settings }) => ({
|
||||
index,
|
||||
name: settings?.name ?? '',
|
||||
uplinkEnabled: settings?.uplinkEnabled ?? false,
|
||||
downlinkEnabled: settings?.downlinkEnabled ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
async sendText(to: number, text: string): Promise<number> {
|
||||
if (!this.connected || !this.socket)
|
||||
throw new ServiceUnavailableException('bridge not connected');
|
||||
@@ -59,6 +86,8 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
this.socket = socket;
|
||||
socket.on('connect', () => {
|
||||
this.connected = true;
|
||||
// fresh sync on every (re)connect — drop stale channels from the old session
|
||||
this.channelMap = new Map();
|
||||
this.logger.log(`bridge connected ${this.host}:${this.port}`);
|
||||
});
|
||||
socket.on('data', (chunk) => {
|
||||
@@ -90,8 +119,7 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
private scheduleReconnect(): void {
|
||||
if (this.shuttingDown) return;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
// ponytail: fixed 5s retry, no backoff — fine for single-bridge use
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), 5000);
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectMs);
|
||||
}
|
||||
|
||||
private handleFromRadio(
|
||||
@@ -103,8 +131,14 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
this.myNodeNum = v.value.myNodeNum;
|
||||
this.logger.log(`my node num: ${this.myNodeNum}`);
|
||||
break;
|
||||
case 'channel':
|
||||
// device streams one Channel per table slot during initial sync
|
||||
this.channelMap.set(v.value.index, v.value);
|
||||
break;
|
||||
case 'configCompleteId':
|
||||
this.logger.log('bridge ready (config complete)');
|
||||
this.logger.log(
|
||||
`bridge ready (config complete), ${this.channelMap.size} channels`,
|
||||
);
|
||||
break;
|
||||
case 'packet': {
|
||||
const pkt = v.value;
|
||||
|
||||
Reference in New Issue
Block a user