Initial commit: NestJS meshtastic bridge bot

This commit is contained in:
2026-09-23 17:36:11 +00:00
commit 83e46b8f07
21 changed files with 6248 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { MeshModule } from './mesh/mesh.module.js';
@Module({
imports: [MeshModule],
})
export class AppModule {}
+9
View File
@@ -0,0 +1,9 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import { extractFrames, frame } from './codec.js';
const F = (payload: number[]) => [
0x94,
0xc3,
(payload.length >> 8) & 0xff,
payload.length & 0xff,
...payload,
];
describe('extractFrames', () => {
it('emits one complete frame and empties rest', () => {
const { packets, rest } = extractFrames(Uint8Array.from(F([1, 2, 3])));
expect(packets).toHaveLength(1);
expect(Array.from(packets[0])).toEqual([1, 2, 3]);
expect(rest.length).toBe(0);
});
it('emits two frames from one chunk', () => {
const buf = Uint8Array.from([...F([1]), ...F([2, 2])]);
const { packets, rest } = extractFrames(buf);
expect(packets.map((p) => Array.from(p))).toEqual([[1], [2, 2]]);
expect(rest.length).toBe(0);
});
it('reassembles a frame split across two feedings', () => {
const full = Uint8Array.from(F([9, 8, 7]));
const first = extractFrames(full.subarray(0, 5)); // header + 1 payload byte
expect(first.packets).toHaveLength(0);
expect(Array.from(first.rest)).toEqual(Array.from(full.subarray(0, 5)));
const second = extractFrames(
Uint8Array.from([...first.rest, ...full.subarray(5)]),
);
expect(second.packets.map((p) => Array.from(p))).toEqual([[9, 8, 7]]);
expect(second.rest.length).toBe(0);
});
it('discards garbage bytes before the header', () => {
const buf = Uint8Array.from([0xaa, 0xbb, ...F([5])]);
const { packets, rest } = extractFrames(buf);
expect(packets.map((p) => Array.from(p))).toEqual([[5]]);
expect(rest.length).toBe(0);
});
it('resyncs past a bogus len > 1024 and finds the real frame', () => {
const buf = Uint8Array.from([0x94, 0xc3, 0xff, 0xfe, ...F([7, 6])]);
const { packets, rest } = extractFrames(buf);
expect(packets.map((p) => Array.from(p))).toEqual([[7, 6]]);
expect(rest.length).toBe(0);
});
it('keeps a trailing lone 0x94 in rest', () => {
const { packets, rest } = extractFrames(Uint8Array.from([0xaa, 0x94]));
expect(packets).toHaveLength(0);
expect(Array.from(rest)).toEqual([0x94]);
});
});
describe('frame', () => {
it('prepends the 0x94 0xC3 big-endian length header', () => {
const out = frame(Uint8Array.from([1, 2, 3]));
expect(Array.from(out)).toEqual([0x94, 0xc3, 0x00, 0x03, 1, 2, 3]);
});
it('encodes lengths above 255 in two bytes', () => {
const payload = new Uint8Array(300).fill(7);
const out = frame(payload);
expect(Array.from(out.subarray(0, 4))).toEqual([0x94, 0xc3, 0x01, 0x2c]);
expect(out.length).toBe(304);
});
it('round-trips through extractFrames', () => {
const framed = frame(Uint8Array.from([4, 5]));
const { packets, rest } = extractFrames(framed);
expect(packets.map((p) => Array.from(p))).toEqual([[4, 5]]);
expect(rest.length).toBe(0);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
let proto: typeof import('@meshtastic/protobufs') | null = null;
async function loadProto(): Promise<typeof import('@meshtastic/protobufs')> {
if (!proto) proto = await import('@meshtastic/protobufs');
return proto!;
}
export const BROADCAST_ADDR = 0xffffffff;
// official portnums.proto: TEXT_MESSAGE_APP=1 (task spec draft said 3 — the enum value wins)
export const PORT_TEXT_MESSAGE_APP = 1;
const START1 = 0x94;
const START2 = 0xc3;
const MAX_LEN = 1024;
export function extractFrames(buf: Uint8Array): {
packets: Uint8Array[];
rest: Uint8Array;
} {
const packets: Uint8Array[] = [];
let i = 0;
for (;;) {
if (i >= buf.length) return { packets, rest: new Uint8Array(0) };
const isHeaderStart =
buf[i] === START1 && i + 1 < buf.length && buf[i + 1] === START2;
if (!isHeaderStart) {
// keep a trailing lone 0x94 — it may be the first byte of a split header
if (buf[i] === START1 && i + 1 >= buf.length)
return { packets, rest: buf.subarray(i) };
i++;
continue;
}
if (i + 4 > buf.length) return { packets, rest: buf.subarray(i) }; // partial header
const len = (buf[i + 2] << 8) | buf[i + 3];
if (len > MAX_LEN) {
i++; // bogus header — rescan from next byte
continue;
}
if (i + 4 + len > buf.length) return { packets, rest: buf.subarray(i) }; // partial payload
packets.push(buf.subarray(i + 4, i + 4 + len));
i += 4 + len;
}
}
export function frame(payload: Uint8Array): Uint8Array {
const out = new Uint8Array(4 + payload.length);
out[0] = START1;
out[1] = START2;
out[2] = (payload.length >> 8) & 0xff;
out[3] = payload.length & 0xff;
out.set(payload, 4);
return out;
}
export async function decodeFromRadio(
bytes: Uint8Array,
): Promise<import('@meshtastic/protobufs').Mesh.FromRadio> {
const P = await loadProto();
return fromBinary(P.Mesh.FromRadioSchema, bytes);
}
export async function buildTextToRadio(
to: number,
text: string,
): Promise<{ bytes: Uint8Array; id: number }> {
const P = await loadProto();
let id = Math.floor(Math.random() * 0x100000000) >>> 0;
if (id === 0) id = 1; // non-zero uint32
const meshPacket = create(P.Mesh.MeshPacketSchema, {
to,
id,
wantAck: true,
channel: 0,
payloadVariant: {
case: 'decoded',
value: {
portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP,
payload: new TextEncoder().encode(text),
wantResponse: true,
},
},
});
const toRadio = create(P.Mesh.ToRadioSchema, {
payloadVariant: { case: 'packet', value: meshPacket },
});
return { bytes: frame(toBinary(P.Mesh.ToRadioSchema, toRadio)), id };
}
+37
View File
@@ -0,0 +1,37 @@
import {
BadRequestException,
Body,
Controller,
Get,
Post,
} from '@nestjs/common';
import { BROADCAST_ADDR } from './codec.js';
import { MeshService } from './mesh.service.js';
@Controller()
export class MeshController {
constructor(private readonly mesh: MeshService) {}
@Get('status')
status() {
return this.mesh.status();
}
@Post('send')
async send(@Body() body: { to?: string | number; text: string }) {
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) };
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { MeshController } from './mesh.controller.js';
import { MeshService } from './mesh.service.js';
@Module({
controllers: [MeshController],
providers: [MeshService],
})
export class MeshModule {}
+124
View File
@@ -0,0 +1,124 @@
import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
ServiceUnavailableException,
} from '@nestjs/common';
import * as net from 'node:net';
import {
PORT_TEXT_MESSAGE_APP,
buildTextToRadio,
decodeFromRadio,
extractFrames,
} from './codec.js';
@Injectable()
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);
private socket: net.Socket | null = null;
private buffer: Uint8Array = new Uint8Array(0);
private connected = false;
private myNodeNum: number | null = null;
private reconnectTimer: NodeJS.Timeout | null = null;
private shuttingDown = false;
onModuleInit(): void {
this.connect();
}
onModuleDestroy(): void {
this.shuttingDown = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.socket?.destroy();
}
status() {
return {
connected: this.connected,
myNodeNum: this.myNodeNum,
host: this.host,
port: this.port,
};
}
async sendText(to: number, text: string): Promise<number> {
if (!this.connected || !this.socket)
throw new ServiceUnavailableException('bridge not connected');
const { bytes, id } = await buildTextToRadio(to, text);
this.socket.write(bytes);
return id;
}
private connect(): void {
if (this.shuttingDown) return;
const socket = net.createConnection({ host: this.host, port: this.port });
this.socket = socket;
socket.on('connect', () => {
this.connected = true;
this.logger.log(`bridge connected ${this.host}:${this.port}`);
});
socket.on('data', (chunk) => {
const merged = new Uint8Array(this.buffer.length + chunk.length);
merged.set(this.buffer, 0);
merged.set(chunk, this.buffer.length);
this.buffer = merged;
const { packets, rest } = extractFrames(this.buffer);
this.buffer = rest;
for (const p of packets) {
decodeFromRadio(p)
.then((fr) => this.handleFromRadio(fr))
.catch((err: unknown) =>
this.logger.error(`decode failed: ${String(err)}`),
);
}
});
const onDown = () => {
this.connected = false;
this.scheduleReconnect();
};
socket.on('error', (err) => {
this.logger.warn(`bridge error: ${err.message}`);
onDown();
});
socket.on('close', onDown);
}
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);
}
private handleFromRadio(
fr: import('@meshtastic/protobufs').Mesh.FromRadio,
): void {
const v = fr.payloadVariant;
switch (v?.case) {
case 'myInfo':
this.myNodeNum = v.value.myNodeNum;
this.logger.log(`my node num: ${this.myNodeNum}`);
break;
case 'configCompleteId':
this.logger.log('bridge ready (config complete)');
break;
case 'packet': {
const pkt = v.value;
const decoded =
pkt.payloadVariant?.case === 'decoded'
? pkt.payloadVariant.value
: undefined;
if (decoded && decoded.portnum === PORT_TEXT_MESSAGE_APP) {
this.logger.log(
`[mesh] ${pkt.from} -> ${pkt.to}: "${new TextDecoder().decode(decoded.payload)}"`,
);
}
break;
}
}
}
}