Drive MeshService through the MeshMonitor HTTP gateway (Phase 2) #56

Merged
kamilkrzywda merged 2 commits from feat/meshmonitor-connection into main 2026-10-13 03:10:15 +02:00
14 changed files with 2221 additions and 2487 deletions
Showing only changes of commit f6b884dfdf - Show all commits
+9
View File
@@ -1,5 +1,14 @@
# 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
- Battery monitoring now ignores a 0% reading: a 0 (or any non-positive) `batteryLevel` is treated as an invalid/no-data value (node has no battery wired or is misconfigured) and no longer produces a "⚠️ battery low" alert, while genuinely-low readings (1–20%) still alert exactly as before. One-line guard added to `BatteryAlertService.shouldAlert` right after the `undefined` check. Tests: 3 new specs in the `check threshold` block — a 0% node is excluded from the batch (and a lone 0% sends nothing) while a 1% node still alerts.
+6 -2
View File
@@ -3,8 +3,12 @@ services:
build: .
environment:
TZ: Europe/Warsaw
MESH_HOST: 192.168.1.4
MESH_PORT: '4403'
MESHMONITOR_API_TOKEN: ${MESHMONITOR_API_TOKEN:-}
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_POLL_MS: '1000'
MESH_NOTIFY_CHANNEL: '${MESH_NOTIFY_CHANNEL:-3}'
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mesh-bot",
"version": "0.21.12",
"version": "0.22.0",
"description": "",
"author": "",
"private": true,
+23 -50
View File
@@ -1,10 +1,8 @@
import { fromBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { 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 [];
// 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,
});
};
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 {
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();
}
});
});
+97 -79
View File
@@ -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)', () => {
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();
try {
// wire the responder exactly like command.module.ts does in production
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 waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
await svc.pollStatus();
return svc;
};
const out: Uint8Array[] = [];
bridge.sockets[0].on('data', (c) => out.push(c));
const settle = () => new Promise((r) => setTimeout(r, 10));
// rx DM from KAMIL to node 42 on channel 2 (packet id 1)
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 2));
it('answers a #ping DM on ch2 with a directed #pong! back to the sender, same channel, threaded quote', async () => {
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])
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();
}
});
});
+4
View File
@@ -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();
-210
View File
@@ -1,210 +0,0 @@
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import * as net from 'node:net';
import type { AddressInfo } from 'node:net';
import { extractFrames, frame } from './codec.js';
/** In-process TCP stand-in for the meshtastic-serial-bridge; hands out accepted sockets. */
export function startBridge(
onSocket?: (s: net.Socket) => void,
): Promise<{ port: number; sockets: net.Socket[]; close: () => void }> {
const sockets: net.Socket[] = [];
const server = net.createServer((socket) => {
// Test-owned socket: the service destroys its end mid-test with unread data pending (overflow/garbage tests), which makes the kernel send RST and this side emit ECONNRESET. Expected teardown noise — without a handler Node throws it as an uncaught exception and vitest fails the whole run.
socket.on('error', () => {});
onSocket?.(socket); // attached at accept time, before any data can arrive
sockets.push(socket);
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve({
port: (server.address() as AddressInfo).port,
sockets,
close: () => server.close(),
});
});
});
}
export async function waitFor(
cond: () => boolean | Promise<boolean>,
timeoutMs = 5000,
): Promise<void> {
const t0 = Date.now();
while (!(await cond())) {
if (Date.now() - t0 > timeoutMs) throw new Error('timed out waiting');
await new Promise((r) => setTimeout(r, 10));
}
}
/** Plain-object NodeInfo fields (init shape, not the runtime message type) accepted by nodeInfoFrame. */
export interface NodeInfoFields {
user?: {
id?: string;
longName?: string;
shortName?: string;
hwModel?: number;
};
position?: { latitudeI?: number; longitudeI?: number; altitude?: number };
lastHeard?: number;
deviceMetrics?: { batteryLevel?: number };
snr?: number;
viaMqtt?: boolean;
hopsAway?: number;
}
// init shape for create(): all fields optional (protobuf-es' MessageInit is not exported)
type Init<T> = {
[P in keyof T as P extends '$unknown' | '$typeName' ? never : P]?: T[P];
};
/** DeviceMetadata frame as streamed once during the initial sync. */
export function metadataFrame(md: Init<P.Mesh.DeviceMetadata>): Uint8Array {
const fr = create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'metadata', value: md },
});
return frame(toBinary(P.Mesh.FromRadioSchema, fr));
}
// Config/ModuleConfig section frames as streamed one-per-section during the initial sync.
// (meshtastic.Config / meshtastic.ModuleConfig live in their own root namespaces, not under Mesh)
export function configSectionFrame(
sectionCase: P.Config.Config['payloadVariant']['case'],
value: unknown,
): Uint8Array {
const cfg = create(P.Config.ConfigSchema, {
payloadVariant: {
case: sectionCase,
value,
} as P.Config.Config['payloadVariant'],
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'config', value: cfg },
}),
),
);
}
export function moduleConfigSectionFrame(
sectionCase: P.ModuleConfig.ModuleConfig['payloadVariant']['case'],
value: unknown,
): Uint8Array {
const mc = create(P.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: sectionCase,
value,
} as P.ModuleConfig.ModuleConfig['payloadVariant'],
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'moduleConfig', value: mc },
}),
),
);
}
export function nodeInfoFrame(
num: number,
fields?: NodeInfoFields,
): Uint8Array {
const ni = create(P.Mesh.NodeInfoSchema, { num, ...fields });
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'nodeInfo', value: ni },
}),
),
);
}
/** TELEMETRY_APP packet frame with a serialized Telemetry in Data.payload (live telemetry from any node). */
export function telemetryPacketFrame(
from: number,
variant: P.Telemetry.Telemetry['variant'],
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.TELEMETRY_APP,
payload: toBinary(
P.Telemetry.TelemetrySchema,
create(P.Telemetry.TelemetrySchema, { variant }),
),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from,
id: 1,
payloadVariant: { case: 'decoded', value: data },
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'packet', value: pkt },
}),
),
);
}
/** Inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload (fixed packet id 1). */
export function textPacketFrame(
from: number,
to: number,
text: string,
channel = 0,
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP,
payload: new TextEncoder().encode(text),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from,
to,
id: 1,
channel,
payloadVariant: { case: 'decoded', value: data },
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'packet', value: pkt },
}),
),
);
}
/** Outbound text packets written to the bridge, in order (want_config frames skipped). */
export function outboundPackets(out: Uint8Array[]): P.Mesh.MeshPacket[] {
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
let off = 0;
for (const c of out) {
merged.set(c, off);
off += c.length;
}
return extractFrames(merged).packets.flatMap((p) => {
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
if (tr.payloadVariant?.case === 'packet') return [tr.payloadVariant.value];
return [];
});
}
export const KAMIL_NUM = 123456789;
export function kamilNodeInfo(): NodeInfoFields {
return {
user: {
id: '!kamil',
longName: 'Kamil SP8MK/P',
shortName: 'KK',
hwModel: P.Mesh.HardwareModel.TBEAM,
},
position: { latitudeI: 521049637, longitudeI: 209833152, altitude: 123 },
lastHeard: 1758000000,
deviceMetrics: { batteryLevel: 87 },
};
}
+102 -64
View File
@@ -1,19 +1,12 @@
import { fromBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { 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();
}
+7 -32
View File
@@ -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
View File
@@ -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],
+1391 -1607
View File
File diff suppressed because it is too large Load Diff
+445 -399
View File
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,7 @@ describe('FakeMeshMonitorClient', () => {
let fake: FakeMeshMonitorClient;
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 ||