feat(mesh): drive MeshService through the MeshMonitor HTTP gateway (Phase 2)
CI / build + unit (pull_request) Successful in 1m17s

Rewire MeshService off the raw TCP socket + codec onto the Phase-1
MeshMonitorClient contract (HttpMeshMonitorClient in production,
FakeMeshMonitorClient in tests): status/messages/nodes/channels/telemetry
are polled (15s/1s/30s/5min/60s) instead of streamed, outbound broadcasts
and DMs go through client.send(), respond/inject/notifier/command/battery/
weather behavior and all /api/* + MCP endpoint shapes are preserved.
The fake TCP bridge is deleted and every consumer spec (mesh service, MCP
controller, command auto-responder, battery alerts) now drives the real
service through the widened fake client. Docker compose drops
MESH_HOST/MESH_PORT and adds the MESHMONITOR_* env (token from .env,
self-signed TLS via NODE_TLS_REJECT_UNAUTHORIZED=0).

Live smoke against https://meshmonitor.kkhost.pl: getStatus ->
{connected:true, myNodeNum:3994646728, longName:'SP8MK Bot',
nodeResponsive:true}; getNodes -> 104 nodes.

Build green, suite 17 files / 245 tests (baseline 234).
This commit is contained in:
2026-10-13 00:42:02 +00:00
parent f80340ae6e
commit f6b884dfdf
14 changed files with 2221 additions and 2487 deletions
+9
View File
@@ -1,5 +1,14 @@
# Changelog # Changelog
## v0.22.0 — 2026-10-13 00:42
- **Phase 2: the service now reaches the Meshtastic device through the MeshMonitor HTTP gateway** (`MeshMonitorClient`) instead of a raw TCP socket + codec. `MeshService` takes an injectable client (`HttpMeshMonitorClient` in production via `MESHMONITOR_API_TOKEN`, `FakeMeshMonitorClient` in tests); a missing token means "no gateway" — the service stays disconnected and sends throw 503. The `net.Socket` connection, frame buffer, codec framing, idle watchdog and periodic reconnect are all gone; all `/api/*` REST and MCP endpoint shapes, the notifier, the command auto-responder, the battery alerts and the weather job behave as before.
- Poll loops replace the socket: status every 15 s (5 s retries while down) gates everything; on first success the service syncs nodes → channels → telemetry → messages (the message poll takes a seen-set baseline so pre-existing traffic is not re-processed) and starts the loops: messages every `MESH_POLL_MS` (default 1 s) in `{limit: 50}` newest-first windows, nodes every 30 s, channels every 5 min, telemetry every 60 s (long-format rows are grouped into per-packet records and deduped).
- Outbound: broadcasts ride `send({ text, channel })` on the pinned send channel (default 3, `MESH_SEND_CHANNEL`); DMs ride `send({ text, toNodeId: '!hex' })` on the primary channel in mesh terms. Gateway-rejected sends are recorded as failed tx history entries; HTTP 413 → 400 BadRequest, anything else → 503 ServiceUnavailable. `respond()` semantics are unchanged (rx → DM back to the sender unless the original was a broadcast, tx → its destination, injected → broadcast; always threaded via `replyId`).
- Documented degradations vs the raw socket: `GET /api/device` is degraded (no device metadata / local+module config — only the firmware version when present in the node record); there is no idle-watchdog/periodic-reconnect (steady polling replaces it); world state (nodes/channels/telemetry) is not cleared when the status drops — each refreshes on its own interval.
- **Test migration:** the `MeshService`, MCP controller, command auto-responder and battery-alert specs now drive the real service through `FakeMeshMonitorClient` — the fake TCP bridge is gone (`src/mesh/fake-bridge.ts` deleted, zero imports remain). The fake client's test API was widened to match: the constructor takes the source identity (`{ connected, nodeNum, longName, shortName }`), auto-seeds the bot's own node (the gateway always reports it), `pushNode` upserts by num, plus the existing `pushInbound`/`setDeliveryState`/`clearTraffic`/`sends[]` mirror. Suite: 17 files / 245 tests green (baseline 234).
- **Docker:** compose drops `MESH_HOST`/`MESH_PORT` and adds `MESHMONITOR_API_TOKEN` (from `.env`, never committed), `MESHMONITOR_API_BASE` (default `https://meshmonitor.kkhost.pl/api/v1`), `MESHMONITOR_SOURCE` (default `default`), `MESHMONITOR_TIMEOUT_MS` (default 15000) and `NODE_TLS_REJECT_UNAUTHORIZED=0` (the gateway uses a self-signed certificate).
## v0.21.12 — 2026-10-06 08:01 ## v0.21.12 — 2026-10-06 08:01
- Battery monitoring now ignores a 0% reading: a 0 (or any non-positive) `batteryLevel` is treated as an invalid/no-data value (node has no battery wired or is misconfigured) and no longer produces a "⚠️ battery low" alert, while genuinely-low readings (1–20%) still alert exactly as before. One-line guard added to `BatteryAlertService.shouldAlert` right after the `undefined` check. Tests: 3 new specs in the `check threshold` block — a 0% node is excluded from the batch (and a lone 0% sends nothing) while a 1% node still alerts. - Battery monitoring now ignores a 0% reading: a 0 (or any non-positive) `batteryLevel` is treated as an invalid/no-data value (node has no battery wired or is misconfigured) and no longer produces a "⚠️ battery low" alert, while genuinely-low readings (1–20%) still alert exactly as before. One-line guard added to `BatteryAlertService.shouldAlert` right after the `undefined` check. Tests: 3 new specs in the `check threshold` block — a 0% node is excluded from the batch (and a lone 0% sends nothing) while a 1% node still alerts.
+6 -2
View File
@@ -3,8 +3,12 @@ services:
build: . build: .
environment: environment:
TZ: Europe/Warsaw TZ: Europe/Warsaw
MESH_HOST: 192.168.1.4 MESHMONITOR_API_TOKEN: ${MESHMONITOR_API_TOKEN:-}
MESH_PORT: '4403' MESHMONITOR_API_BASE: ${MESHMONITOR_API_BASE:-https://meshmonitor.kkhost.pl/api/v1}
MESHMONITOR_SOURCE: ${MESHMONITOR_SOURCE:-default}
MESHMONITOR_TIMEOUT_MS: ${MESHMONITOR_TIMEOUT_MS:-15000}
# the gateway uses a self-signed certificate
NODE_TLS_REJECT_UNAUTHORIZED: '0'
MESH_DB_PATH: ./data/mesh-bot.db MESH_DB_PATH: ./data/mesh-bot.db
MESH_POLL_MS: '1000' MESH_POLL_MS: '1000'
MESH_NOTIFY_CHANNEL: '${MESH_NOTIFY_CHANNEL:-3}' MESH_NOTIFY_CHANNEL: '${MESH_NOTIFY_CHANNEL:-3}'
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mesh-bot", "name": "mesh-bot",
"version": "0.21.12", "version": "0.22.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
+23 -50
View File
@@ -1,10 +1,8 @@
import { fromBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { BROADCAST_ADDR, extractFrames } from '../mesh/codec.js'; import { BROADCAST_ADDR } from '../mesh/codec.js';
import { nodeInfoFrame, startBridge, waitFor } from '../mesh/fake-bridge.js';
import type { MeshService, NodeSummary } from '../mesh/mesh.service.js'; import type { MeshService, NodeSummary } from '../mesh/mesh.service.js';
import { MeshService as RealMeshService } from '../mesh/mesh.service.js'; import { MeshService as RealMeshService } from '../mesh/mesh.service.js';
import { FakeMeshMonitorClient } from '../mesh/meshmonitor/fake-mesh-monitor-client.js';
import { import {
BatteryAlertService, BatteryAlertService,
formatAlert, formatAlert,
@@ -266,63 +264,38 @@ describe('BatteryAlertService check cooldown', () => {
describe('BatteryAlertService check end-to-end (real MeshService)', () => { describe('BatteryAlertService check end-to-end (real MeshService)', () => {
it('broadcasts the alert on "testowy" (channel index 2), not the pinned send channel', async () => { it('broadcasts the alert on "testowy" (channel index 2), not the pinned send channel', async () => {
const bridge = await startBridge(); const client = new FakeMeshMonitorClient({
process.env.MESH_HOST = '127.0.0.1'; connected: true,
process.env.MESH_PORT = String(bridge.port); nodeNum: 3994646728,
});
client.pushNode({
num: 0xabc,
longName: 'Alpha',
batteryLevel: 5,
lastHeard: 1_760_000_000,
});
process.env.MESHMONITOR_API_BASE = 'https://meshmonitor.kkhost.pl/api/v1';
delete process.env.MESH_SEND_CHANNEL; // default pinned send channel is 3 — the alert must NOT ride it delete process.env.MESH_SEND_CHANNEL; // default pinned send channel is 3 — the alert must NOT ride it
const mesh = new RealMeshService(); const mesh = new RealMeshService(undefined, undefined, client);
try { try {
mesh.onModuleInit(); mesh.onModuleInit();
await waitFor( await mesh.pollStatus();
() => bridge.sockets.length === 1 && mesh.status().connected,
);
bridge.sockets[0].write(
nodeInfoFrame(0xabc, {
user: { id: '!a', longName: 'Alpha' },
deviceMetrics: { batteryLevel: 5 },
}),
);
await waitFor(async () => (await mesh.nodes()).length === 1);
const out: Uint8Array[] = [];
bridge.sockets[0].on('data', (c) => out.push(c));
const svc = new BatteryAlertService(mesh); const svc = new BatteryAlertService(mesh);
await svc.check(); await svc.check();
const readOutbound = (): P.Mesh.MeshPacket[] => { // one broadcast on the alert channel ("testowy", index 2) — not the pinned send channel (3)
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0)); expect(client.sends).toHaveLength(1);
let off = 0; expect(client.sends[0]).toMatchObject({
for (const c of out) { text: '⚠️ battery low: @!00000abc (Alpha) 5%',
merged.set(c, off); toNodeId: undefined, // broadcast — not a DM
off += c.length; channel: 2,
} replyId: undefined,
const { packets } = extractFrames(merged);
// outbound starts with the want_config_id frame — collect only text packets, in order
return packets.flatMap((p) => {
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
if (tr.payloadVariant?.case === 'packet')
return [tr.payloadVariant.value];
return [];
}); });
};
await waitFor(() => readOutbound().length === 1);
const pkt = readOutbound()[0];
expect(pkt.channel).toBe(2); // "testowy" — not the pinned send channel (3) or primary (0)
const data =
pkt.payloadVariant?.case === 'decoded'
? pkt.payloadVariant.value
: undefined;
expect(new TextDecoder().decode(data?.payload ?? new Uint8Array())).toBe(
'⚠️ battery low: @!00000abc (Alpha) 5%',
);
} finally { } finally {
delete process.env.MESH_HOST; delete process.env.MESHMONITOR_API_BASE;
delete process.env.MESH_PORT;
delete process.env.MESH_SEND_CHANNEL; delete process.env.MESH_SEND_CHANNEL;
mesh.onModuleDestroy(); mesh.onModuleDestroy();
bridge.close();
} }
}); });
}); });
+97 -79
View File
@@ -3,15 +3,6 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite'; import { DatabaseSync } from 'node:sqlite';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { LlmService } from '../llm/llm.service.js';
import { RssService } from '../rss/rss.service.js';
import {
startBridge,
textPacketFrame,
waitFor,
outboundPackets,
KAMIL_NUM,
} from '../mesh/fake-bridge.js';
import { BROADCAST_ADDR } from '../mesh/codec.js'; import { BROADCAST_ADDR } from '../mesh/codec.js';
import type { import type {
MessageRecord, MessageRecord,
@@ -19,8 +10,15 @@ import type {
NodeSummary, NodeSummary,
} from '../mesh/mesh.service.js'; } from '../mesh/mesh.service.js';
import { MeshService as RealMeshService } from '../mesh/mesh.service.js'; import { MeshService as RealMeshService } from '../mesh/mesh.service.js';
import { FakeMeshMonitorClient } from '../mesh/meshmonitor/fake-mesh-monitor-client.js';
import type { LlmService } from '../llm/llm.service.js';
import { RssService } from '../rss/rss.service.js';
import { CommandService } from './command.service.js'; import { CommandService } from './command.service.js';
// local copies — the fake-bridge constants these specs used are gone with Phase 2
const KAMIL_NUM = 123456789;
const KAMIL_ID = '!075bcd15';
function makeService( function makeService(
sendText?: ReturnType<typeof vi.fn>, sendText?: ReturnType<typeof vi.fn>,
respond?: ReturnType<typeof vi.fn>, respond?: ReturnType<typeof vi.fn>,
@@ -395,116 +393,136 @@ describe('#rss prefix dispatch', () => {
}); });
describe('MeshService wiring (event-driven trigger)', () => { describe('MeshService wiring (event-driven trigger)', () => {
it('answers a #ping DM on ch2 with a directed #pong! back to the sender, same channel, threaded quote', async () => { const MY_NUM = 3994646728;
const bridge = await startBridge(); const T0 = 1_760_000_000_000;
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port); // wire the responder exactly like command.module.ts does in production,
const svc = new RealMeshService(); // against the fake gateway client; `settle` lets the fire-and-forget
try { // onRx → respond → sendText microtask chain finish
// wire the responder exactly like command.module.ts does in production const makeWiredService = async (
client: FakeMeshMonitorClient,
): Promise<RealMeshService> => {
const svc = new RealMeshService(undefined, undefined, client);
svc.attachCommands(new CommandService(svc)); svc.attachCommands(new CommandService(svc));
svc.onModuleInit(); svc.onModuleInit();
await waitFor( await svc.pollStatus();
() => bridge.sockets.length === 1 && svc.status().connected, return svc;
); };
const out: Uint8Array[] = []; const settle = () => new Promise((r) => setTimeout(r, 10));
bridge.sockets[0].on('data', (c) => out.push(c));
// rx DM from KAMIL to node 42 on channel 2 (packet id 1) it('answers a #ping DM on ch2 with a directed #pong! back to the sender, same channel, threaded quote', async () => {
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 2)); const client = new FakeMeshMonitorClient({
connected: true,
nodeNum: MY_NUM,
});
const svc = await makeWiredService(client);
try {
// rx DM from KAMIL to us on channel 2 (packet id 7)
client.pushInbound({
packetId: 7,
fromNum: KAMIL_NUM,
fromId: KAMIL_ID,
toNum: MY_NUM,
text: '#ping',
channel: 2,
portnum: 1,
at: T0,
});
await svc.pollMessages();
await settle();
// rx record + the directed #pong! reply on channel 2 (newest first — the reply is [0]) // history: the reply is recorded as a DM (mesh channel 0 — the gateway reports DMs as -1)
await waitFor(() => svc.messages().length === 2);
expect(svc.messages()[0]).toMatchObject({ expect(svc.messages()[0]).toMatchObject({
dir: 'tx', dir: 'tx',
to: KAMIL_NUM, to: KAMIL_NUM,
text: '#pong!', text: '#pong!',
channel: 2, channel: 0,
}); });
// wire-level: DM back to the sender on ch2 with replyId = the ping packet id (threaded quote) // client-level: a DM back to the sender (toNodeId), quoting the ping packet (threaded reply)
await waitFor(() => outboundPackets(out).length >= 1); expect(client.sends).toHaveLength(1);
const pkt = outboundPackets(out)[0]!; expect(client.sends[0]).toMatchObject({
expect(pkt.to).toBe(KAMIL_NUM); text: '#pong!',
expect(pkt.channel).toBe(2); toNodeId: KAMIL_ID,
const data = channel: undefined,
pkt.payloadVariant?.case === 'decoded' replyId: 7,
? pkt.payloadVariant.value });
: undefined;
expect(data?.replyId).toBe(1);
} finally { } finally {
svc.onModuleDestroy(); svc.onModuleDestroy();
bridge.close();
} }
}); });
it('answers a #ping broadcast on ch2 with a #pong! broadcast, same channel, threaded quote', async () => { it('answers a #ping broadcast on ch2 with a #pong! broadcast, same channel, threaded quote', async () => {
const bridge = await startBridge(); const client = new FakeMeshMonitorClient({
process.env.MESH_HOST = '127.0.0.1'; connected: true,
process.env.MESH_PORT = String(bridge.port); nodeNum: MY_NUM,
const svc = new RealMeshService(); });
const svc = await makeWiredService(client);
try { try {
svc.attachCommands(new CommandService(svc)); // rx broadcast from KAMIL on channel 2 (packet id 7)
svc.onModuleInit(); client.pushInbound({
await waitFor( packetId: 7,
() => bridge.sockets.length === 1 && svc.status().connected, fromNum: KAMIL_NUM,
); fromId: KAMIL_ID,
toId: 'broadcast',
text: '#ping',
channel: 2,
portnum: 1,
at: T0,
});
await svc.pollMessages();
await settle();
const out: Uint8Array[] = []; // broadcast original → broadcast reply, on the same channel
bridge.sockets[0].on('data', (c) => out.push(c));
// rx broadcast from KAMIL on channel 2 (packet id 1)
bridge.sockets[0].write(
textPacketFrame(KAMIL_NUM, BROADCAST_ADDR, '#ping', 2),
);
await waitFor(() => svc.messages().length === 2);
expect(svc.messages()[0]).toMatchObject({ expect(svc.messages()[0]).toMatchObject({
dir: 'tx', dir: 'tx',
to: BROADCAST_ADDR, // broadcast original → broadcast reply to: BROADCAST_ADDR,
text: '#pong!', text: '#pong!',
channel: 2, channel: 2,
}); });
await waitFor(() => outboundPackets(out).length >= 1); expect(client.sends).toHaveLength(1);
const pkt = outboundPackets(out)[0]!; expect(client.sends[0]).toMatchObject({
expect(pkt.to).toBe(BROADCAST_ADDR); text: '#pong!',
expect(pkt.channel).toBe(2); toNodeId: undefined,
const data = channel: 2,
pkt.payloadVariant?.case === 'decoded' replyId: 7, // threaded quote of the ping packet (id 7)
? pkt.payloadVariant.value });
: undefined;
expect(data?.replyId).toBe(1); // threaded quote of the ping packet (id 1)
} finally { } finally {
svc.onModuleDestroy(); svc.onModuleDestroy();
bridge.close();
} }
}); });
it('does not answer #ping arriving on a non-command channel', async () => { it('does not answer #ping arriving on a non-command channel', async () => {
const bridge = await startBridge(); const client = new FakeMeshMonitorClient({
process.env.MESH_HOST = '127.0.0.1'; connected: true,
process.env.MESH_PORT = String(bridge.port); nodeNum: MY_NUM,
const svc = new RealMeshService(); });
const svc = await makeWiredService(client);
try { try {
svc.attachCommands(new CommandService(svc)); client.pushInbound({
svc.onModuleInit(); packetId: 7,
await waitFor( fromNum: KAMIL_NUM,
() => bridge.sockets.length === 1 && svc.status().connected, fromId: KAMIL_ID,
); toNum: MY_NUM,
text: '#ping',
channel: 3, // not the command channel (2)
portnum: 1,
at: T0,
});
await svc.pollMessages();
await settle();
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 3)); // only the rx record — no reply anywhere
await waitFor(() => svc.messages().length === 1); // only the rx record — no reply
expect(svc.messages()[0]).toMatchObject({ expect(svc.messages()[0]).toMatchObject({
dir: 'rx', dir: 'rx',
text: '#ping', text: '#ping',
channel: 3, channel: 3,
}); });
expect(client.sends).toEqual([]);
} finally { } finally {
svc.onModuleDestroy(); svc.onModuleDestroy();
bridge.close();
} }
}); });
}); });
+4
View File
@@ -6,6 +6,10 @@ import { apiReference } from '@scalar/nestjs-api-reference';
import * as pkg from '../package.json'; import * as pkg from '../package.json';
import { AppModule } from './app.module.js'; import { AppModule } from './app.module.js';
// the MeshMonitor gateway (https://meshmonitor.kkhost.pl) uses a self-signed certificate — accept it
// (compose sets the same variable for the deployed stack; this covers local runs too)
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableShutdownHooks(); app.enableShutdownHooks();
-210
View File
@@ -1,210 +0,0 @@
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import * as net from 'node:net';
import type { AddressInfo } from 'node:net';
import { extractFrames, frame } from './codec.js';
/** In-process TCP stand-in for the meshtastic-serial-bridge; hands out accepted sockets. */
export function startBridge(
onSocket?: (s: net.Socket) => void,
): Promise<{ port: number; sockets: net.Socket[]; close: () => void }> {
const sockets: net.Socket[] = [];
const server = net.createServer((socket) => {
// Test-owned socket: the service destroys its end mid-test with unread data pending (overflow/garbage tests), which makes the kernel send RST and this side emit ECONNRESET. Expected teardown noise — without a handler Node throws it as an uncaught exception and vitest fails the whole run.
socket.on('error', () => {});
onSocket?.(socket); // attached at accept time, before any data can arrive
sockets.push(socket);
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve({
port: (server.address() as AddressInfo).port,
sockets,
close: () => server.close(),
});
});
});
}
export async function waitFor(
cond: () => boolean | Promise<boolean>,
timeoutMs = 5000,
): Promise<void> {
const t0 = Date.now();
while (!(await cond())) {
if (Date.now() - t0 > timeoutMs) throw new Error('timed out waiting');
await new Promise((r) => setTimeout(r, 10));
}
}
/** Plain-object NodeInfo fields (init shape, not the runtime message type) accepted by nodeInfoFrame. */
export interface NodeInfoFields {
user?: {
id?: string;
longName?: string;
shortName?: string;
hwModel?: number;
};
position?: { latitudeI?: number; longitudeI?: number; altitude?: number };
lastHeard?: number;
deviceMetrics?: { batteryLevel?: number };
snr?: number;
viaMqtt?: boolean;
hopsAway?: number;
}
// init shape for create(): all fields optional (protobuf-es' MessageInit is not exported)
type Init<T> = {
[P in keyof T as P extends '$unknown' | '$typeName' ? never : P]?: T[P];
};
/** DeviceMetadata frame as streamed once during the initial sync. */
export function metadataFrame(md: Init<P.Mesh.DeviceMetadata>): Uint8Array {
const fr = create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'metadata', value: md },
});
return frame(toBinary(P.Mesh.FromRadioSchema, fr));
}
// Config/ModuleConfig section frames as streamed one-per-section during the initial sync.
// (meshtastic.Config / meshtastic.ModuleConfig live in their own root namespaces, not under Mesh)
export function configSectionFrame(
sectionCase: P.Config.Config['payloadVariant']['case'],
value: unknown,
): Uint8Array {
const cfg = create(P.Config.ConfigSchema, {
payloadVariant: {
case: sectionCase,
value,
} as P.Config.Config['payloadVariant'],
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'config', value: cfg },
}),
),
);
}
export function moduleConfigSectionFrame(
sectionCase: P.ModuleConfig.ModuleConfig['payloadVariant']['case'],
value: unknown,
): Uint8Array {
const mc = create(P.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: sectionCase,
value,
} as P.ModuleConfig.ModuleConfig['payloadVariant'],
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'moduleConfig', value: mc },
}),
),
);
}
export function nodeInfoFrame(
num: number,
fields?: NodeInfoFields,
): Uint8Array {
const ni = create(P.Mesh.NodeInfoSchema, { num, ...fields });
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'nodeInfo', value: ni },
}),
),
);
}
/** TELEMETRY_APP packet frame with a serialized Telemetry in Data.payload (live telemetry from any node). */
export function telemetryPacketFrame(
from: number,
variant: P.Telemetry.Telemetry['variant'],
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.TELEMETRY_APP,
payload: toBinary(
P.Telemetry.TelemetrySchema,
create(P.Telemetry.TelemetrySchema, { variant }),
),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from,
id: 1,
payloadVariant: { case: 'decoded', value: data },
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'packet', value: pkt },
}),
),
);
}
/** Inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload (fixed packet id 1). */
export function textPacketFrame(
from: number,
to: number,
text: string,
channel = 0,
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP,
payload: new TextEncoder().encode(text),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from,
to,
id: 1,
channel,
payloadVariant: { case: 'decoded', value: data },
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'packet', value: pkt },
}),
),
);
}
/** Outbound text packets written to the bridge, in order (want_config frames skipped). */
export function outboundPackets(out: Uint8Array[]): P.Mesh.MeshPacket[] {
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
let off = 0;
for (const c of out) {
merged.set(c, off);
off += c.length;
}
return extractFrames(merged).packets.flatMap((p) => {
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
if (tr.payloadVariant?.case === 'packet') return [tr.payloadVariant.value];
return [];
});
}
export const KAMIL_NUM = 123456789;
export function kamilNodeInfo(): NodeInfoFields {
return {
user: {
id: '!kamil',
longName: 'Kamil SP8MK/P',
shortName: 'KK',
hwModel: P.Mesh.HardwareModel.TBEAM,
},
position: { latitudeI: 521049637, longitudeI: 209833152, altitude: 123 },
lastHeard: 1758000000,
deviceMetrics: { batteryLevel: 87 },
};
}
+102 -64
View File
@@ -1,19 +1,12 @@
import { fromBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import type * as net from 'node:net';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { BROADCAST_ADDR, extractFrames } from './codec.js'; import { BROADCAST_ADDR } from './codec.js';
import { FakeMeshMonitorClient } from './meshmonitor/fake-mesh-monitor-client.js';
import { import {
KAMIL_NUM, nodeNumToId,
kamilNodeInfo, type MMMessage,
nodeInfoFrame, } from './meshmonitor/mesh-monitor-client.js';
startBridge,
telemetryPacketFrame,
textPacketFrame,
waitFor,
} from './fake-bridge.js';
import { createMeshMcpServer } from './mcp.controller.js'; import { createMeshMcpServer } from './mcp.controller.js';
import { MeshService } from './mesh.service.js'; import { MeshService } from './mesh.service.js';
@@ -26,23 +19,63 @@ function toolText(r: Awaited<ReturnType<Client['callTool']>>): string {
return c.text ?? ''; return c.text ?? '';
} }
/** The bot's own mesh node number (the gateway's view of "us"). */
const MY_NUM = 3994646728; // 0xee1978c8
/** A regular peer node. */
const KAMIL_NUM = 123456789;
const KAMIL_ID = nodeNumToId(KAMIL_NUM);
/** A fixed "past" timestamp (2025-10-09) for deterministic ordering. */
const T0 = 1_760_000_000_000;
function kamilNode() {
return { num: KAMIL_NUM, longName: 'Kamil SP8MK/P', shortName: 'KK' };
}
/** An inbound (mesh -> bot) text message as the gateway reports it (channel 3, addressed to us). */
function inbound(over: Partial<MMMessage> & { text: string }): MMMessage {
return {
packetId: 1,
fromNum: KAMIL_NUM,
toNum: MY_NUM,
channel: 3,
portnum: 1,
at: T0,
...over,
};
}
function makeClient(): FakeMeshMonitorClient {
const client = new FakeMeshMonitorClient({ localNum: MY_NUM });
client.setStatus({
connected: true,
myNodeNum: MY_NUM,
longName: 'SP8MK Bot',
nodeResponsive: true,
});
return client;
}
/**
* A fully synced service on a fake gateway client (deterministic: the status
* poll drives the one-shot initial sync; tests drive the other polls explicitly).
*/
async function connectedService(): Promise<{ async function connectedService(): Promise<{
svc: MeshService; svc: MeshService;
sockets: net.Socket[]; client: FakeMeshMonitorClient;
close: () => void; close: () => void;
}> { }> {
const bridge = await startBridge(); // pin the gateway base so the status() host/port assertion is deterministic
process.env.MESH_HOST = '127.0.0.1'; process.env.MESHMONITOR_API_BASE = 'https://meshmonitor.kkhost.pl/api/v1';
process.env.MESH_PORT = String(bridge.port); const client = makeClient();
const svc = new MeshService(); const svc = new MeshService(undefined, undefined, client);
svc.onModuleInit(); svc.onModuleInit();
await waitFor(() => bridge.sockets.length === 1 && svc.status().connected); await svc.pollStatus();
return { return {
svc, svc,
sockets: bridge.sockets, client,
close: () => { close: () => {
svc.onModuleDestroy(); svc.onModuleDestroy();
bridge.close(); delete process.env.MESHMONITOR_API_BASE;
}, },
}; };
} }
@@ -117,7 +150,7 @@ describe('MCP server tools', () => {
} }
}); });
it('mesh_status returns the bridge connection state', async () => { it('mesh_status returns the gateway connection state', async () => {
const env = await connectedService(); const env = await connectedService();
try { try {
const mcp = await connectMcp(env.svc); const mcp = await connectMcp(env.svc);
@@ -129,9 +162,9 @@ describe('MCP server tools', () => {
expect(r.isError).not.toBe(true); expect(r.isError).not.toBe(true);
expect(JSON.parse(toolText(r))).toEqual({ expect(JSON.parse(toolText(r))).toEqual({
connected: true, connected: true,
myNodeNum: null, myNodeNum: MY_NUM,
host: '127.0.0.1', host: 'meshmonitor.kkhost.pl',
port: Number(process.env.MESH_PORT), port: 443,
}); });
} finally { } finally {
await mcp.close(); await mcp.close();
@@ -144,8 +177,8 @@ describe('MCP server tools', () => {
it('send_message broadcasts by default and resolves names to node numbers', async () => { it('send_message broadcasts by default and resolves names to node numbers', async () => {
const env = await connectedService(); const env = await connectedService();
try { try {
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); env.client.setNodes([kamilNode()]);
await waitFor(async () => (await env.svc.nodes()).length === 1); await env.svc.pollNodes();
const mcp = await connectMcp(env.svc); const mcp = await connectMcp(env.svc);
try { try {
@@ -160,6 +193,7 @@ describe('MCP server tools', () => {
dir: 'tx', dir: 'tx',
to: BROADCAST_ADDR, to: BROADCAST_ADDR,
}); });
expect(env.client.sends[0]?.channel).toBe(3); // pinned test channel
// name lookup via the node DB (unique substring) // name lookup via the node DB (unique substring)
const r2 = await mcp.client.callTool({ const r2 = await mcp.client.callTool({
@@ -171,6 +205,7 @@ describe('MCP server tools', () => {
dir: 'tx', dir: 'tx',
to: KAMIL_NUM, to: KAMIL_NUM,
}); });
expect(env.client.sends[1]?.toNodeId).toBe(KAMIL_ID); // a gateway DM
} finally { } finally {
await mcp.close(); await mcp.close();
} }
@@ -209,8 +244,10 @@ describe('MCP server tools', () => {
const env = await connectedService(); const env = await connectedService();
try { try {
// rx DM from KAMIL on channel 3 (packet id 1) // rx DM from KAMIL on channel 3 (packet id 1)
env.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3)); env.client.pushInbound(
await waitFor(() => env.svc.messages().length === 1); inbound({ text: 'ping', packetId: 1, channel: 3 }),
);
await env.svc.pollMessages();
const mcp = await connectMcp(env.svc); const mcp = await connectMcp(env.svc);
try { try {
@@ -221,12 +258,17 @@ describe('MCP server tools', () => {
expect(r1.isError).not.toBe(true); expect(r1.isError).not.toBe(true);
const reply = JSON.parse(toolText(r1)) as { id: number }; const reply = JSON.parse(toolText(r1)) as { id: number };
expect(reply.id).toBeGreaterThan(0); expect(reply.id).toBeGreaterThan(0);
// DM back to the original sender on the original channel // DM back to the original sender — in mesh terms DMs ride the primary
// channel (recorded as 0) and address the node by id
expect(env.svc.messages()[0]).toMatchObject({ expect(env.svc.messages()[0]).toMatchObject({
dir: 'tx', dir: 'tx',
to: KAMIL_NUM, to: KAMIL_NUM,
text: 'pong', text: 'pong',
channel: 3, channel: 0,
});
expect(env.client.sends[0]).toMatchObject({
toNodeId: KAMIL_ID,
replyId: 1,
}); });
const r2 = await mcp.client.callTool({ const r2 = await mcp.client.callTool({
@@ -256,16 +298,11 @@ describe('MCP server tools', () => {
} }
}); });
it('inject_message records an rx entry without sending over RF', async () => { it('inject_message records an rx entry without sending over the air', async () => {
const env = await connectedService(); const env = await connectedService();
try { try {
const mcp = await connectMcp(env.svc); const mcp = await connectMcp(env.svc);
try { try {
// capture outbound bytes: the only frame ever written on connect is want_config,
// so any 'packet' variant after this point would be an RF transmission
const out: Uint8Array[] = [];
env.sockets[0].on('data', (c) => out.push(c));
const r1 = await mcp.client.callTool({ const r1 = await mcp.client.callTool({
name: 'inject_message', name: 'inject_message',
arguments: { text: 'trigger' }, arguments: { text: 'trigger' },
@@ -297,19 +334,8 @@ describe('MCP server tools', () => {
expect(r3.isError).toBe(true); expect(r3.isError).toBe(true);
expect(toolText(r3)).toContain('invalid node number'); expect(toolText(r3)).toContain('invalid node number');
// nothing went over the wire — synthetic inbox injection only. // nothing was transmitted — synthetic inbox injection only
// The want_config frame (written on connect) may or may not have been captured; expect(env.client.sends).toHaveLength(0);
// what matters is that no ToRadio 'packet' variant ever made it to the bridge.
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
let off = 0;
for (const c of out) {
merged.set(c, off);
off += c.length;
}
for (const p of extractFrames(merged).packets) {
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
expect(tr.payloadVariant?.case).not.toBe('packet');
}
} finally { } finally {
await mcp.close(); await mcp.close();
} }
@@ -321,8 +347,8 @@ describe('MCP server tools', () => {
it('get_node resolves by name and errors when missing', async () => { it('get_node resolves by name and errors when missing', async () => {
const env = await connectedService(); const env = await connectedService();
try { try {
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); env.client.setNodes([kamilNode()]);
await waitFor(async () => (await env.svc.nodes()).length === 1); await env.svc.pollNodes();
const mcp = await connectMcp(env.svc); const mcp = await connectMcp(env.svc);
try { try {
@@ -354,8 +380,19 @@ describe('MCP server tools', () => {
it('list_channels, list_nodes, get_messages and device_info return the service data', async () => { it('list_channels, list_nodes, get_messages and device_info return the service data', async () => {
const env = await connectedService(); const env = await connectedService();
try { try {
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); env.client.setChannels([{ index: 3, name: 'test' }]);
await waitFor(async () => (await env.svc.nodes()).length === 1); env.client.setNodes([kamilNode()]);
env.client.setTelemetry([
{
fromNum: KAMIL_NUM,
at: T0,
kind: 'batteryLevel',
data: { value: 87, packetId: 77 },
},
]);
await env.svc.pollChannels();
await env.svc.pollNodes();
await env.svc.pollTelemetry();
const mcp = await connectMcp(env.svc); const mcp = await connectMcp(env.svc);
try { try {
@@ -368,16 +405,24 @@ describe('MCP server tools', () => {
}), }),
), ),
), ),
).toEqual([]); // no channel frames fed yet ).toEqual([
{
index: 3,
name: 'test',
uplinkEnabled: false,
downlinkEnabled: false,
},
]);
// no metadata/config frames fed yet → empty sections, null node num // degraded device info: identity from the gateway, empty config sections
// (the gateway has no device-metadata/config endpoints)
expect( expect(
JSON.parse( JSON.parse(
toolText( toolText(
await mcp.client.callTool({ name: 'device_info', arguments: {} }), await mcp.client.callTool({ name: 'device_info', arguments: {} }),
), ),
), ),
).toEqual({ myNodeNum: null, localConfig: {}, moduleConfig: {} }); ).toEqual({ myNodeNum: MY_NUM, localConfig: {}, moduleConfig: {} });
const nodes = JSON.parse( const nodes = JSON.parse(
toolText( toolText(
@@ -399,13 +444,6 @@ describe('MCP server tools', () => {
) as { dir: string; to?: number }[]; ) as { dir: string; to?: number }[];
expect(msgs[0]).toMatchObject({ dir: 'tx', to: BROADCAST_ADDR }); // newest first expect(msgs[0]).toMatchObject({ dir: 'tx', to: BROADCAST_ADDR }); // newest first
env.sockets[0].write(
telemetryPacketFrame(KAMIL_NUM, {
case: 'deviceMetrics',
value: { batteryLevel: 87 },
}),
);
await waitFor(() => env.svc.telemetry().length === 1);
const tel = JSON.parse( const tel = JSON.parse(
toolText( toolText(
await mcp.client.callTool({ name: 'get_telemetry', arguments: {} }), await mcp.client.callTool({ name: 'get_telemetry', arguments: {} }),
@@ -416,7 +454,7 @@ describe('MCP server tools', () => {
from: KAMIL_NUM, from: KAMIL_NUM,
kind: 'deviceMetrics', kind: 'deviceMetrics',
}); });
expect(tel[0].data).toEqual({ batteryLevel: 87 }); expect(tel[0].data).toEqual({ batteryLevel: { value: 87 } });
} finally { } finally {
await mcp.close(); await mcp.close();
} }
+7 -32
View File
@@ -69,7 +69,7 @@ export class MeshController {
@ApiOperation({ @ApiOperation({
summary: 'Bridge connection status', summary: 'Bridge connection status',
description: description:
'Whether the bot is connected to the meshtastic-serial-bridge and which node number this device has.', 'Whether the device behind the MeshMonitor gateway is connected and which node number this device has; host/port identify the gateway.',
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -77,8 +77,8 @@ export class MeshController {
example: { example: {
connected: true, connected: true,
myNodeNum: 123456789, myNodeNum: 123456789,
host: '192.168.1.4', host: 'meshmonitor.kkhost.pl',
port: 4403, port: 443,
}, },
}) })
status() { status() {
@@ -165,9 +165,9 @@ export class MeshController {
@Get('device') @Get('device')
@ApiOperation({ @ApiOperation({
summary: 'Device info (metadata + config, keys redacted)', summary: 'Device info (firmware; config sections degraded)',
description: description:
'This device\'s node number, firmware version and metadata, plus per-section config and module-config messages captured during the initial sync; empty until it completes. Key material is never exposed: security publicKey/privateKey/adminKey and network wifiPsk are replaced with "REDACTED". In-memory only, reset on every bridge reconnect.', "This device's node number and the firmware version the gateway reports for it. Degraded versus the old TCP bridge: the gateway has no device-metadata or config endpoints, so localConfig/moduleConfig are always empty (key material is never exposed). In-memory only.",
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -175,33 +175,8 @@ export class MeshController {
example: { example: {
myNodeNum: 123456789, myNodeNum: 123456789,
firmwareVersion: '2.7.0.1', firmwareVersion: '2.7.0.1',
metadata: { localConfig: {},
firmwareVersion: '2.7.0.1', moduleConfig: {},
deviceStateVersion: 1,
canShutdown: true,
hasWifi: false,
hasBluetooth: true,
hasEthernet: false,
role: 3,
positionFlags: 65,
hwModel: 42,
hasRemoteHardware: false,
hasPKC: true,
excludedModules: 0,
},
localConfig: {
device: { role: 3, buttonGpio: 13 },
security: {
publicKey: 'REDACTED',
privateKey: 'REDACTED',
adminKey: 'REDACTED',
isManaged: true,
},
network: { wifiEnabled: false, wifiSsid: '', wifiPsk: 'REDACTED' },
},
moduleConfig: {
telemetry: { environmentUpdateInterval: 900 },
},
}, },
}) })
device(): DeviceInfo { device(): DeviceInfo {
+12 -3
View File
@@ -5,6 +5,11 @@ import { DatabaseSync } from 'node:sqlite';
import { OpencodeService } from '../opencode/opencode.service.js'; import { OpencodeService } from '../opencode/opencode.service.js';
import { McpController } from './mcp.controller.js'; import { McpController } from './mcp.controller.js';
import { MeshController } from './mesh.controller.js'; import { MeshController } from './mesh.controller.js';
import {
MESH_MONITOR_CLIENT,
MeshMonitorModule,
} from './meshmonitor/meshmonitor.module.js';
import type { MeshMonitorClient } from './meshmonitor/mesh-monitor-client.js';
import { initMessagesTable, MeshService } from './mesh.service.js'; import { initMessagesTable, MeshService } from './mesh.service.js';
// SQLite-backed message history: file path from MESH_DB_PATH (default ./data/mesh-bot.db), parent dir created if missing. // SQLite-backed message history: file path from MESH_DB_PATH (default ./data/mesh-bot.db), parent dir created if missing.
@@ -17,14 +22,18 @@ function openMessageDb(): DatabaseSync {
} }
@Module({ @Module({
imports: [MeshMonitorModule],
controllers: [MeshController, McpController], controllers: [MeshController, McpController],
providers: [ providers: [
OpencodeService, OpencodeService,
{ {
// the MeshMonitor client is null when MESHMONITOR_API_TOKEN is unset — the service then stays disconnected
provide: MeshService, provide: MeshService,
useFactory: (opencode: OpencodeService) => useFactory: (
new MeshService(openMessageDb(), opencode), opencode: OpencodeService,
inject: [OpencodeService], client: MeshMonitorClient | null,
) => new MeshService(openMessageDb(), opencode, client),
inject: [OpencodeService, MESH_MONITOR_CLIENT],
}, },
], ],
exports: [MeshService], exports: [MeshService],
+1391 -1607
View File
File diff suppressed because it is too large Load Diff
+445 -399
View File
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,7 @@ describe('FakeMeshMonitorClient', () => {
let fake: FakeMeshMonitorClient; let fake: FakeMeshMonitorClient;
beforeEach(() => { beforeEach(() => {
fake = new FakeMeshMonitorClient({ localNum: BOT_NUM }); fake = new FakeMeshMonitorClient({ nodeNum: BOT_NUM });
}); });
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -18,7 +18,7 @@ describe('FakeMeshMonitorClient', () => {
describe('getStatus', () => { describe('getStatus', () => {
it('returns a default status until configured', async () => { it('returns a default status until configured', async () => {
expect(await fake.getStatus()).toEqual({ expect(await new FakeMeshMonitorClient().getStatus()).toEqual({
connected: true, connected: true,
myNodeNum: 0, myNodeNum: 0,
}); });
@@ -78,6 +78,49 @@ describe('FakeMeshMonitorClient', () => {
}); });
}); });
describe('constructor + pushNode', () => {
it('seeds the local node and reports the source identity', async () => {
const f = new FakeMeshMonitorClient({
nodeNum: BOT_NUM,
longName: 'SP8MK Bot',
shortName: '8MKb',
connected: false,
});
expect(await f.getStatus()).toEqual({
connected: false,
myNodeNum: BOT_NUM,
longName: 'SP8MK Bot',
});
const nodes = await f.getNodes();
expect(nodes).toHaveLength(1);
expect(nodes[0]).toMatchObject({
num: BOT_NUM,
longName: 'SP8MK Bot',
shortName: '8MKb',
lastHeard: 0,
});
});
it('pushNode upserts by num — replace in place when present, append when new', async () => {
const f = new FakeMeshMonitorClient({ nodeNum: BOT_NUM });
f.pushNode({ num: BOT_NUM, longName: 'renamed', lastHeard: 5 });
f.pushNode({ num: KAMIL_NUM, longName: 'Kamil' });
const nodes = await f.getNodes();
// order preserved: the seeded bot node first (replaced in place), Kamil appended
expect(nodes).toHaveLength(2);
expect(nodes[0]).toMatchObject({
num: BOT_NUM,
longName: 'renamed',
lastHeard: 5,
});
expect(nodes[1]).toMatchObject({ num: KAMIL_NUM, longName: 'Kamil' });
});
it('a zero/omitted nodeNum seeds no node and reports myNodeNum 0', async () => {
expect(await new FakeMeshMonitorClient().getNodes()).toEqual([]);
});
});
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// channels + telemetry (passthrough of configured data) // channels + telemetry (passthrough of configured data)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -1,10 +1,15 @@
/** /**
* In-memory stand-in for the {@link MeshMonitorClient}, mirroring the * In-memory stand-in for the {@link MeshMonitorClient} — the Phase-2 test
* `FakeBridge` pattern used by the TCP-bridge tests. It lets Phase 2 exercise * driver that lets specs exercise `MeshService` against the HTTP-client
* `MeshService` against the HTTP-client contract without touching the network: * contract without touching the network:
* - set the world with the `set*`/`pushInbound` helpers below * - construct it with the source identity the gateway would report
* (`{ connected, nodeNum, longName, shortName }`); the bot's own node is
* seeded in the node list automatically (the gateway always reports the
* local node)
* - set the world with the `set*`/`pushNode`/`pushInbound` helpers below
* - every `send()` is recorded in {@link FakeMeshMonitorClient.sends} AND * - every `send()` is recorded in {@link FakeMeshMonitorClient.sends} AND
* appears as an outbound entry in `getMessages()` (newest first) * appears as an outbound entry in `getMessages()` (newest first), exactly
* like the real gateway mirrors outbound packets
* *
* It implements the exact {@link MeshMonitorClient} interface, so it is a * It implements the exact {@link MeshMonitorClient} interface, so it is a
* drop-in replacement for the real client in the service's constructor. * drop-in replacement for the real client in the service's constructor.
@@ -47,7 +52,7 @@ export interface FakeMessageInput {
} }
export class FakeMeshMonitorClient implements MeshMonitorClient { export class FakeMeshMonitorClient implements MeshMonitorClient {
private status: MMStatus = { connected: true, myNodeNum: 0 }; private status: MMStatus;
private nodes: MMNode[] = []; private nodes: MMNode[] = [];
private channels: MMChannel[] = []; private channels: MMChannel[] = [];
private telemetry: MMTelemetry[] = []; private telemetry: MMTelemetry[] = [];
@@ -60,8 +65,26 @@ export class FakeMeshMonitorClient implements MeshMonitorClient {
/** Every `send()` in call order. */ /** Every `send()` in call order. */
readonly sends: FakeSendRecord[] = []; readonly sends: FakeSendRecord[] = [];
constructor(opts?: { localNum?: number }) { constructor(opts?: {
this.localNum = opts?.localNum ?? 0; connected?: boolean;
nodeNum?: number;
longName?: string;
shortName?: string;
}) {
this.localNum = opts?.nodeNum ?? 0;
this.status = {
connected: opts?.connected ?? true,
myNodeNum: this.localNum,
};
if (opts?.longName) this.status.longName = opts.longName;
// the gateway always reports the local node in the node list
if (this.localNum !== 0)
this.pushNode({
num: this.localNum,
longName: opts?.longName,
shortName: opts?.shortName,
lastHeard: 0,
});
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -84,6 +107,13 @@ export class FakeMeshMonitorClient implements MeshMonitorClient {
this.telemetry = t.map((x) => ({ ...x })); this.telemetry = t.map((x) => ({ ...x }));
} }
/** Upsert a node by `num` — replace in place when present, append when new. */
pushNode(n: MMNode): void {
const i = this.nodes.findIndex((x) => x.num === n.num);
if (i === -1) this.nodes.push({ ...n });
else this.nodes[i] = { ...n };
}
/** /**
* Record a message as if it arrived on the mesh (inbound). Prepend, so the * Record a message as if it arrived on the mesh (inbound). Prepend, so the
* newest stays first, exactly like the gateway's newest-first ordering. * newest stays first, exactly like the gateway's newest-first ordering.
@@ -92,13 +122,24 @@ export class FakeMeshMonitorClient implements MeshMonitorClient {
this.messages.unshift({ ...m }); this.messages.unshift({ ...m });
} }
/** Reset sends + messages + the packet-id counter (keeps configured world). */ /** Reset sends + messages + the packet-id counter (keeps the configured world). */
clearTraffic(): void { clearTraffic(): void {
this.sends.length = 0; this.sends.length = 0;
this.messages.length = 0; this.messages.length = 0;
this.packetSeq = 0; this.packetSeq = 0;
} }
/**
* Mutate the delivery state of a stored message by packet id — models the
* gateway updating an outbound entry from `pending` to a final state in
* place (the same `id` reappears in later `GET .../messages` windows).
* No-op when no message has the id.
*/
setDeliveryState(packetId: number, state: MMMessage['deliveryState']): void {
for (const m of this.messages)
if (m.packetId === packetId) m.deliveryState = state;
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// MeshMonitorClient // MeshMonitorClient
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -116,7 +157,7 @@ export class FakeMeshMonitorClient implements MeshMonitorClient {
return hit ? { ...hit } : null; return hit ? { ...hit } : null;
} }
/** Match a node by `!hex` id, node number (or numeric string), or name. */ /** Match a node by `!hex` id, node number (or numeric string), or exact name. */
private nodeMatches(n: MMNode, idOrNum: string | number): boolean { private nodeMatches(n: MMNode, idOrNum: string | number): boolean {
if (typeof idOrNum === 'number') return n.num === idOrNum >>> 0; if (typeof idOrNum === 'number') return n.num === idOrNum >>> 0;
const s = String(idOrNum).trim(); const s = String(idOrNum).trim();
@@ -125,7 +166,7 @@ export class FakeMeshMonitorClient implements MeshMonitorClient {
if (asNum != null) return n.num === asNum; if (asNum != null) return n.num === asNum;
// plain numeric string // plain numeric string
if (/^[0-9]+$/.test(s)) return n.num === Number(s); if (/^[0-9]+$/.test(s)) return n.num === Number(s);
// name (case-insensitive, like the existing REST lookup) // name (case-insensitive, exact long/short — like the gateway lookup)
const lower = s.toLowerCase(); const lower = s.toLowerCase();
return ( return (
n.longName?.toLowerCase() === lower || n.longName?.toLowerCase() === lower ||