Node database: collect nodes during sync, expose GET /api/nodes (#7)
CI / build + unit (push) Successful in 39s
CI / build + unit (push) Successful in 39s
This commit was merged in pull request #7.
This commit is contained in:
+115
-4
@@ -6,15 +6,36 @@ import {
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import * as net from 'node:net';
|
||||
import type * as Proto from '@meshtastic/protobufs';
|
||||
import {
|
||||
PORT_POSITION_APP,
|
||||
PORT_TEXT_MESSAGE_APP,
|
||||
buildTextToRadio,
|
||||
buildWantConfigToRadio,
|
||||
decodeFromRadio,
|
||||
decodePosition,
|
||||
extractFrames,
|
||||
frame,
|
||||
loadHwModelNames,
|
||||
} from './codec.js';
|
||||
|
||||
/** 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);
|
||||
@@ -40,6 +61,8 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
number,
|
||||
import('@meshtastic/protobufs').Channel.Channel
|
||||
>();
|
||||
// node database: NodeInfo frames from the initial sync + live POSITION_APP updates
|
||||
private nodeMap = new Map<number, NodeEntry>();
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private shuttingDown = false;
|
||||
|
||||
@@ -53,6 +76,7 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
this.socket?.destroy();
|
||||
}
|
||||
|
||||
/** Bridge connection state: `{ connected, myNodeNum, host, port }`. */
|
||||
status() {
|
||||
return {
|
||||
connected: this.connected,
|
||||
@@ -63,6 +87,7 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -79,6 +104,52 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** 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');
|
||||
@@ -87,14 +158,34 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
return id;
|
||||
}
|
||||
|
||||
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 from the old session
|
||||
// fresh sync on every (re)connect — drop stale channels/nodes from the old session
|
||||
this.channelMap = new Map();
|
||||
this.nodeMap = new Map();
|
||||
this.logger.log(`bridge connected ${this.host}:${this.port}`);
|
||||
const nonce = Math.floor(Math.random() * 0xffffffff) || 1;
|
||||
buildWantConfigToRadio(nonce)
|
||||
@@ -136,9 +227,9 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectMs);
|
||||
}
|
||||
|
||||
private handleFromRadio(
|
||||
private async handleFromRadio(
|
||||
fr: import('@meshtastic/protobufs').Mesh.FromRadio,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const v = fr.payloadVariant;
|
||||
switch (v?.case) {
|
||||
case 'myInfo':
|
||||
@@ -149,9 +240,13 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
// 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`,
|
||||
`bridge ready (config complete), ${this.channelMap.size} channels, ${this.nodeMap.size} nodes`,
|
||||
);
|
||||
break;
|
||||
case 'packet': {
|
||||
@@ -164,6 +259,22 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
this.logger.log(
|
||||
`[mesh] ${pkt.from} -> ${pkt.to}: "${new TextDecoder().decode(decoded.payload)}"`,
|
||||
);
|
||||
} 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user