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:
2026-09-23 21:05:22 +02:00
parent 5a33e4590a
commit 75a10c195f
11 changed files with 489 additions and 19 deletions
+23 -1
View File
@@ -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();
+24 -1
View File
@@ -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();
}
}
+118
View File
@@ -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();
}
});
});
+37 -3
View File
@@ -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;