159 lines
4.7 KiB
TypeScript
159 lines
4.7 KiB
TypeScript
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 ?? '4403', 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;
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
// 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');
|
|
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;
|
|
// 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) => {
|
|
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);
|
|
this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectMs);
|
|
}
|
|
|
|
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 '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.channelMap.size} channels`,
|
|
);
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|