Initial commit: NestJS meshtastic bridge bot
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MeshModule } from './mesh/mesh.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [MeshModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user