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,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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 { 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+444
-398
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 ||
|
||||||
|
|||||||
Reference in New Issue
Block a user