Initial commit: NestJS meshtastic bridge bot
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
coverage
|
||||||
|
*.log
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# test coverage
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# build info
|
||||||
|
tsconfig.build.tsbuildinfo
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build && npm prune --omit=dev
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY package*.json ./
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# mesh-bot
|
||||||
|
|
||||||
|
Minimal NestJS bot for Meshtastic. Long-running service that TCP-connects to a
|
||||||
|
Meshtastic serial bridge (raw socat pipe) at `192.168.1.4:4003`, parses inbound
|
||||||
|
protobuf frames, logs received text messages, and exposes HTTP endpoints to send text.
|
||||||
|
|
||||||
|
## Local run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm i
|
||||||
|
npm run start:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Exposed via Traefik only (no published ports) at `http://bot.dev.kkhost.pl`.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /status` — `{ connected, myNodeNum, host, port }`
|
||||||
|
- `POST /send` — body `{ "text": "...", "to"?: <node number | "!b" | "broadcast"> }`, returns `{ id }`
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl http://localhost:3000/status
|
||||||
|
curl -X POST http://localhost:3000/send -H 'content-type: application/json' -d '{"text":"hi"}'
|
||||||
|
curl -X POST http://localhost:3000/send -H 'content-type: application/json' -d '{"to":123456789,"text":"hello"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`to` omitted, `"!b"`, or `"broadcast"` sends to the broadcast address `0xffffffff`.
|
||||||
|
|
||||||
|
## Env vars
|
||||||
|
|
||||||
|
- `MESH_HOST` (default `192.168.1.4`) — bridge host
|
||||||
|
- `MESH_PORT` (default `4003`) — bridge port
|
||||||
|
- `PORT` (default `3000`) — HTTP listen port
|
||||||
|
|
||||||
|
Note: the serial bridge accepts only ONE client at a time — if another app is
|
||||||
|
connected to the radio, this bot will not get a connection.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
bot:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
MESH_HOST: 192.168.1.4
|
||||||
|
MESH_PORT: '4003'
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- dev_net
|
||||||
|
labels:
|
||||||
|
- 'traefik.enable=true'
|
||||||
|
- 'traefik.http.routers.mesh-bot.rule=Host(`bot.dev.kkhost.pl`)'
|
||||||
|
- 'traefik.http.routers.mesh-bot.entrypoints=web'
|
||||||
|
- 'traefik.http.services.mesh-bot.loadbalancer.server.port=3000'
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dev_net:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/crates/oxc_linter/src/rules.rs",
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-floating-promises": "warn"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"node": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5676
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "mesh-bot",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"deploy": "nest deploy",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "oxlint src/ test/",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:cov": "vitest run --coverage",
|
||||||
|
"test:debug": "vitest --inspect-brk --no-file-parallelism",
|
||||||
|
"test:e2e": "vitest run --config ./vitest.config.e2e.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "^2.15.0",
|
||||||
|
"@meshtastic/protobufs": "^2.8.0",
|
||||||
|
"@nestjs/common": "^12.0.1",
|
||||||
|
"@nestjs/core": "^12.0.1",
|
||||||
|
"@nestjs/platform-express": "^12.0.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^12.0.0",
|
||||||
|
"@nestjs/mau": "^0.2.6",
|
||||||
|
"@nestjs/schematics": "^12.0.0",
|
||||||
|
"@nestjs/testing": "^12.0.1",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/supertest": "^7.0.0",
|
||||||
|
"@vitest/coverage-v8": "^4.1.2",
|
||||||
|
"oxlint": "^1.58.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"typescript": "^6.0.2",
|
||||||
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
"vitest": "^4.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"resolvePackageJsonExports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"strictPropertyInitialization": false,
|
||||||
|
"types": ["vitest/globals", "node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tsconfigPaths()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
root: './',
|
||||||
|
include: ['**/*.e2e-spec.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
// Resolves the path aliases declared in tsconfig.json, including the ones
|
||||||
|
// added by `nest g library`.
|
||||||
|
plugins: [tsconfigPaths()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
root: './',
|
||||||
|
include: ['**/*.spec.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user