feat(mesh): drive MeshService through the MeshMonitor HTTP gateway (Phase 2)
CI / build + unit (pull_request) Successful in 1m17s
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:
@@ -1,10 +1,8 @@
|
||||
import { fromBinary } from '@bufbuild/protobuf';
|
||||
import * as P from '@meshtastic/protobufs';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BROADCAST_ADDR, extractFrames } from '../mesh/codec.js';
|
||||
import { nodeInfoFrame, startBridge, waitFor } from '../mesh/fake-bridge.js';
|
||||
import { BROADCAST_ADDR } from '../mesh/codec.js';
|
||||
import type { MeshService, NodeSummary } 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 {
|
||||
BatteryAlertService,
|
||||
formatAlert,
|
||||
@@ -266,63 +264,38 @@ describe('BatteryAlertService check cooldown', () => {
|
||||
|
||||
describe('BatteryAlertService check end-to-end (real MeshService)', () => {
|
||||
it('broadcasts the alert on "testowy" (channel index 2), not the pinned send channel', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
const client = new FakeMeshMonitorClient({
|
||||
connected: true,
|
||||
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
|
||||
const mesh = new RealMeshService();
|
||||
const mesh = new RealMeshService(undefined, undefined, client);
|
||||
try {
|
||||
mesh.onModuleInit();
|
||||
await waitFor(
|
||||
() => bridge.sockets.length === 1 && mesh.status().connected,
|
||||
);
|
||||
await mesh.pollStatus();
|
||||
|
||||
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);
|
||||
await svc.check();
|
||||
|
||||
const readOutbound = (): 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;
|
||||
}
|
||||
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%',
|
||||
);
|
||||
// one broadcast on the alert channel ("testowy", index 2) — not the pinned send channel (3)
|
||||
expect(client.sends).toHaveLength(1);
|
||||
expect(client.sends[0]).toMatchObject({
|
||||
text: '⚠️ battery low: @!00000abc (Alpha) 5%',
|
||||
toNodeId: undefined, // broadcast — not a DM
|
||||
channel: 2,
|
||||
replyId: undefined,
|
||||
});
|
||||
} finally {
|
||||
delete process.env.MESH_HOST;
|
||||
delete process.env.MESH_PORT;
|
||||
delete process.env.MESHMONITOR_API_BASE;
|
||||
delete process.env.MESH_SEND_CHANNEL;
|
||||
mesh.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,15 +3,6 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
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 type {
|
||||
MessageRecord,
|
||||
@@ -19,8 +10,15 @@ import type {
|
||||
NodeSummary,
|
||||
} 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';
|
||||
|
||||
// local copies — the fake-bridge constants these specs used are gone with Phase 2
|
||||
const KAMIL_NUM = 123456789;
|
||||
const KAMIL_ID = '!075bcd15';
|
||||
|
||||
function makeService(
|
||||
sendText?: ReturnType<typeof vi.fn>,
|
||||
respond?: ReturnType<typeof vi.fn>,
|
||||
@@ -395,116 +393,136 @@ describe('#rss prefix dispatch', () => {
|
||||
});
|
||||
|
||||
describe('MeshService wiring (event-driven trigger)', () => {
|
||||
const MY_NUM = 3994646728;
|
||||
const T0 = 1_760_000_000_000;
|
||||
|
||||
// wire the responder exactly like command.module.ts does in production,
|
||||
// against the fake gateway client; `settle` lets the fire-and-forget
|
||||
// onRx → respond → sendText microtask chain finish
|
||||
const makeWiredService = async (
|
||||
client: FakeMeshMonitorClient,
|
||||
): Promise<RealMeshService> => {
|
||||
const svc = new RealMeshService(undefined, undefined, client);
|
||||
svc.attachCommands(new CommandService(svc));
|
||||
svc.onModuleInit();
|
||||
await svc.pollStatus();
|
||||
return svc;
|
||||
};
|
||||
|
||||
const settle = () => new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
it('answers a #ping DM on ch2 with a directed #pong! back to the sender, same channel, threaded quote', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
const svc = new RealMeshService();
|
||||
const client = new FakeMeshMonitorClient({
|
||||
connected: true,
|
||||
nodeNum: MY_NUM,
|
||||
});
|
||||
const svc = await makeWiredService(client);
|
||||
try {
|
||||
// wire the responder exactly like command.module.ts does in production
|
||||
svc.attachCommands(new CommandService(svc));
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||
);
|
||||
// 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();
|
||||
|
||||
const out: Uint8Array[] = [];
|
||||
bridge.sockets[0].on('data', (c) => out.push(c));
|
||||
|
||||
// rx DM from KAMIL to node 42 on channel 2 (packet id 1)
|
||||
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 2));
|
||||
|
||||
// rx record + the directed #pong! reply on channel 2 (newest first — the reply is [0])
|
||||
await waitFor(() => svc.messages().length === 2);
|
||||
// history: the reply is recorded as a DM (mesh channel 0 — the gateway reports DMs as -1)
|
||||
expect(svc.messages()[0]).toMatchObject({
|
||||
dir: 'tx',
|
||||
to: KAMIL_NUM,
|
||||
text: '#pong!',
|
||||
channel: 2,
|
||||
channel: 0,
|
||||
});
|
||||
|
||||
// wire-level: DM back to the sender on ch2 with replyId = the ping packet id (threaded quote)
|
||||
await waitFor(() => outboundPackets(out).length >= 1);
|
||||
const pkt = outboundPackets(out)[0]!;
|
||||
expect(pkt.to).toBe(KAMIL_NUM);
|
||||
expect(pkt.channel).toBe(2);
|
||||
const data =
|
||||
pkt.payloadVariant?.case === 'decoded'
|
||||
? pkt.payloadVariant.value
|
||||
: undefined;
|
||||
expect(data?.replyId).toBe(1);
|
||||
// client-level: a DM back to the sender (toNodeId), quoting the ping packet (threaded reply)
|
||||
expect(client.sends).toHaveLength(1);
|
||||
expect(client.sends[0]).toMatchObject({
|
||||
text: '#pong!',
|
||||
toNodeId: KAMIL_ID,
|
||||
channel: undefined,
|
||||
replyId: 7,
|
||||
});
|
||||
} finally {
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('answers a #ping broadcast on ch2 with a #pong! broadcast, same channel, threaded quote', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
const svc = new RealMeshService();
|
||||
const client = new FakeMeshMonitorClient({
|
||||
connected: true,
|
||||
nodeNum: MY_NUM,
|
||||
});
|
||||
const svc = await makeWiredService(client);
|
||||
try {
|
||||
svc.attachCommands(new CommandService(svc));
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||
);
|
||||
// rx broadcast from KAMIL on channel 2 (packet id 7)
|
||||
client.pushInbound({
|
||||
packetId: 7,
|
||||
fromNum: KAMIL_NUM,
|
||||
fromId: KAMIL_ID,
|
||||
toId: 'broadcast',
|
||||
text: '#ping',
|
||||
channel: 2,
|
||||
portnum: 1,
|
||||
at: T0,
|
||||
});
|
||||
await svc.pollMessages();
|
||||
await settle();
|
||||
|
||||
const out: Uint8Array[] = [];
|
||||
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);
|
||||
// broadcast original → broadcast reply, on the same channel
|
||||
expect(svc.messages()[0]).toMatchObject({
|
||||
dir: 'tx',
|
||||
to: BROADCAST_ADDR, // broadcast original → broadcast reply
|
||||
to: BROADCAST_ADDR,
|
||||
text: '#pong!',
|
||||
channel: 2,
|
||||
});
|
||||
|
||||
await waitFor(() => outboundPackets(out).length >= 1);
|
||||
const pkt = outboundPackets(out)[0]!;
|
||||
expect(pkt.to).toBe(BROADCAST_ADDR);
|
||||
expect(pkt.channel).toBe(2);
|
||||
const data =
|
||||
pkt.payloadVariant?.case === 'decoded'
|
||||
? pkt.payloadVariant.value
|
||||
: undefined;
|
||||
expect(data?.replyId).toBe(1); // threaded quote of the ping packet (id 1)
|
||||
expect(client.sends).toHaveLength(1);
|
||||
expect(client.sends[0]).toMatchObject({
|
||||
text: '#pong!',
|
||||
toNodeId: undefined,
|
||||
channel: 2,
|
||||
replyId: 7, // threaded quote of the ping packet (id 7)
|
||||
});
|
||||
} finally {
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not answer #ping arriving on a non-command channel', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
const svc = new RealMeshService();
|
||||
const client = new FakeMeshMonitorClient({
|
||||
connected: true,
|
||||
nodeNum: MY_NUM,
|
||||
});
|
||||
const svc = await makeWiredService(client);
|
||||
try {
|
||||
svc.attachCommands(new CommandService(svc));
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||
);
|
||||
client.pushInbound({
|
||||
packetId: 7,
|
||||
fromNum: KAMIL_NUM,
|
||||
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));
|
||||
|
||||
await waitFor(() => svc.messages().length === 1); // only the rx record — no reply
|
||||
// only the rx record — no reply anywhere
|
||||
expect(svc.messages()[0]).toMatchObject({
|
||||
dir: 'rx',
|
||||
text: '#ping',
|
||||
channel: 3,
|
||||
});
|
||||
expect(client.sends).toEqual([]);
|
||||
} finally {
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,10 @@ import { apiReference } from '@scalar/nestjs-api-reference';
|
||||
import * as pkg from '../package.json';
|
||||
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() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.enableShutdownHooks();
|
||||
|
||||
@@ -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
@@ -1,19 +1,12 @@
|
||||
import { fromBinary } from '@bufbuild/protobuf';
|
||||
import * as P from '@meshtastic/protobufs';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
import type * as net from 'node:net';
|
||||
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 {
|
||||
KAMIL_NUM,
|
||||
kamilNodeInfo,
|
||||
nodeInfoFrame,
|
||||
startBridge,
|
||||
telemetryPacketFrame,
|
||||
textPacketFrame,
|
||||
waitFor,
|
||||
} from './fake-bridge.js';
|
||||
nodeNumToId,
|
||||
type MMMessage,
|
||||
} from './meshmonitor/mesh-monitor-client.js';
|
||||
import { createMeshMcpServer } from './mcp.controller.js';
|
||||
import { MeshService } from './mesh.service.js';
|
||||
|
||||
@@ -26,23 +19,63 @@ function toolText(r: Awaited<ReturnType<Client['callTool']>>): string {
|
||||
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<{
|
||||
svc: MeshService;
|
||||
sockets: net.Socket[];
|
||||
client: FakeMeshMonitorClient;
|
||||
close: () => void;
|
||||
}> {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
const svc = new MeshService();
|
||||
// pin the gateway base so the status() host/port assertion is deterministic
|
||||
process.env.MESHMONITOR_API_BASE = 'https://meshmonitor.kkhost.pl/api/v1';
|
||||
const client = makeClient();
|
||||
const svc = new MeshService(undefined, undefined, client);
|
||||
svc.onModuleInit();
|
||||
await waitFor(() => bridge.sockets.length === 1 && svc.status().connected);
|
||||
await svc.pollStatus();
|
||||
return {
|
||||
svc,
|
||||
sockets: bridge.sockets,
|
||||
client,
|
||||
close: () => {
|
||||
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();
|
||||
try {
|
||||
const mcp = await connectMcp(env.svc);
|
||||
@@ -129,9 +162,9 @@ describe('MCP server tools', () => {
|
||||
expect(r.isError).not.toBe(true);
|
||||
expect(JSON.parse(toolText(r))).toEqual({
|
||||
connected: true,
|
||||
myNodeNum: null,
|
||||
host: '127.0.0.1',
|
||||
port: Number(process.env.MESH_PORT),
|
||||
myNodeNum: MY_NUM,
|
||||
host: 'meshmonitor.kkhost.pl',
|
||||
port: 443,
|
||||
});
|
||||
} finally {
|
||||
await mcp.close();
|
||||
@@ -144,8 +177,8 @@ describe('MCP server tools', () => {
|
||||
it('send_message broadcasts by default and resolves names to node numbers', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||
await waitFor(async () => (await env.svc.nodes()).length === 1);
|
||||
env.client.setNodes([kamilNode()]);
|
||||
await env.svc.pollNodes();
|
||||
|
||||
const mcp = await connectMcp(env.svc);
|
||||
try {
|
||||
@@ -160,6 +193,7 @@ describe('MCP server tools', () => {
|
||||
dir: 'tx',
|
||||
to: BROADCAST_ADDR,
|
||||
});
|
||||
expect(env.client.sends[0]?.channel).toBe(3); // pinned test channel
|
||||
|
||||
// name lookup via the node DB (unique substring)
|
||||
const r2 = await mcp.client.callTool({
|
||||
@@ -171,6 +205,7 @@ describe('MCP server tools', () => {
|
||||
dir: 'tx',
|
||||
to: KAMIL_NUM,
|
||||
});
|
||||
expect(env.client.sends[1]?.toNodeId).toBe(KAMIL_ID); // a gateway DM
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
@@ -209,8 +244,10 @@ describe('MCP server tools', () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
// rx DM from KAMIL on channel 3 (packet id 1)
|
||||
env.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3));
|
||||
await waitFor(() => env.svc.messages().length === 1);
|
||||
env.client.pushInbound(
|
||||
inbound({ text: 'ping', packetId: 1, channel: 3 }),
|
||||
);
|
||||
await env.svc.pollMessages();
|
||||
|
||||
const mcp = await connectMcp(env.svc);
|
||||
try {
|
||||
@@ -221,12 +258,17 @@ describe('MCP server tools', () => {
|
||||
expect(r1.isError).not.toBe(true);
|
||||
const reply = JSON.parse(toolText(r1)) as { id: number };
|
||||
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({
|
||||
dir: 'tx',
|
||||
to: KAMIL_NUM,
|
||||
text: 'pong',
|
||||
channel: 3,
|
||||
channel: 0,
|
||||
});
|
||||
expect(env.client.sends[0]).toMatchObject({
|
||||
toNodeId: KAMIL_ID,
|
||||
replyId: 1,
|
||||
});
|
||||
|
||||
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();
|
||||
try {
|
||||
const mcp = await connectMcp(env.svc);
|
||||
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({
|
||||
name: 'inject_message',
|
||||
arguments: { text: 'trigger' },
|
||||
@@ -297,19 +334,8 @@ describe('MCP server tools', () => {
|
||||
expect(r3.isError).toBe(true);
|
||||
expect(toolText(r3)).toContain('invalid node number');
|
||||
|
||||
// nothing went over the wire — synthetic inbox injection only.
|
||||
// The want_config frame (written on connect) may or may not have been captured;
|
||||
// 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');
|
||||
}
|
||||
// nothing was transmitted — synthetic inbox injection only
|
||||
expect(env.client.sends).toHaveLength(0);
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
@@ -321,8 +347,8 @@ describe('MCP server tools', () => {
|
||||
it('get_node resolves by name and errors when missing', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||
await waitFor(async () => (await env.svc.nodes()).length === 1);
|
||||
env.client.setNodes([kamilNode()]);
|
||||
await env.svc.pollNodes();
|
||||
|
||||
const mcp = await connectMcp(env.svc);
|
||||
try {
|
||||
@@ -354,8 +380,19 @@ describe('MCP server tools', () => {
|
||||
it('list_channels, list_nodes, get_messages and device_info return the service data', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||
await waitFor(async () => (await env.svc.nodes()).length === 1);
|
||||
env.client.setChannels([{ index: 3, name: 'test' }]);
|
||||
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);
|
||||
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(
|
||||
JSON.parse(
|
||||
toolText(
|
||||
await mcp.client.callTool({ name: 'device_info', arguments: {} }),
|
||||
),
|
||||
),
|
||||
).toEqual({ myNodeNum: null, localConfig: {}, moduleConfig: {} });
|
||||
).toEqual({ myNodeNum: MY_NUM, localConfig: {}, moduleConfig: {} });
|
||||
|
||||
const nodes = JSON.parse(
|
||||
toolText(
|
||||
@@ -399,13 +444,6 @@ describe('MCP server tools', () => {
|
||||
) as { dir: string; to?: number }[];
|
||||
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(
|
||||
toolText(
|
||||
await mcp.client.callTool({ name: 'get_telemetry', arguments: {} }),
|
||||
@@ -416,7 +454,7 @@ describe('MCP server tools', () => {
|
||||
from: KAMIL_NUM,
|
||||
kind: 'deviceMetrics',
|
||||
});
|
||||
expect(tel[0].data).toEqual({ batteryLevel: 87 });
|
||||
expect(tel[0].data).toEqual({ batteryLevel: { value: 87 } });
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export class MeshController {
|
||||
@ApiOperation({
|
||||
summary: 'Bridge connection status',
|
||||
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({
|
||||
status: 200,
|
||||
@@ -77,8 +77,8 @@ export class MeshController {
|
||||
example: {
|
||||
connected: true,
|
||||
myNodeNum: 123456789,
|
||||
host: '192.168.1.4',
|
||||
port: 4403,
|
||||
host: 'meshmonitor.kkhost.pl',
|
||||
port: 443,
|
||||
},
|
||||
})
|
||||
status() {
|
||||
@@ -165,9 +165,9 @@ export class MeshController {
|
||||
|
||||
@Get('device')
|
||||
@ApiOperation({
|
||||
summary: 'Device info (metadata + config, keys redacted)',
|
||||
summary: 'Device info (firmware; config sections degraded)',
|
||||
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({
|
||||
status: 200,
|
||||
@@ -175,33 +175,8 @@ export class MeshController {
|
||||
example: {
|
||||
myNodeNum: 123456789,
|
||||
firmwareVersion: '2.7.0.1',
|
||||
metadata: {
|
||||
firmwareVersion: '2.7.0.1',
|
||||
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 },
|
||||
},
|
||||
localConfig: {},
|
||||
moduleConfig: {},
|
||||
},
|
||||
})
|
||||
device(): DeviceInfo {
|
||||
|
||||
+12
-3
@@ -5,6 +5,11 @@ import { DatabaseSync } from 'node:sqlite';
|
||||
import { OpencodeService } from '../opencode/opencode.service.js';
|
||||
import { McpController } from './mcp.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';
|
||||
|
||||
// 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({
|
||||
imports: [MeshMonitorModule],
|
||||
controllers: [MeshController, McpController],
|
||||
providers: [
|
||||
OpencodeService,
|
||||
{
|
||||
// the MeshMonitor client is null when MESHMONITOR_API_TOKEN is unset — the service then stays disconnected
|
||||
provide: MeshService,
|
||||
useFactory: (opencode: OpencodeService) =>
|
||||
new MeshService(openMessageDb(), opencode),
|
||||
inject: [OpencodeService],
|
||||
useFactory: (
|
||||
opencode: OpencodeService,
|
||||
client: MeshMonitorClient | null,
|
||||
) => new MeshService(openMessageDb(), opencode, client),
|
||||
inject: [OpencodeService, MESH_MONITOR_CLIENT],
|
||||
},
|
||||
],
|
||||
exports: [MeshService],
|
||||
|
||||
+1400
-1616
File diff suppressed because it is too large
Load Diff
+460
-414
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ describe('FakeMeshMonitorClient', () => {
|
||||
let fake: FakeMeshMonitorClient;
|
||||
|
||||
beforeEach(() => {
|
||||
fake = new FakeMeshMonitorClient({ localNum: BOT_NUM });
|
||||
fake = new FakeMeshMonitorClient({ nodeNum: BOT_NUM });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -18,7 +18,7 @@ describe('FakeMeshMonitorClient', () => {
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('returns a default status until configured', async () => {
|
||||
expect(await fake.getStatus()).toEqual({
|
||||
expect(await new FakeMeshMonitorClient().getStatus()).toEqual({
|
||||
connected: true,
|
||||
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)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
/**
|
||||
* In-memory stand-in for the {@link MeshMonitorClient}, mirroring the
|
||||
* `FakeBridge` pattern used by the TCP-bridge tests. It lets Phase 2 exercise
|
||||
* `MeshService` against the HTTP-client contract without touching the network:
|
||||
* - set the world with the `set*`/`pushInbound` helpers below
|
||||
* In-memory stand-in for the {@link MeshMonitorClient} — the Phase-2 test
|
||||
* driver that lets specs exercise `MeshService` against the HTTP-client
|
||||
* contract without touching the network:
|
||||
* - 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
|
||||
* 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
|
||||
* drop-in replacement for the real client in the service's constructor.
|
||||
@@ -47,7 +52,7 @@ export interface FakeMessageInput {
|
||||
}
|
||||
|
||||
export class FakeMeshMonitorClient implements MeshMonitorClient {
|
||||
private status: MMStatus = { connected: true, myNodeNum: 0 };
|
||||
private status: MMStatus;
|
||||
private nodes: MMNode[] = [];
|
||||
private channels: MMChannel[] = [];
|
||||
private telemetry: MMTelemetry[] = [];
|
||||
@@ -60,8 +65,26 @@ export class FakeMeshMonitorClient implements MeshMonitorClient {
|
||||
/** Every `send()` in call order. */
|
||||
readonly sends: FakeSendRecord[] = [];
|
||||
|
||||
constructor(opts?: { localNum?: number }) {
|
||||
this.localNum = opts?.localNum ?? 0;
|
||||
constructor(opts?: {
|
||||
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 }));
|
||||
}
|
||||
|
||||
/** 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
|
||||
* newest stays first, exactly like the gateway's newest-first ordering.
|
||||
@@ -92,13 +122,24 @@ export class FakeMeshMonitorClient implements MeshMonitorClient {
|
||||
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 {
|
||||
this.sends.length = 0;
|
||||
this.messages.length = 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
|
||||
// ------------------------------------------------------------------
|
||||
@@ -116,7 +157,7 @@ export class FakeMeshMonitorClient implements MeshMonitorClient {
|
||||
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 {
|
||||
if (typeof idOrNum === 'number') return n.num === idOrNum >>> 0;
|
||||
const s = String(idOrNum).trim();
|
||||
@@ -125,7 +166,7 @@ export class FakeMeshMonitorClient implements MeshMonitorClient {
|
||||
if (asNum != null) return n.num === asNum;
|
||||
// plain numeric string
|
||||
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();
|
||||
return (
|
||||
n.longName?.toLowerCase() === lower ||
|
||||
|
||||
Reference in New Issue
Block a user