Files
MeshBot/src/mesh/mesh.service.ts
T
2026-09-24 19:44:11 +02:00

397 lines
14 KiB
TypeScript

import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
ServiceUnavailableException,
} from '@nestjs/common';
import * as net from 'node:net';
import type * as Proto from '@meshtastic/protobufs';
import {
BROADCAST_ADDR,
PORT_POSITION_APP,
PORT_ROUTING_APP,
PORT_TEXT_MESSAGE_APP,
buildTextToRadio,
buildWantConfigToRadio,
decodeFromRadio,
decodePosition,
decodeRouting,
describeRoutingError,
extractFrames,
frame,
loadHwModelNames,
} from './codec.js';
/** One entry in the message history: an inbound text (dir 'rx') or one of our sends (dir 'tx'). */
export interface MessageRecord {
id: number;
dir: 'rx' | 'tx';
/** sender node num — rx only */
from?: number;
/** destination node num (broadcast addr for broadcasts) — tx only */
to?: number;
text: string;
channel: number;
/** epoch ms */
at: number;
/** tx only: pending until the routing ACK/NACK arrives; broadcasts stay pending (firmware strips want_ack pre-TX) */
status?: 'pending' | 'delivered' | 'failed';
/** tx only, when failed: human-readable Routing.Error reason */
ackError?: string;
}
// bounded history: keep the newest N records, in-memory only
const HISTORY_LIMIT = 100;
/** What we know about a node so far: full NodeInfo after sync, possibly just {num}+position for live-only nodes. */
type NodeEntry = Pick<Proto.Mesh.NodeInfo, 'num'> &
Partial<Omit<Proto.Mesh.NodeInfo, 'num'>>;
export interface NodeSummary {
num: number;
longName?: string;
shortName?: string;
/** meshtastic.HardwareModel enum name (e.g. "TBEAM"); omitted when unknown or UNSET */
hwModel?: string;
/** 0-100 = battery percent, >100 = mains powered (device convention) */
batteryLevel?: number;
/** epoch seconds of the last heard packet; null when never heard */
lastHeard: number | null;
position?: { lat: number; lon: number; alt?: number };
}
@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,
);
// sends are pinned to the test channel ("test", index 3) on purpose — never the primary channel
private readonly sendChannel = parseInt(
process.env.MESH_SEND_CHANNEL ?? '3',
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
>();
// node database: NodeInfo frames from the initial sync + live POSITION_APP updates
private nodeMap = new Map<number, NodeEntry>();
// message history (newest kept, capped at HISTORY_LIMIT) + pending tx records awaiting their routing ACK/NACK
private history: MessageRecord[] = [];
private pendingTx = new Map<number, MessageRecord>();
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();
}
/** Bridge connection state: `{ connected, myNodeNum, host, port }`. */
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
/** Device channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` sorted by index; empty until the initial sync completes. */
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,
}));
}
/**
* Lean summaries of all known nodes, sorted by node number.
* Names/hwModel/batteryLevel appear only when the device reported them;
* `lastHeard` is epoch seconds (null = never heard); `position` only when both coordinates are known.
*/
async nodes(): Promise<NodeSummary[]> {
const names = await loadHwModelNames();
return [...this.nodeMap.values()]
.sort((a, b) => a.num - b.num)
.map((n) => this.summarize(n, names));
}
/**
* Look up one node: exact node number first (numbers and numeric strings),
* then case-insensitive EXACT long/short name, else the unique case-insensitive
* SUBSTRING match across long/short names. Returns null when nothing or more
* than one node matches. Reused by send-by-name lookups.
*/
async node(idOrName: string | number): Promise<NodeSummary | null> {
const q = String(idOrName).trim().toLowerCase();
if (!q) return null;
const names = await loadHwModelNames();
if (/^\d+$/.test(q)) {
const hit = this.nodeMap.get(Number(q));
if (hit) return this.summarize(hit, names);
}
const all = [...this.nodeMap.values()];
// exact name match wins over substring; a string always contains itself, so an ambiguous exact set can never become unique below
const exact = all.filter(
(n) =>
n.user &&
(n.user.longName.toLowerCase() === q ||
n.user.shortName.toLowerCase() === q),
);
if (exact.length === 1) return this.summarize(exact[0], names);
const sub = all.filter(
(n) =>
n.user &&
(n.user.longName.toLowerCase().includes(q) ||
n.user.shortName.toLowerCase().includes(q)),
);
if (sub.length === 1) return this.summarize(sub[0], names);
return null;
}
/**
* Resolve a send target to a node number for sendText().
* undefined/"" and "!b"/"broadcast" (case-insensitive, trimmed) → BROADCAST_ADDR;
* numbers pass through as-is (must be an integer in 1..0xffffffff); numeric strings are parsed the same way;
* anything else is looked up via node() — throws `no node matching '<to>'` when nothing or more than one matches.
*/
async resolveDest(to?: number | string): Promise<number> {
if (typeof to === 'number') {
this.assertNodeNum(to);
return to;
}
const s = String(to ?? '').trim();
if (!s) return BROADCAST_ADDR; // omitted or empty → broadcast
if (s === '!b' || s.toLowerCase() === 'broadcast') return BROADCAST_ADDR;
if (/^\d+$/.test(s)) {
this.assertNodeNum(Number(s));
return Number(s);
}
const hit = await this.node(s);
if (!hit) throw new Error(`no node matching '${to}'`);
return hit.num;
}
private assertNodeNum(n: number): void {
if (!Number.isInteger(n) || n < 1 || n > BROADCAST_ADDR)
throw new Error(`invalid node number '${n}'`);
}
/**
* Message history, newest first: inbound TEXT_MESSAGE_APP packets (dir 'rx') and our sends (dir 'tx').
* tx records start 'pending' and move to 'delivered'/'failed' when the routing ACK/NACK arrives;
* broadcasts stay 'pending' because firmware strips want_ack before TX. In-memory only, last 100, reset on reconnect.
*/
messages(): MessageRecord[] {
return [...this.history].reverse();
}
/** Send `text` to node `to` on the pinned test channel; returns the packet id. Throws ServiceUnavailable when the bridge is down. */
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.sendChannel);
const rec: MessageRecord = {
id,
dir: 'tx',
to,
text,
channel: this.sendChannel,
at: Date.now(),
status: 'pending',
};
this.pushHistory(rec);
this.pendingTx.set(id, rec);
this.socket.write(bytes);
return id;
}
private pushHistory(rec: MessageRecord): void {
this.history.push(rec);
// ponytail: shift on a ≤100 array is fine; true ring buffer only if this ever gets hot
if (this.history.length > HISTORY_LIMIT) this.history.shift();
}
private summarize(n: NodeEntry, names: Map<number, string>): NodeSummary {
const s: NodeSummary = { num: n.num, lastHeard: n.lastHeard || null };
if (n.user?.longName) s.longName = n.user.longName;
if (n.user?.shortName) s.shortName = n.user.shortName;
// omit UNSET (enum value 0) rather than report "UNSET"
const hwName = n.user?.hwModel ? names.get(n.user.hwModel) : undefined;
if (hwName && hwName !== 'UNSET') s.hwModel = hwName;
// optional proto field: undefined when the device never reported metrics
const battery = n.deviceMetrics?.batteryLevel;
if (battery !== undefined) s.batteryLevel = battery;
const p = n.position;
if (p?.latitudeI !== undefined && p.longitudeI !== undefined) {
// alt included only when the device sent it — protobuf-es leaves absent optionals undefined, so an explicit 0 (sea level) is kept
s.position = { lat: p.latitudeI * 1e-7, lon: p.longitudeI * 1e-7 };
if (p.altitude !== undefined) s.position.alt = p.altitude;
}
return s;
}
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/nodes/history from the old session
this.channelMap = new Map();
this.nodeMap = new Map();
this.history = [];
this.pendingTx.clear();
this.logger.log(`bridge connected ${this.host}:${this.port}`);
const nonce = Math.floor(Math.random() * 0xffffffff) || 1;
buildWantConfigToRadio(nonce)
.then((bytes) => socket.write(frame(bytes)))
.catch((err: unknown) =>
this.logger.error(`want_config failed: ${String(err)}`),
);
this.logger.log(`requested initial sync (want_config_id=${nonce})`);
});
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 async handleFromRadio(
fr: import('@meshtastic/protobufs').Mesh.FromRadio,
): Promise<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 'nodeInfo':
// device streams the whole node DB during initial sync
this.nodeMap.set(v.value.num, v.value);
break;
case 'configCompleteId':
this.logger.log(
`bridge ready (config complete), ${this.channelMap.size} channels, ${this.nodeMap.size} nodes`,
);
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) {
const text = new TextDecoder().decode(decoded.payload);
this.logger.log(`[mesh] ${pkt.from} -> ${pkt.to}: "${text}"`);
this.pushHistory({
id: pkt.id,
dir: 'rx',
from: pkt.from,
text,
channel: pkt.channel ?? 0,
at: Date.now(),
});
} else if (decoded && decoded.portnum === PORT_POSITION_APP) {
// live position update: the Position is serialized in Data.payload
let pos: Proto.Mesh.Position | undefined;
try {
pos = await decodePosition(decoded.payload);
} catch {
pos = undefined; // malformed payload — skip, keep whatever we had
}
if (pos?.latitudeI !== undefined && pos.longitudeI !== undefined) {
const num = pkt.from;
this.nodeMap.set(num, {
...this.nodeMap.get(num),
num,
position: pos,
});
}
} else if (decoded && decoded.portnum === PORT_ROUTING_APP) {
// ACK/NACK for our want_ack sends: Routing serialized in Data.payload,
// Data.requestId = the original packet id we sent
let routing: Proto.Mesh.Routing | undefined;
try {
routing = await decodeRouting(decoded.payload);
} catch {
routing = undefined; // malformed payload — skip
}
if (routing?.variant.case === 'errorReason' && decoded.requestId) {
const rec = this.pendingTx.get(decoded.requestId);
if (rec) {
const reason = routing.variant.value;
if (reason === 0)
rec.status = 'delivered'; // Routing_Error.NONE
else {
rec.status = 'failed';
rec.ackError = await describeRoutingError(reason);
}
this.pendingTx.delete(decoded.requestId);
}
}
}
break;
}
}
}
}