diff --git a/CHANGELOG.md b/CHANGELOG.md index bb56f20..671815e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml index 39f40de..869888a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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}' diff --git a/package.json b/package.json index 367b0de..71424ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.21.12", + "version": "0.22.0", "description": "", "author": "", "private": true, diff --git a/src/battery/battery.service.spec.ts b/src/battery/battery.service.spec.ts index 1187ac7..9338331 100644 --- a/src/battery/battery.service.spec.ts +++ b/src/battery/battery.service.spec.ts @@ -1,10 +1,8 @@ -import { fromBinary } from '@bufbuild/protobuf'; -import * as P from '@meshtastic/protobufs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { BROADCAST_ADDR, extractFrames } from '../mesh/codec.js'; -import { nodeInfoFrame, startBridge, waitFor } from '../mesh/fake-bridge.js'; +import { BROADCAST_ADDR } from '../mesh/codec.js'; import type { MeshService, NodeSummary } from '../mesh/mesh.service.js'; import { MeshService as RealMeshService } from '../mesh/mesh.service.js'; +import { FakeMeshMonitorClient } from '../mesh/meshmonitor/fake-mesh-monitor-client.js'; import { BatteryAlertService, formatAlert, @@ -266,63 +264,38 @@ describe('BatteryAlertService check cooldown', () => { describe('BatteryAlertService check end-to-end (real MeshService)', () => { it('broadcasts the alert on "testowy" (channel index 2), not the pinned send channel', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); + const client = new FakeMeshMonitorClient({ + connected: true, + nodeNum: 3994646728, + }); + client.pushNode({ + num: 0xabc, + longName: 'Alpha', + batteryLevel: 5, + lastHeard: 1_760_000_000, + }); + process.env.MESHMONITOR_API_BASE = 'https://meshmonitor.kkhost.pl/api/v1'; delete process.env.MESH_SEND_CHANNEL; // default pinned send channel is 3 — the alert must NOT ride it - const mesh = new RealMeshService(); + const mesh = new RealMeshService(undefined, undefined, client); try { mesh.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && mesh.status().connected, - ); + await mesh.pollStatus(); - bridge.sockets[0].write( - nodeInfoFrame(0xabc, { - user: { id: '!a', longName: 'Alpha' }, - deviceMetrics: { batteryLevel: 5 }, - }), - ); - await waitFor(async () => (await mesh.nodes()).length === 1); - - const out: Uint8Array[] = []; - bridge.sockets[0].on('data', (c) => out.push(c)); const svc = new BatteryAlertService(mesh); await svc.check(); - const readOutbound = (): P.Mesh.MeshPacket[] => { - const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0)); - let off = 0; - for (const c of out) { - merged.set(c, off); - off += c.length; - } - const { packets } = extractFrames(merged); - // outbound starts with the want_config_id frame — collect only text packets, in order - return packets.flatMap((p) => { - const tr = fromBinary(P.Mesh.ToRadioSchema, p); - if (tr.payloadVariant?.case === 'packet') - return [tr.payloadVariant.value]; - return []; - }); - }; - - await waitFor(() => readOutbound().length === 1); - const pkt = readOutbound()[0]; - expect(pkt.channel).toBe(2); // "testowy" — not the pinned send channel (3) or primary (0) - const data = - pkt.payloadVariant?.case === 'decoded' - ? pkt.payloadVariant.value - : undefined; - expect(new TextDecoder().decode(data?.payload ?? new Uint8Array())).toBe( - '⚠️ battery low: @!00000abc (Alpha) 5%', - ); + // one broadcast on the alert channel ("testowy", index 2) — not the pinned send channel (3) + expect(client.sends).toHaveLength(1); + expect(client.sends[0]).toMatchObject({ + text: '⚠️ battery low: @!00000abc (Alpha) 5%', + toNodeId: undefined, // broadcast — not a DM + channel: 2, + replyId: undefined, + }); } finally { - delete process.env.MESH_HOST; - delete process.env.MESH_PORT; + delete process.env.MESHMONITOR_API_BASE; delete process.env.MESH_SEND_CHANNEL; mesh.onModuleDestroy(); - bridge.close(); } }); }); diff --git a/src/commands/command.service.spec.ts b/src/commands/command.service.spec.ts index 09c443d..b26c469 100644 --- a/src/commands/command.service.spec.ts +++ b/src/commands/command.service.spec.ts @@ -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, respond?: ReturnType, @@ -395,116 +393,136 @@ describe('#rss prefix dispatch', () => { }); describe('MeshService wiring (event-driven trigger)', () => { + const MY_NUM = 3994646728; + const T0 = 1_760_000_000_000; + + // wire the responder exactly like command.module.ts does in production, + // against the fake gateway client; `settle` lets the fire-and-forget + // onRx → respond → sendText microtask chain finish + const makeWiredService = async ( + client: FakeMeshMonitorClient, + ): Promise => { + const svc = new RealMeshService(undefined, undefined, client); + svc.attachCommands(new CommandService(svc)); + svc.onModuleInit(); + await svc.pollStatus(); + return svc; + }; + + const settle = () => new Promise((r) => setTimeout(r, 10)); + it('answers a #ping DM on ch2 with a directed #pong! back to the sender, same channel, threaded quote', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new RealMeshService(); + const client = new FakeMeshMonitorClient({ + connected: true, + nodeNum: MY_NUM, + }); + const svc = await makeWiredService(client); try { - // wire the responder exactly like command.module.ts does in production - svc.attachCommands(new CommandService(svc)); - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); + // rx DM from KAMIL to us on channel 2 (packet id 7) + client.pushInbound({ + packetId: 7, + fromNum: KAMIL_NUM, + fromId: KAMIL_ID, + toNum: MY_NUM, + text: '#ping', + channel: 2, + portnum: 1, + at: T0, + }); + await svc.pollMessages(); + await settle(); - const out: Uint8Array[] = []; - bridge.sockets[0].on('data', (c) => out.push(c)); - - // rx DM from KAMIL to node 42 on channel 2 (packet id 1) - bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 2)); - - // rx record + the directed #pong! reply on channel 2 (newest first — the reply is [0]) - await waitFor(() => svc.messages().length === 2); + // history: the reply is recorded as a DM (mesh channel 0 — the gateway reports DMs as -1) expect(svc.messages()[0]).toMatchObject({ dir: 'tx', to: KAMIL_NUM, text: '#pong!', - channel: 2, + channel: 0, }); - // wire-level: DM back to the sender on ch2 with replyId = the ping packet id (threaded quote) - await waitFor(() => outboundPackets(out).length >= 1); - const pkt = outboundPackets(out)[0]!; - expect(pkt.to).toBe(KAMIL_NUM); - expect(pkt.channel).toBe(2); - const data = - pkt.payloadVariant?.case === 'decoded' - ? pkt.payloadVariant.value - : undefined; - expect(data?.replyId).toBe(1); + // client-level: a DM back to the sender (toNodeId), quoting the ping packet (threaded reply) + expect(client.sends).toHaveLength(1); + expect(client.sends[0]).toMatchObject({ + text: '#pong!', + toNodeId: KAMIL_ID, + channel: undefined, + replyId: 7, + }); } finally { svc.onModuleDestroy(); - bridge.close(); } }); it('answers a #ping broadcast on ch2 with a #pong! broadcast, same channel, threaded quote', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new RealMeshService(); + const client = new FakeMeshMonitorClient({ + connected: true, + nodeNum: MY_NUM, + }); + const svc = await makeWiredService(client); try { - svc.attachCommands(new CommandService(svc)); - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); + // rx broadcast from KAMIL on channel 2 (packet id 7) + client.pushInbound({ + packetId: 7, + fromNum: KAMIL_NUM, + fromId: KAMIL_ID, + toId: 'broadcast', + text: '#ping', + channel: 2, + portnum: 1, + at: T0, + }); + await svc.pollMessages(); + await settle(); - const out: Uint8Array[] = []; - bridge.sockets[0].on('data', (c) => out.push(c)); - - // rx broadcast from KAMIL on channel 2 (packet id 1) - bridge.sockets[0].write( - textPacketFrame(KAMIL_NUM, BROADCAST_ADDR, '#ping', 2), - ); - - await waitFor(() => svc.messages().length === 2); + // broadcast original → broadcast reply, on the same channel expect(svc.messages()[0]).toMatchObject({ dir: 'tx', - to: BROADCAST_ADDR, // broadcast original → broadcast reply + to: BROADCAST_ADDR, text: '#pong!', channel: 2, }); - await waitFor(() => outboundPackets(out).length >= 1); - const pkt = outboundPackets(out)[0]!; - expect(pkt.to).toBe(BROADCAST_ADDR); - expect(pkt.channel).toBe(2); - const data = - pkt.payloadVariant?.case === 'decoded' - ? pkt.payloadVariant.value - : undefined; - expect(data?.replyId).toBe(1); // threaded quote of the ping packet (id 1) + expect(client.sends).toHaveLength(1); + expect(client.sends[0]).toMatchObject({ + text: '#pong!', + toNodeId: undefined, + channel: 2, + replyId: 7, // threaded quote of the ping packet (id 7) + }); } finally { svc.onModuleDestroy(); - bridge.close(); } }); it('does not answer #ping arriving on a non-command channel', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new RealMeshService(); + const client = new FakeMeshMonitorClient({ + connected: true, + nodeNum: MY_NUM, + }); + const svc = await makeWiredService(client); try { - svc.attachCommands(new CommandService(svc)); - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); + client.pushInbound({ + packetId: 7, + fromNum: KAMIL_NUM, + fromId: KAMIL_ID, + toNum: MY_NUM, + text: '#ping', + channel: 3, // not the command channel (2) + portnum: 1, + at: T0, + }); + await svc.pollMessages(); + await settle(); - bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 3)); - - await waitFor(() => svc.messages().length === 1); // only the rx record — no reply + // only the rx record — no reply anywhere expect(svc.messages()[0]).toMatchObject({ dir: 'rx', text: '#ping', channel: 3, }); + expect(client.sends).toEqual([]); } finally { svc.onModuleDestroy(); - bridge.close(); } }); }); diff --git a/src/main.ts b/src/main.ts index 0195857..8706d3c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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(AppModule); app.enableShutdownHooks(); diff --git a/src/mesh/fake-bridge.ts b/src/mesh/fake-bridge.ts deleted file mode 100644 index dec8899..0000000 --- a/src/mesh/fake-bridge.ts +++ /dev/null @@ -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, - timeoutMs = 5000, -): Promise { - 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 = { - [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): 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 }, - }; -} diff --git a/src/mesh/mcp.controller.spec.ts b/src/mesh/mcp.controller.spec.ts index a64b16d..ba0d247 100644 --- a/src/mesh/mcp.controller.spec.ts +++ b/src/mesh/mcp.controller.spec.ts @@ -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>): 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 & { 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(); } diff --git a/src/mesh/mesh.controller.ts b/src/mesh/mesh.controller.ts index efc0194..a1b7d18 100644 --- a/src/mesh/mesh.controller.ts +++ b/src/mesh/mesh.controller.ts @@ -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 { diff --git a/src/mesh/mesh.module.ts b/src/mesh/mesh.module.ts index 433db53..534a8b6 100644 --- a/src/mesh/mesh.module.ts +++ b/src/mesh/mesh.module.ts @@ -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], diff --git a/src/mesh/mesh.service.spec.ts b/src/mesh/mesh.service.spec.ts index 2b1735d..889f4b3 100644 --- a/src/mesh/mesh.service.spec.ts +++ b/src/mesh/mesh.service.spec.ts @@ -1,156 +1,331 @@ -import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; import * as P from '@meshtastic/protobufs'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; -import * as net from 'node:net'; -import { DatabaseSync } from 'node:sqlite'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { BROADCAST_ADDR, extractFrames, frame } from './codec.js'; import { - KAMIL_NUM, - configSectionFrame, - kamilNodeInfo, - metadataFrame, - moduleConfigSectionFrame, - nodeInfoFrame, - outboundPackets, - startBridge, - telemetryPacketFrame, - textPacketFrame, - waitFor, -} from './fake-bridge.js'; + BadRequestException, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { DatabaseSync } from 'node:sqlite'; +import { describe, expect, it, vi } from 'vitest'; +import { BROADCAST_ADDR } from './codec.js'; +import { FakeMeshMonitorClient } from './meshmonitor/fake-mesh-monitor-client.js'; +import { + MeshMonitorError, + nodeIdToNum, + nodeNumToId, + type MeshMonitorClient, + type MMMessage, + type MMNode, + type MMTelemetry, +} from './meshmonitor/mesh-monitor-client.js'; import { OpencodeService } from '../opencode/opencode.service.js'; import { initMessagesTable, MeshService } from './mesh.service.js'; -// live position update: POSITION_APP packet with a serialized Position in Data.payload -function positionPacketFrame( - from: number, - pos: { latitudeI?: number; longitudeI?: number }, -): Uint8Array { - const data = create(P.Mesh.DataSchema, { - portnum: P.Portnums.PortNum.POSITION_APP, - payload: toBinary( - P.Mesh.PositionSchema, - create(P.Mesh.PositionSchema, pos), - ), - }); - 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 }, - }), - ), - ); +// --------------------------------------------------------------------------- +// Fixtures & drivers +// --------------------------------------------------------------------------- + +/** 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 against Date.now(). */ +const T0 = 1_760_000_000_000; + +function kamilNode(over?: Partial): MMNode { + return { + num: KAMIL_NUM, + longName: 'Kamil SP8MK/P', + shortName: 'KK', + hwModel: P.Mesh.HardwareModel.TBEAM, + batteryLevel: 87, + lastHeard: 1758000000, + position: { lat: 52.1049637, lon: 20.9833152, alt: 123 }, + ...over, + }; } -// live node-info update: NODEINFO_APP packet with a serialized NodeInfo in Data.payload -function nodeInfoPacketFrame( - from: number, - fields: { - user?: { id?: string; longName?: string }; - deviceMetrics?: { batteryLevel?: number }; - }, -): Uint8Array { - const data = create(P.Mesh.DataSchema, { - portnum: P.Portnums.PortNum.NODEINFO_APP, - payload: toBinary( - P.Mesh.NodeInfoSchema, - create(P.Mesh.NodeInfoSchema, { num: from, ...fields }), - ), - }); - 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 }, - }), - ), - ); +/** An inbound (mesh -> bot) text message as the gateway reports it (channel 3, addressed to us). */ +function inbound(over: Partial & { text: string }): MMMessage { + return { + packetId: 1, + fromNum: KAMIL_NUM, + toNum: MY_NUM, + channel: 3, + portnum: 1, + at: T0, + ...over, + }; } -// ACK/NACK for our sends: ROUTING_APP packet with Data.requestId = original id and a serialized Routing in the payload -function routingFrame( - requestId: number, - errorReason: P.Mesh.Routing_Error, -): Uint8Array { - const data = create(P.Mesh.DataSchema, { - portnum: P.Portnums.PortNum.ROUTING_APP, - requestId, - payload: toBinary( - P.Mesh.RoutingSchema, - create(P.Mesh.RoutingSchema, { - variant: { case: 'errorReason', value: errorReason }, - }), - ), +function makeClient(): FakeMeshMonitorClient { + const client = new FakeMeshMonitorClient({ localNum: MY_NUM }); + client.setStatus({ + connected: true, + myNodeNum: MY_NUM, + longName: 'SP8MK Bot', + nodeResponsive: true, }); - const pkt = create(P.Mesh.MeshPacketSchema, { - from: 1, - id: 2, - payloadVariant: { case: 'decoded', value: data }, - }); - return frame( - toBinary( - P.Mesh.FromRadioSchema, - create(P.Mesh.FromRadioSchema, { - payloadVariant: { case: 'packet', value: pkt }, - }), - ), - ); + return client; } -function channelFrame( - index: number, - settings?: { name?: string; uplinkEnabled?: boolean }, -): Uint8Array { - const ch = create(P.Channel.ChannelSchema, { - index, - ...(settings - ? { - // psk must never leak into channels() output - settings: { - name: settings.name ?? '', - psk: new Uint8Array(16).fill(7), - uplinkEnabled: settings.uplinkEnabled ?? false, - }, - } - : {}), - }); - const fr = create(P.Mesh.FromRadioSchema, { - payloadVariant: { case: 'channel', value: ch }, - }); - return frame(toBinary(P.Mesh.FromRadioSchema, fr)); +/** Construct the service and run the initial sync (the status poll drives the one-shot baseline). */ +async function makeSyncedService( + client: FakeMeshMonitorClient, + db?: DatabaseSync, + opencode?: OpencodeService, +): Promise { + const svc = new MeshService(db, opencode, client); + svc.onModuleInit(); + await svc.pollStatus(); + return svc; } -describe('MeshService channel pipeline', () => { - it('collects channels from FromRadio frames and redacts keys', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); +/** An opencode notifier mock that records calls. */ +function mockOpencode() { + const notify = vi.fn(async () => {}); + return { notify, svc: { notify } as unknown as OpencodeService }; +} + +/** Wraps a client so one method always throws (models a failing gateway endpoint). */ +function withFailingMethod( + inner: FakeMeshMonitorClient, + method: keyof MeshMonitorClient, + error: Error, +): MeshMonitorClient { + return new Proxy(inner, { + get(target, prop) { + if (prop === method) + return async () => { + throw error; + }; + const v = (target as unknown as Record)[prop]; + return typeof v === 'function' + ? (v as (...a: unknown[]) => unknown).bind(target) + : v; + }, + }); +} + +/** + * Mimics the gateway's queued-send behaviour: the POST response carries no + * packet id yet (`packetId: 0`) and the message later appears in the window + * with a real id + requestId (models a send the gateway buffered/auto-split). + */ +function withQueuedSend(inner: FakeMeshMonitorClient): MeshMonitorClient { + return new Proxy(inner, { + get(target, prop) { + if (prop === 'send') + return async (msg: { + text: string; + toNodeId?: string; + channel?: number; + replyId?: number; + }) => { + const toId = msg.toNodeId ?? nodeNumToId(BROADCAST_ADDR); + inner.sends.push({ + text: msg.text, + toNodeId: msg.toNodeId, + channel: msg.channel, + replyId: msg.replyId, + packetId: 0, + deliveryState: 'pending', + messageId: 'queued', + }); + inner.pushInbound({ + packetId: 999, + fromNum: MY_NUM, + toId, + toNum: msg.toNodeId ? nodeIdToNum(msg.toNodeId) : BROADCAST_ADDR, + text: msg.text, + channel: msg.channel ?? -1, + portnum: 1, + requestId: 999, + replyId: msg.replyId, + at: T0 + 1, + deliveryState: 'pending', + }); + return { packetId: 0, deliveryState: 'pending' }; + }; + const v = (target as unknown as Record)[prop]; + return typeof v === 'function' + ? (v as (...a: unknown[]) => unknown).bind(target) + : v; + }, + }); +} + +async function waitFor( + cond: () => boolean | Promise, + timeoutMs = 5000, +): Promise { + 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)); + } +} + +// --------------------------------------------------------------------------- +// Status & sync +// --------------------------------------------------------------------------- + +describe('MeshService status', () => { + it('reports the gateway host/port parsed from MESHMONITOR_API_BASE (default meshmonitor.kkhost.pl:443)', () => { + const svc = new MeshService(undefined, undefined, makeClient()); + expect(svc.status()).toEqual({ + connected: false, + myNodeNum: null, + host: 'meshmonitor.kkhost.pl', + port: 443, + }); + + process.env.MESHMONITOR_API_BASE = 'http://10.0.0.5:9898/api/v1'; + const svc2 = new MeshService(undefined, undefined, makeClient()); + expect(svc2.status()).toMatchObject({ + host: '10.0.0.5', + port: 9898, + }); + delete process.env.MESHMONITOR_API_BASE; + }); + + it('initial sync: status success populates connected + myNodeNum and baselines the world', async () => { + const client = makeClient(); + client.setNodes([kamilNode()]); + client.setChannels([ + { index: 0, name: 'LongFast', uplinkEnabled: true }, + { index: 3, name: 'test' }, + ]); + client.setTelemetry([ + { fromNum: KAMIL_NUM, at: T0, kind: 'batteryLevel', data: { value: 87 } }, + ]); + // stale history sitting in the gateway window at sync time + client.pushInbound(inbound({ text: 'stale, must not be reprocessed' })); + + const svc = await makeSyncedService(client); try { - expect(svc.channels()).toEqual([]); + expect(svc.status()).toEqual({ + connected: true, + myNodeNum: MY_NUM, + host: 'meshmonitor.kkhost.pl', + port: 443, + }); + expect((await svc.nodes()).length).toBe(1); + expect(svc.channels()).toHaveLength(2); + expect(svc.telemetry()).toHaveLength(1); + // the baseline consumed the stale window without recording anything + expect(svc.messages()).toHaveLength(0); - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, + // a NEW message arriving after the baseline is captured + client.pushInbound(inbound({ text: 'fresh', packetId: 5 })); + await svc.pollMessages(); + expect(svc.messages()).toHaveLength(1); + expect(svc.messages()[0]).toMatchObject({ text: 'fresh', id: 5 }); + } finally { + svc.onModuleDestroy(); + } + }); + + it('status failure reports disconnected; the next success reconnects and syncs', async () => { + const client = makeClient(); + client.setNodes([kamilNode()]); + // the gateway is unreachable once, then comes back + let failsLeft = 1; + const flaky: MeshMonitorClient = new Proxy(client, { + get(t, p) { + if (p === 'getStatus') + return async () => { + if (failsLeft-- > 0) + throw new MeshMonitorError('gateway down', 503); + return (t as FakeMeshMonitorClient).getStatus(); + }; + const v = (t as unknown as Record)[p]; + return typeof v === 'function' + ? (v as (...a: unknown[]) => unknown).bind(t) + : v; + }, + }); + + const svc = new MeshService(undefined, undefined, flaky); + svc.onModuleInit(); + try { + await svc.pollStatus(); + expect(svc.status().connected).toBe(false); + expect(svc.status().myNodeNum).toBeNull(); // no identity from a failed poll + + // the gateway is back — the next tick reconnects and runs the one-shot sync + await svc.pollStatus(); + expect(svc.status()).toMatchObject({ + connected: true, + myNodeNum: MY_NUM, + }); + expect((await svc.nodes()).length).toBe(1); + } finally { + svc.onModuleDestroy(); + } + }); + + it('the steady-state poll loops start after the initial sync', async () => { + const svc = await makeSyncedService(makeClient()); + try { + const s = svc as unknown as Record; + expect(s['messageTimer']).toBeTruthy(); + expect(s['nodeTimer']).toBeTruthy(); + expect(s['channelTimer']).toBeTruthy(); + expect(s['telemetryTimer']).toBeTruthy(); + } finally { + svc.onModuleDestroy(); + } + }); + + it('null client: stays disconnected, sendText throws 503', async () => { + const svc = new MeshService(undefined, undefined, null); + svc.onModuleInit(); + try { + expect(svc.status().connected).toBe(false); + expect(svc.status().myNodeNum).toBeNull(); + await expect(svc.sendText(BROADCAST_ADDR, 'x')).rejects.toBeInstanceOf( + ServiceUnavailableException, ); - - bridge.sockets[0].write( - channelFrame(2, { name: 'LongFast', uplinkEnabled: true }), + await expect(svc.sendText(BROADCAST_ADDR, 'x')).rejects.toThrow( + 'bridge not connected', ); - bridge.sockets[0].write(channelFrame(0)); // channel with no settings at all + } finally { + svc.onModuleDestroy(); + } + }); - await waitFor(() => svc.channels().length === 2); + it('gateway reachable but the device is down: connected=false, sendText throws 503', async () => { + const client = makeClient(); + client.setStatus({ connected: false, myNodeNum: MY_NUM }); + const svc = await makeSyncedService(client); + try { + expect(svc.status()).toMatchObject({ + connected: false, + myNodeNum: MY_NUM, + }); + await expect(svc.sendText(BROADCAST_ADDR, 'x')).rejects.toThrow( + 'bridge not connected', + ); + } finally { + svc.onModuleDestroy(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Channels +// --------------------------------------------------------------------------- + +describe('MeshService channels', () => { + it('maps the gateway channels, sorts by index, and never exposes the psk', async () => { + const client = makeClient(); + client.setChannels([ + { index: 2, name: 'LongFast', uplinkEnabled: true, psk: 'REDACTED' }, + { index: 0, name: '' }, // no settings at all + ]); + const svc = await makeSyncedService(client); + try { expect(svc.channels()).toEqual([ { index: 0, name: '', uplinkEnabled: false, downlinkEnabled: false }, { @@ -160,413 +335,66 @@ describe('MeshService channel pipeline', () => { downlinkEnabled: false, }, ]); + // the raw key never reaches the REST shape + expect(JSON.stringify(svc.channels())).not.toContain('REDACTED'); } finally { svc.onModuleDestroy(); - bridge.close(); } }); - it('resets channels on reconnect (no stale data)', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - process.env.MESH_RECONNECT_MS = '50'; - const svc = new MeshService(); + it('keeps the channel table across a status failure (no reset; the 5-min loop refreshes it)', async () => { + const client = makeClient(); + client.setChannels([{ index: 1, name: 'Stale' }]); + const svc = await makeSyncedService(client); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - bridge.sockets[0].write(channelFrame(1, { name: 'Stale' })); - await waitFor(() => svc.channels().length === 1); - - // drop the connection; bot reconnects and must start with an empty table - bridge.sockets[0].destroy(); - await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected); - await waitFor(() => svc.channels().length === 0); - } finally { - delete process.env.MESH_RECONNECT_MS; - svc.onModuleDestroy(); - bridge.close(); - } - }); -}); - -describe('MeshService connect', () => { - it('sends want_config_id on connect and applies the initial sync', async () => { - const out: Uint8Array[] = []; - const bridge = await startBridge((s) => s.on('data', (c) => out.push(c))); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); - try { - svc.onModuleInit(); - await waitFor(() => svc.status().connected && out.length > 0); - - 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); - expect(packets.length).toBeGreaterThan(0); - const tr = fromBinary(P.Mesh.ToRadioSchema, packets[0]); - expect(tr.payloadVariant?.case).toBe('wantConfigId'); - const nonce = - tr.payloadVariant && tr.payloadVariant.case === 'wantConfigId' - ? tr.payloadVariant.value - : -1; - expect(nonce).toBeGreaterThan(0); - - // feed back the initial sync: myInfo, then configCompleteId echoing the nonce - bridge.sockets[0].write( - frame( - toBinary( - P.Mesh.FromRadioSchema, - create(P.Mesh.FromRadioSchema, { - payloadVariant: { case: 'myInfo', value: { myNodeNum: 1234 } }, - }), - ), - ), - ); - bridge.sockets[0].write( - frame( - toBinary( - P.Mesh.FromRadioSchema, - create(P.Mesh.FromRadioSchema, { - payloadVariant: { case: 'configCompleteId', value: nonce }, - }), - ), - ), - ); - - await waitFor(() => svc.status().myNodeNum === 1234); - } finally { - svc.onModuleDestroy(); - bridge.close(); - } - }); -}); - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -describe('MeshService idle watchdog', () => { - it('forces a reconnect when an established connection goes silent past MESH_IDLE_TIMEOUT_MS', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - process.env.MESH_RECONNECT_MS = '50'; // fast retry so the re-establishment is observable - process.env.MESH_IDLE_TIMEOUT_MS = '100'; // override — the default (60000) would never fire in a test - const svc = new MeshService(); - try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - // zombie case: connected but no inbound traffic at all — wait past the timeout, then drive a watchdog tick - await sleep(250); - svc.checkIdle(); - - // socket torn down → existing connect/retry loop re-establishes with a fresh session - await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected); - } finally { - delete process.env.MESH_RECONNECT_MS; - delete process.env.MESH_IDLE_TIMEOUT_MS; - svc.onModuleDestroy(); - bridge.close(); - } - }); - - it('raw non-frame bytes do NOT keep the watchdog alive — a garbage-only stream forces a reconnect', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - process.env.MESH_RECONNECT_MS = '50'; // fast retry so the re-establishment is observable - process.env.MESH_IDLE_TIMEOUT_MS = '100'; // override — the default (60000) would never fire in a test - const svc = new MeshService(); - try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - // today's incident: the peer keeps dribbling small non-frame bytes (keepalive noise / desynced stream) — raw chunks must not reset the idle clock - const garbage = Buffer.from('zz-no-frames-here'); // pure ASCII — never forms a 0x94 0xc3 frame header - for (let i = 0; i < 6; i++) { - await sleep(50); // ~300 ms wall time, well past the timeout - bridge.sockets[0].write(garbage); - } - - svc.checkIdle(); - await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected); - } finally { - delete process.env.MESH_RECONNECT_MS; - delete process.env.MESH_IDLE_TIMEOUT_MS; - svc.onModuleDestroy(); - bridge.close(); - } - }); - - it('forces a reconnect when the undecoded frame buffer exceeds MESH_MAX_FRAME_BUFFER', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - process.env.MESH_RECONNECT_MS = '50'; // fast retry so the re-establishment is observable - process.env.MESH_MAX_FRAME_BUFFER = '100'; // tiny cap — Node delivers socket data in ~64 KB events, far below the 256_000 default - const svc = new MeshService(); - try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - // non-frame bytes (0x42 is not part of any frame header) — a desynced stream that never forms frames; the first ~64 KB data event alone exceeds the cap - bridge.sockets[0].write(Buffer.alloc(300_000, 0x42)); - - // overflow forced a reconnect — fresh session - await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected); - - // the new socket starts with a clean buffer and decodes valid frames normally (the myInfo frame is well under the cap) - const fr = create(P.Mesh.FromRadioSchema, { - payloadVariant: { case: 'myInfo', value: { myNodeNum: 777 } }, - }); - bridge.sockets[1].write(frame(toBinary(P.Mesh.FromRadioSchema, fr))); - await waitFor(() => svc.status().myNodeNum === 777); - } finally { - delete process.env.MESH_RECONNECT_MS; - delete process.env.MESH_MAX_FRAME_BUFFER; - svc.onModuleDestroy(); - bridge.close(); - } - }); - - it('inbound traffic keeps resetting the idle clock — no false trigger while packets arrive', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - process.env.MESH_IDLE_TIMEOUT_MS = '100'; - const svc = new MeshService(); - try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - // steady drip of inbound frames for well over the timeout in wall time — each chunk resets lastPacketAt - for (let i = 0; i < 5; i++) { - await sleep(60); - bridge.sockets[0].write(nodeInfoFrame(42, kamilNodeInfo())); - } - - svc.checkIdle(); // wall time is past the timeout, but traffic kept resetting it - await sleep(100); // enough for a destroy to surface as 'close' (connected would flip false) + expect(svc.channels()).toHaveLength(1); + client.setStatus({ connected: false, myNodeNum: MY_NUM }); + await svc.pollStatus(); + expect(svc.status().connected).toBe(false); + expect(svc.channels()).toHaveLength(1); // not cleared while the gateway is down + client.setStatus({ connected: true, myNodeNum: MY_NUM }); + await svc.pollStatus(); expect(svc.status().connected).toBe(true); - expect(bridge.sockets.length).toBe(1); } finally { - delete process.env.MESH_IDLE_TIMEOUT_MS; svc.onModuleDestroy(); - bridge.close(); } }); - it('does not trigger under the default timeout (MESH_IDLE_TIMEOUT_MS unset)', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - delete process.env.MESH_IDLE_TIMEOUT_MS; // default 60000 — a test can't wait that long, so silence must NOT fire - const svc = new MeshService(); + it('a failing channels poll does not break the initial sync (no crash, previous world intact)', async () => { + const client = makeClient(); + client.setNodes([kamilNode()]); + const flaky = withFailingMethod( + client, + 'getChannels', + new MeshMonitorError('boom', 500), + ); + const svc = new MeshService(undefined, undefined, flaky); + svc.onModuleInit(); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - await sleep(250); // silent, but far under the default budget - svc.checkIdle(); - + await svc.pollStatus(); + // the channels poll failed, but the sync completed: connected, nodes and identity are intact expect(svc.status().connected).toBe(true); - expect(bridge.sockets.length).toBe(1); + expect(svc.channels()).toEqual([]); // nothing captured, nothing corrupt + expect((await svc.nodes()).length).toBe(1); } finally { - delete process.env.MESH_IDLE_TIMEOUT_MS; svc.onModuleDestroy(); - bridge.close(); - } - }); - - it('enables TCP keepalive on the bridge socket', async () => { - const setKeepAlive = vi.spyOn(net.Socket.prototype, 'setKeepAlive'); - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); - try { - svc.onModuleInit(); - await waitFor(() => svc.status().connected); - expect(setKeepAlive).toHaveBeenCalledWith(true, 30_000); - } finally { - setKeepAlive.mockRestore(); - svc.onModuleDestroy(); - bridge.close(); } }); }); -describe('MeshService periodic autoreconnect', () => { - it('unconditionally reconnects when MESH_RECONNECT_INTERVAL_MS elapses', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - process.env.MESH_RECONNECT_MS = '50'; // fast retry so the re-establishment is observable - process.env.MESH_RECONNECT_INTERVAL_MS = '100'; // override — the default (300000) would never elapse in a test - const svc = new MeshService(); +// --------------------------------------------------------------------------- +// Nodes +// --------------------------------------------------------------------------- + +describe('MeshService nodes', () => { + it('summarizes the gateway node list (collected on the initial sync)', async () => { + const client = makeClient(); + client.setNodes([ + kamilNode(), + { num: 987654321 }, // bare entry: no user/position/battery yet + ]); + const svc = await makeSyncedService(client); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - // interval fires → destroy → existing close/retry loop re-establishes with a fresh session - await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected); - } finally { - delete process.env.MESH_RECONNECT_MS; - delete process.env.MESH_RECONNECT_INTERVAL_MS; - svc.onModuleDestroy(); - bridge.close(); - } - }); -}); - -describe('MeshService send', () => { - it('sends on the test channel (index 3) by default, never primary', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - delete process.env.MESH_SEND_CHANNEL; // default path: "test" (index 3) - const svc = new MeshService(); - try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - const out: Uint8Array[] = []; - bridge.sockets[0].on('data', (c) => out.push(c)); - const id = await svc.sendText(42, 'hello test'); - expect(id).toBeGreaterThan(0); - - const readOutbound = (): P.Mesh.MeshPacket | undefined => { - 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 now starts with the want_config_id frame — find the text packet - for (const p of packets) { - const tr = fromBinary(P.Mesh.ToRadioSchema, p); - if (tr.payloadVariant?.case === 'packet') - return tr.payloadVariant.value; - } - return undefined; - }; - - await waitFor(() => readOutbound() !== undefined); - const pkt = readOutbound()!; - expect(pkt.channel).toBe(3); // "test" — never the primary channel (0) - expect(pkt.to).toBe(42); - const decoded = - pkt.payloadVariant?.case === 'decoded' - ? pkt.payloadVariant.value - : undefined; - expect( - new TextDecoder().decode(decoded?.payload ?? new Uint8Array()), - ).toBe('hello test'); - } finally { - delete process.env.MESH_SEND_CHANNEL; - svc.onModuleDestroy(); - bridge.close(); - } - }); - - it('sets Data.reply_id when replyId is given and leaves it unset otherwise', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); - try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - const out: Uint8Array[] = []; - bridge.sockets[0].on('data', (c) => out.push(c)); - await svc.sendText(42, 'reply send', 42); // replyId = 42 - await svc.sendText(43, 'plain send'); - - const readOutbound = (): P.Mesh.MeshPacket[] => { - const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0)); - let off = 0; - for (const c of out) { - merged.set(c, off); - off += c.length; - } - const { packets } = extractFrames(merged); - // outbound starts with the want_config_id frame — collect only text packets, in order - return packets.flatMap((p) => { - const tr = fromBinary(P.Mesh.ToRadioSchema, p); - if (tr.payloadVariant?.case === 'packet') - return [tr.payloadVariant.value]; - return []; - }); - }; - - await waitFor(() => readOutbound().length === 2); - const [replyPkt, plainPkt] = readOutbound(); - const replyData = - replyPkt.payloadVariant?.case === 'decoded' - ? replyPkt.payloadVariant.value - : undefined; - expect( - new TextDecoder().decode(replyData?.payload ?? new Uint8Array()), - ).toBe('reply send'); - expect(replyData?.replyId).toBe(42); // threaded reply to packet 42 - - const plainData = - plainPkt.payloadVariant?.case === 'decoded' - ? plainPkt.payloadVariant.value - : undefined; - expect( - new TextDecoder().decode(plainData?.payload ?? new Uint8Array()), - ).toBe('plain send'); - expect(plainData?.replyId).toBe(0); // omitted → proto3 default, not a reply - } finally { - svc.onModuleDestroy(); - bridge.close(); - } - }); -}); - -describe('MeshService node database', () => { - it('collects NodeInfo during sync and summarizes nodes', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); - try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); - bridge.sockets[0].write(nodeInfoFrame(987654321)); // bare entry: no user/position/metrics yet - - await waitFor(async () => (await svc.nodes()).length === 2); expect(await svc.nodes()).toEqual([ { num: KAMIL_NUM, @@ -575,1357 +403,1330 @@ describe('MeshService node database', () => { hwModel: 'TBEAM', batteryLevel: 87, lastHeard: 1758000000, - position: { lat: 521049637 * 1e-7, lon: 209833152 * 1e-7, alt: 123 }, + position: { lat: 52.1049637, lon: 20.9833152, alt: 123 }, }, { num: 987654321, lastHeard: null }, // names/hwModel/battery/position omitted when absent ]); } finally { svc.onModuleDestroy(); - bridge.close(); } }); - it('creates a stub entry for POSITION_APP packets from unknown nodes', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); + it('updates the node database on the nodes poll (live battery/position changes)', async () => { + const client = makeClient(); + client.setNodes([kamilNode({ batteryLevel: 87 })]); + const svc = await makeSyncedService(client); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - bridge.sockets[0].write( - positionPacketFrame(555, { - latitudeI: 10000000, - longitudeI: -20000000, + expect((await svc.nodes())[0]?.batteryLevel).toBe(87); + client.setNodes([ + kamilNode({ + batteryLevel: 5, + lastHeard: 1758100000, + position: { lat: 1.5, lon: -2.5 }, }), - ); - - await waitFor(async () => (await svc.nodes()).length === 1); - expect(await svc.nodes()).toEqual([ - { num: 555, lastHeard: null, position: { lat: 1, lon: -2 } }, // no alt sent -> omitted ]); + await svc.pollNodes(); + expect((await svc.nodes())[0]).toMatchObject({ + num: KAMIL_NUM, + batteryLevel: 5, + lastHeard: 1758100000, + position: { lat: 1.5, lon: -2.5 }, + }); + // alt omitted when absent + expect((await svc.nodes())[0]?.position).not.toHaveProperty('alt'); } finally { svc.onModuleDestroy(); - bridge.close(); } }); - it('updates batteryLevel from live NODEINFO_APP packets (and stubs unknown nodes)', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); - try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - // sync entry with a stale battery, then a live NODEINFO_APP update - bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); - await waitFor(async () => (await svc.nodes()).length === 1); - expect((await svc.nodes())[0]?.batteryLevel).toBe(87); - - bridge.sockets[0].write( - nodeInfoPacketFrame(KAMIL_NUM, { deviceMetrics: { batteryLevel: 9 } }), - ); - - await waitFor(async () => (await svc.nodes())[0]?.batteryLevel === 9); - // other fields of the sync entry survive the live update - expect(await svc.node(KAMIL_NUM)).toMatchObject({ + it('omits UNSET hwModel, zero snr and false viaMqtt (proto3 default conventions)', async () => { + const client = makeClient(); + client.setNodes([ + { + num: KAMIL_NUM, longName: 'Kamil SP8MK/P', shortName: 'KK', - batteryLevel: 9, + hwModel: 0, // UNSET + snr: 0, + viaMqtt: false, + }, + { + num: 555, + longName: 'Snr', + snr: 7.5, + viaMqtt: true, + }, + ]); + const svc = await makeSyncedService(client); + try { + // sorted by num: 555 comes before KAMIL + const [small, kamil] = await svc.nodes(); + expect(kamil).toEqual({ + num: KAMIL_NUM, + longName: 'Kamil SP8MK/P', + shortName: 'KK', + lastHeard: null, + }); // hwModel/snr/viaMqtt omitted + expect(small).toEqual({ + num: 555, + longName: 'Snr', + snr: 7.5, + viaMqtt: true, + lastHeard: null, }); - - // unknown node: a NODEINFO_APP with deviceMetrics creates a stub entry - bridge.sockets[0].write( - nodeInfoPacketFrame(555, { deviceMetrics: { batteryLevel: 3 } }), - ); - - await waitFor(async () => (await svc.nodes()).length === 2); - expect((await svc.node(555))?.batteryLevel).toBe(3); } finally { svc.onModuleDestroy(); - bridge.close(); } }); it('looks up nodes by exact num, numeric string, exact name and unique substring', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); + const client = makeClient(); + client.setNodes([ + kamilNode(), + kamilNode({ + num: 777, + longName: 'SP8MK Home', + shortName: 'HOM', + }), + ]); + const svc = await makeSyncedService(client); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); - bridge.sockets[0].write( - nodeInfoFrame(42, { - user: { id: '!ot', longName: 'Other Node', shortName: 'OT' }, - }), - ); - await waitFor(async () => (await svc.nodes()).length === 2); - - expect((await svc.node(KAMIL_NUM))?.longName).toBe('Kamil SP8MK/P'); // exact num - expect((await svc.node(String(KAMIL_NUM)))?.num).toBe(KAMIL_NUM); // numeric string - expect((await svc.node('kamil sp8mk/p'))?.shortName).toBe('KK'); // case-insensitive exact name + expect((await svc.node(KAMIL_NUM))?.num).toBe(KAMIL_NUM); + expect((await svc.node('123456789'))?.num).toBe(KAMIL_NUM); + expect((await svc.node('Kamil SP8MK/P'))?.num).toBe(KAMIL_NUM); // exact long name + expect((await svc.node('kk'))?.num).toBe(KAMIL_NUM); // exact short name (case-insensitive) expect((await svc.node('kamil'))?.num).toBe(KAMIL_NUM); // unique substring - expect(await svc.node('zzz-nope')).toBeNull(); // no match + expect((await svc.node('777'))?.num).toBe(777); // numeric string wins over names + expect(await svc.node('sp8mk')).toBeNull(); // two substring matches — ambiguous + expect(await svc.node('zzz')).toBeNull(); // nothing + expect(await svc.node(' ')).toBeNull(); // blank } finally { svc.onModuleDestroy(); - bridge.close(); } }); - it('returns null for ambiguous name matches', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); + it('a failing nodes poll does not break the initial sync (no crash, empty database)', async () => { + const client = makeClient(); + const flaky = withFailingMethod( + client, + 'getNodes', + new MeshMonitorError('boom', 500), + ); + const svc = new MeshService(undefined, undefined, flaky); + svc.onModuleInit(); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - // two nodes whose names both contain "kamil" and share the short name "KK" - bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); - bridge.sockets[0].write( - nodeInfoFrame(777, { - user: { id: '!kj', longName: 'Kamil Jr.', shortName: 'KK' }, - }), - ); - await waitFor(async () => (await svc.nodes()).length === 2); - - expect(await svc.node('kamil')).toBeNull(); // substring matches two nodes - expect(await svc.node('kk')).toBeNull(); // exact name matches two nodes + await svc.pollStatus(); + // the nodes poll failed, but the sync completed: connected, channels intact + expect(svc.status().connected).toBe(true); + expect(await svc.nodes()).toEqual([]); // nothing captured, nothing corrupt } finally { svc.onModuleDestroy(); - bridge.close(); - } - }); - - it('resets the node database on reconnect (no stale data)', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - process.env.MESH_RECONNECT_MS = '50'; - const svc = new MeshService(); - try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); - await waitFor(async () => (await svc.nodes()).length === 1); - - // drop the connection; bot reconnects and must start with an empty node DB - bridge.sockets[0].destroy(); - await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected); - await waitFor(async () => (await svc.nodes()).length === 0); - } finally { - delete process.env.MESH_RECONNECT_MS; - svc.onModuleDestroy(); - bridge.close(); } }); }); -describe('MeshService message history', () => { - it('captures inbound text as rx records', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); +// --------------------------------------------------------------------------- +// Messages +// --------------------------------------------------------------------------- + +describe('MeshService messages', () => { + it('captures inbound DM text as an rx record (gateway channel -1 maps to 0)', async () => { + const client = makeClient(); + const svc = await makeSyncedService(client); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 2)); - - await waitFor(() => svc.messages().length === 1); + client.pushInbound(inbound({ text: 'ping', channel: -1, at: T0 + 5 })); + await svc.pollMessages(); expect(svc.messages()).toEqual([ { id: 1, dir: 'rx', from: KAMIL_NUM, - to: 42, // destination persisted on rx records (broadcast addr for broadcasts) + to: MY_NUM, text: 'ping', - channel: 2, - at: expect.any(Number), + channel: 0, // a direct message rides the primary channel in mesh terms + at: T0 + 5, }, ]); } finally { svc.onModuleDestroy(); - bridge.close(); } }); - it('tracks a tx send pending -> delivered on the routing ACK', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); + it('captures channel broadcasts via the toId sentinels (toNum unset)', async () => { + const client = makeClient(); + const svc = await makeSyncedService(client); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, + client.pushInbound( + inbound({ text: 'bcast', toNum: undefined, toId: 'broadcast' }), ); - - const id = await svc.sendText(42, 'hello test'); - expect(svc.messages()[0]).toMatchObject({ - id, - dir: 'tx', - to: 42, - text: 'hello test', - channel: 3, - status: 'pending', - }); - - bridge.sockets[0].write(routingFrame(id, P.Mesh.Routing_Error.NONE)); - - await waitFor(() => svc.messages()[0]?.status === 'delivered'); - expect(svc.messages()[0]).toMatchObject({ - id, - dir: 'tx', - status: 'delivered', - }); + client.pushInbound( + inbound({ + text: 'bcast2', + packetId: 2, + toNum: undefined, + toId: nodeNumToId(BROADCAST_ADDR), + }), + ); + // a DM addressed only by node id + client.pushInbound( + inbound({ + text: 'dm-id', + packetId: 3, + toNum: undefined, + toId: KAMIL_ID, + channel: -1, + }), + ); + await svc.pollMessages(); + // window is newest first; the buffer must come out newest-first in messages() + expect(svc.messages().map((m) => [m.text, m.to])).toEqual([ + ['dm-id', KAMIL_NUM], + ['bcast2', BROADCAST_ADDR], + ['bcast', BROADCAST_ADDR], + ]); } finally { svc.onModuleDestroy(); - bridge.close(); } }); - it('marks a tx failed with the routing error reason on NACK', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); + it('skips non-text portnums', async () => { + const client = makeClient(); + const svc = await makeSyncedService(client); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, + client.pushInbound(inbound({ text: 'pos', portnum: 3 })); + await svc.pollMessages(); + expect(svc.messages()).toHaveLength(0); + } finally { + svc.onModuleDestroy(); + } + }); + + it('dedupes by packet id across polls (seen set)', async () => { + const client = makeClient(); + const svc = await makeSyncedService(client); + try { + client.pushInbound(inbound({ text: 'once' })); + await svc.pollMessages(); + client.pushInbound(inbound({ text: 'once' })); // same packet id again in the window + await svc.pollMessages(); + expect(svc.messages()).toHaveLength(1); + } finally { + svc.onModuleDestroy(); + } + }); + + it('does not reprocess a packet id already persisted in SQLite (restart backstop)', async () => { + process.env.MESH_POLL_MS = '60000'; + const db = new DatabaseSync(':memory:'); + initMessagesTable(db); + // 101 rows from a previous run: only the newest 100 seed the memory buffer, + // so the oldest (id 500) is reachable only through the SQLite backstop + const ins = db.prepare( + 'INSERT INTO messages(id, dir, to_node, from_node, text, channel, at, status, ack_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + ); + for (let i = 0; i < 101; i++) + ins.run( + 500 + i, + 'rx', + null, + KAMIL_NUM, + i === 0 ? 'old' : `row-${i}`, + 3, + T0 + i * 1000, + null, + null, ); - const id = await svc.sendText(42, 'will fail'); - expect(svc.messages()[0]?.status).toBe('pending'); + const client = makeClient(); + const svc = await makeSyncedService(client, db); + try { + client.pushInbound(inbound({ packetId: 500, text: 'dup' })); + await svc.pollMessages(); + // the incoming copy is dropped — id 500 is already in SQLite — and the + // DB-only row stays out of the 100-slot buffer + expect(svc.messages()).toHaveLength(100); + expect(svc.messages().some((m) => m.id === 500)).toBe(false); + // and the persisted row keeps its original text (no overwrite) + const row = db + .prepare('SELECT text FROM messages WHERE id = ?') + .get(500) as { text: string }; + expect(row.text).toBe('old'); + } finally { + delete process.env.MESH_POLL_MS; + svc.onModuleDestroy(); + } + }); - bridge.sockets[0].write(routingFrame(id, P.Mesh.Routing_Error.TIMEOUT)); + it('resolves a pending tx on the gateway delivery state (pending -> delivered)', async () => { + const client = makeClient(); + const svc = await makeSyncedService(client); + try { + const id = await svc.sendText(BROADCAST_ADDR, 'ack me'); + expect(svc.messages()[0]).toMatchObject({ id, status: 'pending' }); - await waitFor(() => svc.messages()[0]?.status === 'failed'); + // the outbound entry appears in the window as pending — no state change yet + await svc.pollMessages(); + expect(svc.messages()[0].status).toBe('pending'); + + // the gateway flips it to delivered in place (same packet id) — the next poll resolves it + client.setDeliveryState(id, 'delivered'); + await svc.pollMessages(); + expect(svc.messages()[0]).toMatchObject({ + id, + status: 'delivered', + }); + expect(svc.messages()[0]).not.toHaveProperty('ackError'); + } finally { + svc.onModuleDestroy(); + } + }); + + it('marks a tx failed with the gateway reason', async () => { + const client = makeClient(); + const svc = await makeSyncedService(client); + try { + const id = await svc.sendText(BROADCAST_ADDR, 'doomed'); + client.setDeliveryState(id, 'failed'); + await svc.pollMessages(); expect(svc.messages()[0]).toMatchObject({ id, status: 'failed', - ackError: 'TIMEOUT', // human-readable Routing.Error name + ackError: 'delivery failed (gateway)', }); } finally { svc.onModuleDestroy(); - bridge.close(); } }); - it('caps history at 100 records, dropping the oldest', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); + it('a queued send (gateway packet id 0) gets a generated id and resolves by text+destination', async () => { + const client = makeClient(); + const queued = withQueuedSend(client); + const svc = new MeshService(undefined, undefined, queued); + svc.onModuleInit(); + await svc.pollStatus(); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - for (let i = 0; i < 105; i++) - bridge.sockets[0].write(textPacketFrame(42, KAMIL_NUM, `m-${i}`)); - - await waitFor(() => svc.messages().length === 100, 10000); - expect(svc.messages()[0]?.text).toBe('m-104'); // newest first - expect(svc.messages()[99]?.text).toBe('m-5'); // m-0..m-4 dropped - } finally { - svc.onModuleDestroy(); - bridge.close(); - } - }); -}); - -describe('MeshService respond', () => { - it('replies to an rx DM as a directed send back to the sender on the original channel with replyId = messageId', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); - try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - // rx DM from KAMIL to our node on channel 2 (packet id 1) - bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 2)); - await waitFor(() => svc.messages().length === 1); - - const out: Uint8Array[] = []; - bridge.sockets[0].on('data', (c) => out.push(c)); - const { id } = await svc.respond(1, 'pong'); - + const id = await svc.sendText(KAMIL_NUM, 'queued dm'); + // no id from the gateway yet — the service generated one expect(id).toBeGreaterThan(0); - // DM back to the original sender on the original channel expect(svc.messages()[0]).toMatchObject({ id, dir: 'tx', to: KAMIL_NUM, - text: 'pong', - channel: 2, status: 'pending', }); - 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); // threaded quote of the original packet + // the gateway assigns a real id and the entry lands in the window — still pending + await svc.pollMessages(); + expect(svc.messages()[0].status).toBe('pending'); + + client.setDeliveryState(999, 'delivered'); + await svc.pollMessages(); + expect(svc.messages()[0]).toMatchObject({ + id, + status: 'delivered', + }); } finally { svc.onModuleDestroy(); - bridge.close(); } }); - it('replies to a broadcast original as a broadcast on the original channel with replyId = messageId', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); + it('caps history at 100 records, dropping the oldest', async () => { + const client = makeClient(); + const svc = await makeSyncedService(client); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); + // the poll window is 50 — feed the buffer in batches across polls + for (let batch = 0; batch < 3; batch++) { + for (let i = batch * 40 + 1; i <= batch * 40 + 40; i++) + client.pushInbound( + inbound({ text: `m${i}`, packetId: i, at: T0 + i }), + ); + await svc.pollMessages(); + } + expect(svc.messages()).toHaveLength(100); // newest kept + expect(svc.messages()[0].id).toBe(120); // newest first + expect(svc.messages()[99].id).toBe(21); // 1..20 evicted + } finally { + svc.onModuleDestroy(); + } + }); +}); - // rx broadcast from KAMIL on channel 2 (packet id 1) - bridge.sockets[0].write( - textPacketFrame(KAMIL_NUM, BROADCAST_ADDR, 'hello all', 2), - ); - await waitFor(() => svc.messages().length === 1); - - const out: Uint8Array[] = []; - bridge.sockets[0].on('data', (c) => out.push(c)); - const { id } = await svc.respond(1, 'pong'); +// --------------------------------------------------------------------------- +// Send +// --------------------------------------------------------------------------- +describe('MeshService send', () => { + it('broadcasts on the pinned test channel (3) by default, never primary', async () => { + delete process.env.MESH_SEND_CHANNEL; + const client = makeClient(); + const svc = await makeSyncedService(client); + try { + const id = await svc.sendText(BROADCAST_ADDR, 'hello test'); expect(id).toBeGreaterThan(0); + expect(client.sends).toEqual([ + { + text: 'hello test', + channel: 3, // "test" — never the primary channel (0) + packetId: id, + deliveryState: 'pending', + messageId: `fake-${id}`, + }, + ]); expect(svc.messages()[0]).toMatchObject({ dir: 'tx', - to: BROADCAST_ADDR, // broadcast original → broadcast reply - text: 'pong', + to: BROADCAST_ADDR, + text: 'hello test', + channel: 3, + status: 'pending', + }); + } finally { + delete process.env.MESH_SEND_CHANNEL; + svc.onModuleDestroy(); + } + }); + + it('honours the MESH_SEND_CHANNEL override', async () => { + process.env.MESH_SEND_CHANNEL = '5'; + const client = makeClient(); + const svc = await makeSyncedService(client); + try { + await svc.sendText(BROADCAST_ADDR, 'on five'); + expect(client.sends[0]?.channel).toBe(5); + } finally { + delete process.env.MESH_SEND_CHANNEL; + svc.onModuleDestroy(); + } + }); + + it('direct messages ride toNodeId (no channel) and record channel 0', async () => { + const client = makeClient(); + const svc = await makeSyncedService(client); + try { + const id = await svc.sendText(KAMIL_NUM, 'dm'); + expect(client.sends).toEqual([ + { + text: 'dm', + toNodeId: KAMIL_ID, + packetId: id, + deliveryState: 'pending', + messageId: `fake-${id}`, + }, + ]); + expect(svc.messages()[0]).toMatchObject({ + dir: 'tx', + to: KAMIL_NUM, + channel: 0, // DMs ride the primary channel in mesh terms + status: 'pending', + }); + } finally { + svc.onModuleDestroy(); + } + }); + + it('carries replyId through (threaded quote) and leaves it unset otherwise', async () => { + const client = makeClient(); + const svc = await makeSyncedService(client); + try { + await svc.sendText(KAMIL_NUM, 'reply send', 42); + await svc.sendText(BROADCAST_ADDR, 'plain send'); + expect(client.sends[0]?.replyId).toBe(42); + expect(client.sends[1]).toMatchObject({ replyId: undefined }); + } finally { + svc.onModuleDestroy(); + } + }); + + it('an explicit channel index wins (battery alerts ride "testowy" (2))', async () => { + const client = makeClient(); + const svc = await makeSyncedService(client); + try { + await svc.sendText(BROADCAST_ADDR, 'alert', undefined, 2); + expect(client.sends[0]).toMatchObject({ + text: 'alert', 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); + expect(svc.messages()[0]).toMatchObject({ + channel: 2, + to: BROADCAST_ADDR, + }); } finally { svc.onModuleDestroy(); - bridge.close(); } }); - it('replies to a tx record targeting its destination on its channel with replyId = messageId', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); + it('an immediate delivery state from the gateway skips the pending window', async () => { + const client = makeClient(); + // wrap the fake so send() reports the final state immediately + const fast: MeshMonitorClient = new Proxy(client, { + get(t, p) { + if (p === 'send') + return async (msg: Parameters[0]) => { + const r = await (t as FakeMeshMonitorClient).send(msg); + return { ...r, deliveryState: 'delivered' }; + }; + const v = (t as unknown as Record)[p]; + return typeof v === 'function' + ? (v as (...a: unknown[]) => unknown).bind(t) + : v; + }, + }); + const svc = new MeshService(undefined, undefined, fast); + svc.onModuleInit(); + await svc.pollStatus(); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - const origId = await svc.sendText(42, 'orig'); // tx record: to=42, default channel 3 - const out: Uint8Array[] = []; - bridge.sockets[0].on('data', (c) => out.push(c)); - const { id } = await svc.respond(origId, 'reply'); - - expect(id).toBeGreaterThan(0); + const id = await svc.sendText(BROADCAST_ADDR, 'instant'); expect(svc.messages()[0]).toMatchObject({ - dir: 'tx', - to: 42, // the tx record's own destination - text: 'reply', - channel: 3, + id, + status: 'delivered', }); - - // the orig send may share a chunk with the reply — take the newest packet - await waitFor(() => outboundPackets(out).length >= 1); - const pkts = outboundPackets(out); - const pkt = pkts[pkts.length - 1]!; - expect(pkt.to).toBe(42); - const data = - pkt.payloadVariant?.case === 'decoded' - ? pkt.payloadVariant.value - : undefined; - expect(data?.replyId).toBe(origId); + // the record resolved in flight — it never entered the pending map + const pendingTx = (svc as unknown as Record)[ + 'pendingTx' + ] as Map; + expect(pendingTx.size).toBe(0); } finally { svc.onModuleDestroy(); - bridge.close(); + } + }); + + it('a rejected send (413) becomes a BadRequestException and records a failed tx', async () => { + const client = makeClient(); + const failing = withFailingMethod( + client, + 'send', + new MeshMonitorError('message too long', 413), + ); + const svc = new MeshService(undefined, undefined, failing); + svc.onModuleInit(); + await svc.pollStatus(); + try { + await expect( + svc.sendText(BROADCAST_ADDR, 'x'.repeat(500)), + ).rejects.toBeInstanceOf(BadRequestException); + expect(svc.messages()[0]).toMatchObject({ + dir: 'tx', + to: BROADCAST_ADDR, + text: 'x'.repeat(500), + channel: 3, + status: 'failed', + ackError: 'message too long', + }); + } finally { + svc.onModuleDestroy(); + } + }); + + it('a rejected send (other failure) becomes a ServiceUnavailableException and records a failed tx', async () => { + const client = makeClient(); + const failing = withFailingMethod( + client, + 'send', + new MeshMonitorError('gateway down', 503), + ); + const svc = new MeshService(undefined, undefined, failing); + svc.onModuleInit(); + await svc.pollStatus(); + try { + await expect(svc.sendText(BROADCAST_ADDR, 'nope')).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + expect(svc.messages()[0]).toMatchObject({ + status: 'failed', + ackError: 'gateway down', + }); + } finally { + svc.onModuleDestroy(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Respond +// --------------------------------------------------------------------------- + +describe('MeshService respond', () => { + it('replies to an rx DM as a directed send back to the sender with replyId = messageId', async () => { + const client = makeClient(); + const svc = await makeSyncedService(client); + try { + client.pushInbound(inbound({ text: 'ping', packetId: 7, channel: 3 })); + await svc.pollMessages(); + + const r = await svc.respond(7, 'pong'); + expect(r.id).toBeGreaterThan(0); + expect(client.sends[0]).toMatchObject({ + text: 'pong', + toNodeId: KAMIL_ID, // DM back to the original sender + replyId: 7, // threaded quote of the ping packet + }); + expect(svc.messages()[0]).toMatchObject({ + dir: 'tx', + to: KAMIL_NUM, + text: 'pong', + }); + } finally { + svc.onModuleDestroy(); + } + }); + + it('replies to a broadcast original as a broadcast with replyId = messageId', async () => { + const client = makeClient(); + const svc = await makeSyncedService(client); + try { + client.pushInbound( + inbound({ + text: 'ping all', + packetId: 8, + toNum: undefined, + toId: 'broadcast', + channel: 2, + }), + ); + await svc.pollMessages(); + + await svc.respond(8, 'pong all'); + expect(client.sends[0]).toMatchObject({ + text: 'pong all', + channel: 2, // rides the original channel + replyId: 8, + }); + expect(client.sends[0].toNodeId).toBeUndefined(); + expect(svc.messages()[0]).toMatchObject({ + dir: 'tx', + to: BROADCAST_ADDR, + channel: 2, + }); + } finally { + svc.onModuleDestroy(); + } + }); + + it('replies to a tx record targeting its destination with replyId = messageId', async () => { + const client = makeClient(); + const svc = await makeSyncedService(client); + try { + const txId = await svc.sendText(KAMIL_NUM, 'orig'); + await svc.respond(txId, 'orig reply'); + expect(client.sends[1]).toMatchObject({ + text: 'orig reply', + toNodeId: KAMIL_ID, + replyId: txId, + }); + } finally { + svc.onModuleDestroy(); + } + }); + + it('replies to an injected record without a sender as a broadcast', async () => { + const client = makeClient(); + const svc = await makeSyncedService(client); + try { + const { id } = svc.injectMessage('trigger'); + await svc.respond(id, 'reacted'); + expect(client.sends[0]).toMatchObject({ + text: 'reacted', + replyId: id, + }); + expect(client.sends[0].toNodeId).toBeUndefined(); + expect(svc.messages()[0]).toMatchObject({ + to: BROADCAST_ADDR, + channel: 0, // rides the record's channel + }); + } finally { + svc.onModuleDestroy(); + } + }); + + it('falls back to SQLite when the record is not in the in-memory buffer', async () => { + process.env.MESH_POLL_MS = '60000'; + const db = new DatabaseSync(':memory:'); + initMessagesTable(db); + // 101 rows from a previous run: id 999 (the oldest) stays out of the + // 100-slot buffer — only the newest 100 are seeded on startup + const ins = db.prepare( + 'INSERT INTO messages(id, dir, to_node, from_node, text, channel, at, status, ack_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + ); + for (let i = 0; i < 101; i++) + ins.run( + 999 + i, + 'rx', + null, + i === 0 ? KAMIL_NUM : 42, + i === 0 ? 'old msg' : `row-${i}`, + 2, + T0 + i * 1000, + null, + null, + ); + + const client = makeClient(); + const svc = await makeSyncedService(client, db); + try { + // the record lives only in SQLite — respond must find it there + // and route back to the original sender + const r = await svc.respond(999, 'late reply'); + expect(r.id).toBeGreaterThan(0); + expect(client.sends[0]).toMatchObject({ + text: 'late reply', + toNodeId: KAMIL_ID, + replyId: 999, + }); + } finally { + delete process.env.MESH_POLL_MS; + svc.onModuleDestroy(); } }); it('throws NotFoundException when no message has the given id (memory or SQLite)', async () => { - const svc = new MeshService(); - await expect(svc.respond(999, 'hi')).rejects.toBeInstanceOf( - NotFoundException, - ); - await expect(svc.respond(999, 'hi')).rejects.toThrow( - 'no message with id 999', - ); - - const db = new DatabaseSync(':memory:'); // empty table — the DB lookup path also misses - initMessagesTable(db); - const svc2 = new MeshService(db); - await expect(svc2.respond(999, 'hi')).rejects.toBeInstanceOf( - NotFoundException, - ); - }); - - it('falls back to SQLite when the record is not in the in-memory buffer', async () => { - const db = new DatabaseSync(':memory:'); - initMessagesTable(db); - // 150 rows — onModuleInit seeds only the newest 100 into the buffer (ids 2050..2149) - const ins = db.prepare( - 'INSERT INTO messages(id, dir, to_node, from_node, text, channel, at, status, ack_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', - ); - for (let i = 0; i < 150; i++) - ins.run( - 2000 + i, - 'rx', - 42, - KAMIL_NUM, - `old-${i}`, - 2, - 1_700_000_000_000 + i * 1000, - null, - null, - ); - - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(db); + const client = makeClient(); + const svc = await makeSyncedService(client); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, + await expect(svc.respond(999, 'x')).rejects.toThrow( + `no message with id 999`, ); - - // id 2000 is in SQLite but outside the seeded buffer - const { id } = await svc.respond(2000, 'pong'); - expect(id).toBeGreaterThan(0); - expect(svc.messages()[0]).toMatchObject({ - dir: 'tx', - to: KAMIL_NUM, // DM back to the sender of the old rx record - text: 'pong', - channel: 2, // the record's channel - }); } finally { svc.onModuleDestroy(); - bridge.close(); } }); it('rejects invalid messageId and empty content with BadRequestException', async () => { - const svc = new MeshService(); - for (const bad of [0, BROADCAST_ADDR + 1, 42.5, 'x' as unknown as number]) - await expect(svc.respond(bad, 'hi')).rejects.toBeInstanceOf( + const client = makeClient(); + const svc = await makeSyncedService(client); + try { + await expect(svc.respond(0, 'x')).rejects.toBeInstanceOf( BadRequestException, ); - await expect(svc.respond(1, '')).rejects.toThrow( - 'content must be a non-empty string', - ); - await expect(svc.respond(1, undefined)).rejects.toBeInstanceOf( - BadRequestException, - ); + await expect(svc.respond(-5, 'x')).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect(svc.respond(1, '')).rejects.toBeInstanceOf( + BadRequestException, + ); + } finally { + svc.onModuleDestroy(); + } }); }); +// --------------------------------------------------------------------------- +// Opencode notifier +// --------------------------------------------------------------------------- + describe('MeshService opencode notifier', () => { - // our own node number for the self-echo filter specs (distinct from KAMIL_NUM, an external user) - const MY_NODE = 987654321; - let notifyMock: ReturnType; - - beforeEach(() => { - notifyMock = vi.fn( - async (_id: number, _text: string, _from?: number) => {}, - ); - }); - - // myInfo frame as streamed during the initial sync — sets svc.myNodeNum - function myInfoFrame(num: number): Uint8Array { - return frame( - toBinary( - P.Mesh.FromRadioSchema, - create(P.Mesh.FromRadioSchema, { - payloadVariant: { case: 'myInfo', value: { myNodeNum: num } }, - }), - ), - ); - } - - // MeshService with a mocked OpencodeService wired in (structural cast — only notify() is used) - function makeSvc(): MeshService { - return new MeshService(undefined, { - notify: notifyMock, - } as unknown as OpencodeService); + function envChannels(raw: string | undefined) { + if (raw === undefined) delete process.env.MESH_NOTIFY_CHANNEL; + else process.env.MESH_NOTIFY_CHANNEL = raw; } it('notifies via the opencode service when an rx text lands on the notify channel (default 3)', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - delete process.env.MESH_NOTIFY_CHANNEL; // default 3 - const svc = makeSvc(); + envChannels(undefined); + const { notify, svc: oc } = mockOpencode(); + const svc = await makeSyncedService(makeClient(), undefined, oc); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3)); - - await waitFor(() => notifyMock.mock.calls.length === 1); - expect(notifyMock).toHaveBeenCalledTimes(1); - expect(notifyMock).toHaveBeenCalledWith(1, 'ping', KAMIL_NUM); // packet id threaded through + const client = (svc as unknown as Record)[ + 'client' + ] as FakeMeshMonitorClient; + client.pushInbound(inbound({ text: 'hello', channel: 3 })); + await svc.pollMessages(); + expect(notify).toHaveBeenCalledTimes(1); + expect(notify).toHaveBeenCalledWith(1, 'hello', KAMIL_NUM); } finally { - delete process.env.MESH_NOTIFY_CHANNEL; + envChannels(undefined); svc.onModuleDestroy(); - bridge.close(); } }); it('does not notify for rx texts on other channels', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - delete process.env.MESH_NOTIFY_CHANNEL; // default 3 - const svc = makeSvc(); + envChannels(undefined); + const { notify, svc: oc } = mockOpencode(); + const svc = await makeSyncedService(makeClient(), undefined, oc); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 2)); - - // the message is captured in history — but no notification fires off-channel - await waitFor(() => svc.messages().length === 1); - expect(notifyMock).not.toHaveBeenCalled(); + const client = (svc as unknown as Record)[ + 'client' + ] as FakeMeshMonitorClient; + client.pushInbound(inbound({ text: 'off-channel', channel: 2 })); + await svc.pollMessages(); + expect(notify).not.toHaveBeenCalled(); } finally { - delete process.env.MESH_NOTIFY_CHANNEL; + envChannels(undefined); svc.onModuleDestroy(); - bridge.close(); } }); - it('fires the notifier for an injected message when MESH_NOTIFY_CHANNEL matches its channel (0)', async () => { - process.env.MESH_NOTIFY_CHANNEL = '0'; - const svc = makeSvc(); + it('fires the notifier for an injected message when 0 is in the list', async () => { + envChannels('3,0'); + const { notify, svc: oc } = mockOpencode(); + const svc = await makeSyncedService(makeClient(), undefined, oc); try { - const r = svc.injectMessage('hi', 42); - - expect(notifyMock).toHaveBeenCalledTimes(1); - expect(notifyMock).toHaveBeenCalledWith(r.id, 'hi', 42); // generated record id threaded through + const { id } = svc.injectMessage('trigger'); + expect(notify).toHaveBeenCalledTimes(1); + expect(notify).toHaveBeenCalledWith(id, 'trigger', undefined); // no sender — from passes through as undefined } finally { - delete process.env.MESH_NOTIFY_CHANNEL; + envChannels(undefined); + svc.onModuleDestroy(); } }); - it('passes `from` through as undefined when an injected message has no sender', async () => { - process.env.MESH_NOTIFY_CHANNEL = '0'; - const svc = makeSvc(); + it('does not notify for rx texts from our own node on the notify channel (self-echo)', async () => { + envChannels(undefined); + const { notify, svc: oc } = mockOpencode(); + const svc = await makeSyncedService(makeClient(), undefined, oc); try { - const r = svc.injectMessage('hi'); - - expect(notifyMock).toHaveBeenCalledTimes(1); - expect(notifyMock).toHaveBeenCalledWith(r.id, 'hi', undefined); - } finally { - delete process.env.MESH_NOTIFY_CHANNEL; - } - }); - - it('does not notify for rx texts from our own node on the notify channel', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - delete process.env.MESH_NOTIFY_CHANNEL; // default 3 - const svc = makeSvc(); - try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, + const client = (svc as unknown as Record)[ + 'client' + ] as FakeMeshMonitorClient; + client.pushInbound( + inbound({ text: 'my own voice', fromNum: MY_NUM, toNum: KAMIL_NUM }), ); - - // initial sync: our own node number arrives via myInfo first - bridge.sockets[0].write(myInfoFrame(MY_NODE)); - await waitFor(() => svc.status().myNodeNum === MY_NODE); - - // an rx text from ourselves on the notify channel must not fire the notifier - bridge.sockets[0].write(textPacketFrame(MY_NODE, 42, 'ping', 3)); - - // history capture proves the packet was processed (notifyRx runs right after it) - await waitFor(() => svc.messages().length === 1); - expect(notifyMock).not.toHaveBeenCalled(); + await svc.pollMessages(); + expect(notify).not.toHaveBeenCalled(); } finally { - delete process.env.MESH_NOTIFY_CHANNEL; + envChannels(undefined); svc.onModuleDestroy(); - bridge.close(); } }); it('does not notify for an injected message from our own node', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - process.env.MESH_NOTIFY_CHANNEL = '0'; - const svc = makeSvc(); + envChannels('3,0'); + const { notify, svc: oc } = mockOpencode(); + const svc = await makeSyncedService(makeClient(), undefined, oc); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - bridge.sockets[0].write(myInfoFrame(MY_NODE)); - await waitFor(() => svc.status().myNodeNum === MY_NODE); - - svc.injectMessage('hi', MY_NODE); - - expect(notifyMock).not.toHaveBeenCalled(); + svc.injectMessage('self inject', MY_NUM); + expect(notify).not.toHaveBeenCalled(); } finally { - delete process.env.MESH_NOTIFY_CHANNEL; + envChannels(undefined); svc.onModuleDestroy(); - bridge.close(); } }); - it('notifies for rx texts on any channel in a comma-separated MESH_NOTIFY_CHANNEL list, and not for others', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - process.env.MESH_NOTIFY_CHANNEL = '2,5'; - const svc = makeSvc(); + it('notifies for rx texts on any channel in a comma-separated list, and not for others', async () => { + envChannels('3,5'); + const { notify, svc: oc } = mockOpencode(); + const svc = await makeSyncedService(makeClient(), undefined, oc); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - // channel 5 is in the list — notified - bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'on-list', 5)); - - await waitFor(() => notifyMock.mock.calls.length === 1); - expect(notifyMock).toHaveBeenCalledWith(1, 'on-list', KAMIL_NUM); - - // channel 3 is not in the list — captured in history but no notification - bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'off-list', 3)); - - await waitFor(() => svc.messages().length === 2); - expect(notifyMock).toHaveBeenCalledTimes(1); + const client = (svc as unknown as Record)[ + 'client' + ] as FakeMeshMonitorClient; + client.pushInbound(inbound({ text: 'on three', channel: 3 })); + client.pushInbound(inbound({ text: 'on five', packetId: 2, channel: 5 })); + client.pushInbound(inbound({ text: 'on two', packetId: 3, channel: 2 })); + await svc.pollMessages(); + expect(notify).toHaveBeenCalledTimes(2); + expect(notify).toHaveBeenCalledWith(1, 'on three', KAMIL_NUM); + expect(notify).toHaveBeenCalledWith(2, 'on five', KAMIL_NUM); } finally { - delete process.env.MESH_NOTIFY_CHANNEL; + envChannels(undefined); svc.onModuleDestroy(); - bridge.close(); } }); it('ignores ch0 broadcasts even when 0 is in the list, but notifies for directed DMs', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - process.env.MESH_NOTIFY_CHANNEL = '3,0'; - const svc = makeSvc(); + envChannels('0,3'); + const { notify, svc: oc } = mockOpencode(); + const svc = await makeSyncedService(makeClient(), undefined, oc); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, + const client = (svc as unknown as Record)[ + 'client' + ] as FakeMeshMonitorClient; + // ch0 broadcast — shared-channel noise + client.pushInbound( + inbound({ + text: 'ch0 bcast', + toNum: undefined, + toId: 'broadcast', + channel: 0, + }), ); - - // ch0 broadcast: shared default channel noise — no notification - bridge.sockets[0].write( - textPacketFrame(KAMIL_NUM, BROADCAST_ADDR, 'hello all', 0), + // ch0 directed DM (to us, by node id) — signal + client.pushInbound( + inbound({ + text: 'ch0 dm', + packetId: 2, + toNum: undefined, + toId: nodeNumToId(MY_NUM), + channel: 0, + }), ); - - await waitFor(() => svc.messages().length === 1); - expect(notifyMock).not.toHaveBeenCalled(); - - // ch0 directed DM: both sender and recipient present — notified - bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'dm', 0)); - - await waitFor(() => notifyMock.mock.calls.length === 1); - expect(notifyMock).toHaveBeenCalledWith(1, 'dm', KAMIL_NUM); + // ch3 broadcast — fine on non-ch0 channels + client.pushInbound( + inbound({ + text: 'ch3 bcast', + packetId: 3, + toNum: undefined, + toId: 'broadcast', + channel: 3, + }), + ); + await svc.pollMessages(); + expect(notify).toHaveBeenCalledTimes(2); + expect(notify).toHaveBeenCalledWith(2, 'ch0 dm', KAMIL_NUM); + expect(notify).toHaveBeenCalledWith(3, 'ch3 bcast', KAMIL_NUM); } finally { - delete process.env.MESH_NOTIFY_CHANNEL; + envChannels(undefined); svc.onModuleDestroy(); - bridge.close(); - } - }); - - it('notifies for a ch0 injection when 0 is in the list (multi-list)', () => { - process.env.MESH_NOTIFY_CHANNEL = '3,0'; - const svc = makeSvc(); - try { - const r = svc.injectMessage('hi', 42); - - // injected records are exempt from the ch0 DM-only rule - expect(notifyMock).toHaveBeenCalledTimes(1); - expect(notifyMock).toHaveBeenCalledWith(r.id, 'hi', 42); - } finally { - delete process.env.MESH_NOTIFY_CHANNEL; } }); it('ignores empty and invalid entries in MESH_NOTIFY_CHANNEL', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - process.env.MESH_NOTIFY_CHANNEL = 'abc, ,5,-2,x7'; - const svc = makeSvc(); + envChannels(' , 3, x '); + const { notify, svc: oc } = mockOpencode(); + const svc = await makeSyncedService(makeClient(), undefined, oc); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - // only the valid entry (5) survives parsing — notified - bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 5)); - - await waitFor(() => notifyMock.mock.calls.length === 1); - expect(notifyMock).toHaveBeenCalledWith(1, 'ping', KAMIL_NUM); - - // channel 7 only appears inside the invalid entry "x7" — must not match - bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'nope', 7)); - - await waitFor(() => svc.messages().length === 2); - expect(notifyMock).toHaveBeenCalledTimes(1); + const client = (svc as unknown as Record)[ + 'client' + ] as FakeMeshMonitorClient; + client.pushInbound(inbound({ text: 'on three', channel: 3 })); + client.pushInbound(inbound({ text: 'on one', packetId: 2, channel: 1 })); + await svc.pollMessages(); + expect(notify).toHaveBeenCalledTimes(1); + expect(notify).toHaveBeenCalledWith(1, 'on three', KAMIL_NUM); } finally { - delete process.env.MESH_NOTIFY_CHANNEL; + envChannels(undefined); svc.onModuleDestroy(); - bridge.close(); } }); }); +// --------------------------------------------------------------------------- +// Forwarded ring +// --------------------------------------------------------------------------- + describe('MeshService forwarded ring', () => { - const MY_NODE = 987654321; - let notifyMock: ReturnType; - - beforeEach(() => { - notifyMock = vi.fn( - async (_id: number, _text: string, _from?: number) => {}, - ); - }); - - // myInfo frame as streamed during the initial sync — sets svc.myNodeNum - function myInfoFrame(num: number): Uint8Array { - return frame( - toBinary( - P.Mesh.FromRadioSchema, - create(P.Mesh.FromRadioSchema, { - payloadVariant: { case: 'myInfo', value: { myNodeNum: num } }, - }), - ), - ); + function envChannels(raw: string | undefined) { + if (raw === undefined) delete process.env.MESH_NOTIFY_CHANNEL; + else process.env.MESH_NOTIFY_CHANNEL = raw; } - // MeshService with a mocked OpencodeService wired in (structural cast — only notify() is used) - function makeSvc(): MeshService { - return new MeshService(undefined, { - notify: notifyMock, - } as unknown as OpencodeService); - } - - it('records an entry when an rx text passes the gate and is handed off (channel match)', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - delete process.env.MESH_NOTIFY_CHANNEL; // default 3 - const svc = makeSvc(); + it('records an entry when an rx text passes the gate and is handed off', async () => { + envChannels(undefined); + const { svc: oc } = mockOpencode(); + const svc = await makeSyncedService(makeClient(), undefined, oc); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, + const client = (svc as unknown as Record)[ + 'client' + ] as FakeMeshMonitorClient; + client.pushInbound( + inbound({ text: 'forwarded', channel: 3, at: T0 + 9 }), ); - - bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3)); - - await waitFor(() => notifyMock.mock.calls.length === 1); - expect(svc.forwarded()).toEqual([ - { - id: 1, - from: KAMIL_NUM, - text: 'ping', - channel: 3, - at: expect.any(Number), - }, - ]); + await svc.pollMessages(); + expect(svc.forwarded()).toHaveLength(1); + expect(svc.forwarded()[0]).toMatchObject({ + id: 1, + from: KAMIL_NUM, + text: 'forwarded', + channel: 3, + }); + expect(svc.forwarded()[0].at).toBeGreaterThan(0); } finally { - delete process.env.MESH_NOTIFY_CHANNEL; + envChannels(undefined); svc.onModuleDestroy(); - bridge.close(); } }); it('does not record on channel mismatch', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - delete process.env.MESH_NOTIFY_CHANNEL; // default 3 - const svc = makeSvc(); + envChannels(undefined); + const { svc: oc } = mockOpencode(); + const svc = await makeSyncedService(makeClient(), undefined, oc); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 2)); - - // the message is captured in history — but off-channel it never reaches the ring - await waitFor(() => svc.messages().length === 1); - expect(notifyMock).not.toHaveBeenCalled(); - expect(svc.forwarded()).toEqual([]); + const client = (svc as unknown as Record)[ + 'client' + ] as FakeMeshMonitorClient; + client.pushInbound(inbound({ text: 'off-channel', channel: 2 })); + await svc.pollMessages(); + expect(svc.forwarded()).toHaveLength(0); } finally { - delete process.env.MESH_NOTIFY_CHANNEL; + envChannels(undefined); svc.onModuleDestroy(); - bridge.close(); } }); it('does not record a self-echo (rx from our own node on the notify channel)', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - delete process.env.MESH_NOTIFY_CHANNEL; // default 3 - const svc = makeSvc(); + envChannels(undefined); + const { svc: oc } = mockOpencode(); + const svc = await makeSyncedService(makeClient(), undefined, oc); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, + const client = (svc as unknown as Record)[ + 'client' + ] as FakeMeshMonitorClient; + client.pushInbound( + inbound({ text: 'my own voice', fromNum: MY_NUM, toNum: KAMIL_NUM }), ); - - bridge.sockets[0].write(myInfoFrame(MY_NODE)); - await waitFor(() => svc.status().myNodeNum === MY_NODE); - - bridge.sockets[0].write(textPacketFrame(MY_NODE, 42, 'ping', 3)); - - // history capture proves the packet was processed (notifyRx runs right after it) - await waitFor(() => svc.messages().length === 1); - expect(notifyMock).not.toHaveBeenCalled(); - expect(svc.forwarded()).toEqual([]); + await svc.pollMessages(); + expect(svc.forwarded()).toHaveLength(0); } finally { - delete process.env.MESH_NOTIFY_CHANNEL; + envChannels(undefined); svc.onModuleDestroy(); - bridge.close(); } }); - it('does not record when no notifier is wired', () => { - process.env.MESH_NOTIFY_CHANNEL = '0'; // injected records ride channel 0 — the gate would pass - const svc = new MeshService(); // unwired — pinned semantics: nothing forwarded, nothing recorded + it('does not record when no notifier is wired', async () => { + envChannels(undefined); + const svc = await makeSyncedService(makeClient()); // no opencode try { - svc.injectMessage('hi'); - - expect(svc.forwarded()).toEqual([]); + const client = (svc as unknown as Record)[ + 'client' + ] as FakeMeshMonitorClient; + client.pushInbound(inbound({ text: 'no notifier', channel: 3 })); + await svc.pollMessages(); + expect(svc.forwarded()).toHaveLength(0); } finally { - delete process.env.MESH_NOTIFY_CHANNEL; + envChannels(undefined); + svc.onModuleDestroy(); } }); - it('counts injected messages that pass the gate and omits `from` when there is no sender', () => { - process.env.MESH_NOTIFY_CHANNEL = '0'; - const svc = makeSvc(); + it('counts injected messages that pass the gate and omits from when there is no sender', async () => { + envChannels('0,3'); + const { svc: oc } = mockOpencode(); + const svc = await makeSyncedService(makeClient(), undefined, oc); try { - const r = svc.injectMessage('hi'); // no from - - expect(svc.forwarded()).toEqual([ - { id: r.id, text: 'hi', channel: 0, at: expect.any(Number) }, - ]); - expect('from' in svc.forwarded()[0]!).toBe(false); + const { id } = svc.injectMessage('inj'); + expect(svc.forwarded()).toHaveLength(1); + expect(svc.forwarded()[0]).toMatchObject({ + id, + text: 'inj', + channel: 0, + }); + expect(svc.forwarded()[0]).not.toHaveProperty('from'); } finally { - delete process.env.MESH_NOTIFY_CHANNEL; + envChannels(undefined); + svc.onModuleDestroy(); } }); - it('caps at 100 (oldest evicted), newest first', () => { - process.env.MESH_NOTIFY_CHANNEL = '0'; - const svc = makeSvc(); + it('caps at 100 (oldest evicted), newest first', async () => { + envChannels('0,3'); + const { svc: oc } = mockOpencode(); + const svc = await makeSyncedService(makeClient(), undefined, oc); try { - for (let i = 0; i < 101; i++) svc.injectMessage(`m-${i}`); - + for (let i = 1; i <= 105; i++) svc.injectMessage(`inj-${i}`); expect(svc.forwarded()).toHaveLength(100); - expect(svc.forwarded()[0]!.text).toBe('m-100'); // newest first - expect(svc.forwarded()[99]!.text).toBe('m-1'); // m-0 evicted + expect(svc.forwarded()[0].text).toBe('inj-105'); // newest first + expect(svc.forwarded()[99].text).toBe('inj-6'); } finally { - delete process.env.MESH_NOTIFY_CHANNEL; + envChannels(undefined); + svc.onModuleDestroy(); } }); }); +// --------------------------------------------------------------------------- +// Inject +// --------------------------------------------------------------------------- + describe('MeshService injectMessage', () => { - it('injects rx records that appear newest-first in messages()', () => { - const svc = new MeshService(); - expect(svc.messages()).toEqual([]); - - const r1 = svc.injectMessage('first'); - const r2 = svc.injectMessage('second', KAMIL_NUM); - expect(r1.id).toBeGreaterThan(0); // random non-zero uint32, like tx ids - expect(r2.id).toBeGreaterThan(0); - - expect(svc.messages()).toEqual([ - { + it('injects rx records that appear newest-first in messages()', async () => { + const client = makeClient(); + const svc = await makeSyncedService(client); + try { + const r1 = svc.injectMessage('first'); + const r2 = svc.injectMessage('second', KAMIL_NUM); + expect(r1.id).toBeGreaterThan(0); + expect(r2.id).toBeGreaterThan(0); + expect(svc.messages()).toHaveLength(2); + expect(svc.messages()[0]).toMatchObject({ id: r2.id, dir: 'rx', - from: KAMIL_NUM, text: 'second', + from: KAMIL_NUM, channel: 0, - at: expect.any(Number), - }, - { - id: r1.id, - dir: 'rx', - text: 'first', - channel: 0, - at: expect.any(Number), - }, - ]); - }); - - it('omits from when not provided and rejects empty text or invalid from', () => { - const svc = new MeshService(); - const rec = svc.injectMessage('no sender'); - expect(svc.messages()[0]).toEqual({ - id: rec.id, - dir: 'rx', - text: 'no sender', - channel: 0, - at: expect.any(Number), - }); - expect(svc.messages()[0]).not.toHaveProperty('from'); - - for (const bad of ['', undefined]) { - expect(() => svc.injectMessage(bad as unknown as string)).toThrow( - 'text must be a non-empty string', - ); + }); + expect(svc.messages()[0]).not.toHaveProperty('status'); + expect(svc.messages()[1]).toMatchObject({ id: r1.id, text: 'first' }); + expect(svc.messages()[1]).not.toHaveProperty('from'); + // nothing was sent to the gateway + expect(client.sends).toHaveLength(0); + } finally { + svc.onModuleDestroy(); } - for (const bad of [0, -1, 42.5, BROADCAST_ADDR + 1]) { - expect(() => svc.injectMessage('x', bad)).toThrow(/invalid node number/); - } - expect(svc.messages()).toHaveLength(1); // rejected calls add nothing }); - it('caps history at 100 records, dropping the oldest', () => { - const svc = new MeshService(); - for (let i = 0; i < 105; i++) svc.injectMessage(`m-${i}`); - expect(svc.messages()).toHaveLength(100); - expect(svc.messages()[0]?.text).toBe('m-104'); // newest first - expect(svc.messages()[99]?.text).toBe('m-5'); // m-0..m-4 dropped - }); - - it('shares the history buffer with captured packets', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); + it('works without a gateway client (pure local record)', () => { + const svc = new MeshService(undefined, undefined, null); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - svc.injectMessage('injected'); - bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'real', 2)); - - await waitFor(() => svc.messages().length === 2); - expect(svc.messages()[0]).toMatchObject({ dir: 'rx', text: 'real' }); // newest first - expect(svc.messages()[1]).toMatchObject({ + const { id } = svc.injectMessage('local only', KAMIL_NUM); + expect(id).toBeGreaterThan(0); + expect(svc.messages()[0]).toMatchObject({ + id, dir: 'rx', - text: 'injected', + text: 'local only', + from: KAMIL_NUM, channel: 0, }); } finally { svc.onModuleDestroy(); - bridge.close(); + } + }); + + it('rejects empty text and invalid from (0 / negative / non-integer)', () => { + const svc = new MeshService(undefined, undefined, null); + try { + expect(() => svc.injectMessage('')).toThrow( + 'text must be a non-empty string', + ); + expect(() => svc.injectMessage('x', 0)).toThrow('invalid node number'); + expect(() => svc.injectMessage('x', -1)).toThrow('invalid node number'); + expect(() => svc.injectMessage('x', 1.5)).toThrow('invalid node number'); + expect(() => svc.injectMessage('x', BROADCAST_ADDR + 1)).toThrow( + 'invalid node number', + ); + } finally { + svc.onModuleDestroy(); + } + }); + + it('caps history at 100 records, dropping the oldest', () => { + const svc = new MeshService(undefined, undefined, null); + try { + for (let i = 1; i <= 105; i++) svc.injectMessage(`inj-${i}`); + expect(svc.messages()).toHaveLength(100); + } finally { + svc.onModuleDestroy(); + } + }); + + it('shares the history buffer with captured packets (ordered by arrival)', async () => { + const client = makeClient(); + const svc = await makeSyncedService(client); + try { + svc.injectMessage('injected first'); + client.pushInbound(inbound({ text: 'captured second', at: T0 + 9 })); + await svc.pollMessages(); + // the capture carries the newer timestamp — it sorts first + expect(svc.messages().map((m) => m.text)).toEqual([ + 'captured second', + 'injected first', + ]); + } finally { + svc.onModuleDestroy(); } }); }); +// --------------------------------------------------------------------------- +// Telemetry +// --------------------------------------------------------------------------- + describe('MeshService telemetry', () => { - it('captures device metrics and environment variants with from/kind/data', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); + it('groups rows into one record per (node, packetId) and derives kind from the metric set', async () => { + const client = makeClient(); + client.setTelemetry([ + // one logical packet: two metrics sharing a packet id (newest row first, gateway order) + { + fromNum: KAMIL_NUM, + at: T0, + kind: 'temperature', + data: { value: 21.5, unit: 'C', packetId: 555 }, + }, + { + fromNum: KAMIL_NUM, + at: T0, + kind: 'batteryLevel', + data: { value: 87, packetId: 555 }, + }, + ]); + const svc = await makeSyncedService(client); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - expect(svc.telemetry()).toEqual([]); // nothing before the first packet - - bridge.sockets[0].write( - telemetryPacketFrame(KAMIL_NUM, { - case: 'deviceMetrics', - value: { batteryLevel: 87, voltage: 4.1, uptimeSeconds: 3600 }, - }), - ); - bridge.sockets[0].write( - telemetryPacketFrame(987654321, { - case: 'environmentMetrics', - value: { temperature: 21.5, relativeHumidity: 48 }, - }), - ); - - await waitFor(() => svc.telemetry().length === 2); expect(svc.telemetry()).toEqual([ - { - from: 987654321, // newest first - at: expect.any(Number), - kind: 'environmentMetrics', - // the deprecated repeated one_wire_temperature field decodes as an empty list even when unset - data: { - temperature: 21.5, - relativeHumidity: 48, - oneWireTemperature: [], - }, - }, { from: KAMIL_NUM, - at: expect.any(Number), - kind: 'deviceMetrics', - // proto float is IEEE-754 single precision — 4.1 widens to this exact double + at: T0, + kind: 'environmentMetrics', // temperature is an environment-sensor metric data: { - batteryLevel: 87, - voltage: 4.099999904632568, - uptimeSeconds: 3600, + batteryLevel: { value: 87 }, // no unit on the row — omitted + temperature: { value: 21.5, unit: 'C' }, }, }, ]); } finally { svc.onModuleDestroy(); - bridge.close(); + } + }); + + it('groups rows without a packet id by (node, timestamp) and dedupes across polls', async () => { + const client = makeClient(); + client.setTelemetry([ + { fromNum: KAMIL_NUM, at: T0, kind: 'airUtilTx', data: { value: 12 } }, + { fromNum: KAMIL_NUM, at: T0, kind: 'freeMemory', data: { value: 99 } }, + ]); + const svc = await makeSyncedService(client); + try { + expect(svc.telemetry()).toEqual([ + { + from: KAMIL_NUM, + at: T0, + kind: 'deviceMetrics', // no environment-sensor metric in the group + data: { airUtilTx: { value: 12 }, freeMemory: { value: 99 } }, + }, + ]); + // the next poll sees the same rows — the (node, timestamp) group key dedupes them + await svc.pollTelemetry(); + expect(svc.telemetry()).toHaveLength(1); + } finally { + svc.onModuleDestroy(); + } + }); + + it('omits the unit when it is an empty string', async () => { + const client = makeClient(); + client.setTelemetry([ + { + fromNum: KAMIL_NUM, + at: T0, + kind: 'altimeter', + data: { value: 1, unit: '', packetId: 77 }, + }, + ]); + const svc = await makeSyncedService(client); + try { + expect(svc.telemetry()[0].data).toEqual({ altimeter: { value: 1 } }); + } finally { + svc.onModuleDestroy(); } }); it('caps telemetry at 100 records, dropping the oldest', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); + const client = makeClient(); + const rows: MMTelemetry[] = []; + for (let i = 1; i <= 105; i++) + rows.push({ + fromNum: KAMIL_NUM, + at: T0 + i, + kind: 'batteryLevel', + data: { value: i, packetId: i }, + }); + client.setTelemetry(rows); + const svc = await makeSyncedService(client); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - for (let i = 0; i < 105; i++) - bridge.sockets[0].write( - telemetryPacketFrame(42, { - case: 'deviceMetrics', - value: { batteryLevel: i }, - }), - ); - - await waitFor(() => svc.telemetry().length === 100, 10000); - expect(svc.telemetry()[0]?.data).toEqual({ batteryLevel: 104 }); // newest first - expect(svc.telemetry()[99]?.data).toEqual({ batteryLevel: 5 }); // i=0..4 dropped + expect(svc.telemetry()).toHaveLength(100); + expect(svc.telemetry()[0].at).toBe(T0 + 105); // newest first } finally { svc.onModuleDestroy(); - bridge.close(); - } - }); - - it('resets telemetry on reconnect (no stale data)', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - process.env.MESH_RECONNECT_MS = '50'; - const svc = new MeshService(); - try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - bridge.sockets[0].write( - telemetryPacketFrame(KAMIL_NUM, { case: 'deviceMetrics', value: {} }), - ); - await waitFor(() => svc.telemetry().length === 1); - - // drop the connection; bot reconnects and must start with an empty history - bridge.sockets[0].destroy(); - await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected); - await waitFor(() => svc.telemetry().length === 0); - } finally { - delete process.env.MESH_RECONNECT_MS; - svc.onModuleDestroy(); - bridge.close(); } }); }); +// --------------------------------------------------------------------------- +// Resolve dest +// --------------------------------------------------------------------------- + describe('MeshService resolveDest', () => { it('resolves broadcast sentinels, numbers and names to node numbers', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); + const client = makeClient(); + client.setNodes([kamilNode()]); + const svc = await makeSyncedService(client); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); - await waitFor(async () => (await svc.nodes()).length === 1); - - expect(await svc.resolveDest(undefined)).toBe(BROADCAST_ADDR); // omitted → broadcast - expect(await svc.resolveDest('')).toBe(BROADCAST_ADDR); // empty → broadcast + expect(await svc.resolveDest()).toBe(BROADCAST_ADDR); + expect(await svc.resolveDest('')).toBe(BROADCAST_ADDR); expect(await svc.resolveDest('!b')).toBe(BROADCAST_ADDR); - expect(await svc.resolveDest('BROADCAST')).toBe(BROADCAST_ADDR); // case-insensitive - expect(await svc.resolveDest(42)).toBe(42); // number passthrough - expect(await svc.resolveDest(String(KAMIL_NUM))).toBe(KAMIL_NUM); // numeric string - expect(await svc.resolveDest('Kamil SP8MK/P')).toBe(KAMIL_NUM); // exact longName + expect(await svc.resolveDest('broadcast')).toBe(BROADCAST_ADDR); + expect(await svc.resolveDest('Broadcast ')).toBe(BROADCAST_ADDR); + expect(await svc.resolveDest(42)).toBe(42); + expect(await svc.resolveDest('42')).toBe(42); + expect(await svc.resolveDest('123456789')).toBe(KAMIL_NUM); + expect(await svc.resolveDest('kamil')).toBe(KAMIL_NUM); // unique substring + expect(await svc.resolveDest('Kamil SP8MK/P')).toBe(KAMIL_NUM); // exact name } finally { svc.onModuleDestroy(); - bridge.close(); } }); it('throws for unknown names and invalid node numbers', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); + const client = makeClient(); + // two SP8MK nodes — the 'sp8mk' substring is ambiguous between them + client.setNodes([ + kamilNode(), + kamilNode({ + num: 777, + longName: 'SP8MK Home', + shortName: 'HOM', + }), + ]); + const svc = await makeSyncedService(client); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); - await waitFor(async () => (await svc.nodes()).length === 1); - await expect(svc.resolveDest('zzz-nope')).rejects.toThrow( "no node matching 'zzz-nope'", ); - for (const bad of [0, -1, 42.5, BROADCAST_ADDR + 1]) { - await expect(svc.resolveDest(bad)).rejects.toThrow( - /invalid node number/, - ); - } - await expect(svc.resolveDest('0')).rejects.toThrow(/invalid node number/); + await expect(svc.resolveDest('sp8mk')).rejects.toThrow( + 'no node matching', + ); // ambiguous substring — two matches + await expect(svc.resolveDest(0)).rejects.toThrow('invalid node number'); + await expect(svc.resolveDest(-1)).rejects.toThrow('invalid node number'); + await expect(svc.resolveDest(1.5)).rejects.toThrow('invalid node number'); + await expect(svc.resolveDest('0')).rejects.toThrow('invalid node number'); } finally { svc.onModuleDestroy(); - bridge.close(); } }); }); -describe('MeshService device info', () => { - it('captures DeviceMetadata and exposes firmwareVersion', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); - try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); +// --------------------------------------------------------------------------- +// Device info +// --------------------------------------------------------------------------- - // before the sync: only the node number (null until myInfo arrives) +describe('MeshService device info', () => { + it('reports the degraded shape: identity + firmware, empty config sections', async () => { + const client = makeClient(); + client.setNodes([ + kamilNode({ + num: MY_NUM, + longName: 'SP8MK Bot', + shortName: 'BOT', + hwModel: P.Mesh.HardwareModel.RAK4631, + firmwareVersion: '2.7.15.567b8ea', + }), + ]); + const svc = await makeSyncedService(client); + try { expect(svc.deviceInfo()).toEqual({ - myNodeNum: null, + myNodeNum: MY_NUM, + firmwareVersion: '2.7.15.567b8ea', localConfig: {}, moduleConfig: {}, }); - - bridge.sockets[0].write( - metadataFrame({ firmwareVersion: '2.7.0.1', hasWifi: false, role: 3 }), - ); - - await waitFor(() => svc.deviceInfo().firmwareVersion === '2.7.0.1'); - const info = svc.deviceInfo(); - expect(info.firmwareVersion).toBe('2.7.0.1'); - expect(info.metadata?.firmwareVersion).toBe('2.7.0.1'); // metadata stored as-is } finally { svc.onModuleDestroy(); - bridge.close(); } }); - it('captures config/module-config sections and redacts key material', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); + it('omits firmwareVersion before sync or when the own node is unknown', async () => { + // before any sync: no identity at all + const bare = new MeshService(undefined, undefined, makeClient()); + expect(bare.deviceInfo()).toEqual({ + myNodeNum: null, + localConfig: {}, + moduleConfig: {}, + }); + bare.onModuleDestroy(); + + // synced, but the own node is not in the gateway's node list + const client = makeClient(); + client.setNodes([kamilNode()]); + const svc = await makeSyncedService(client); try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - const secret = 'super-secret-wifi-psk'; - bridge.sockets[0].write( - configSectionFrame('security', { - publicKey: new Uint8Array([1, 2, 3]), - privateKey: new Uint8Array([4, 5, 6]), - adminKey: [new Uint8Array([7])], - isManaged: true, - }), - ); - bridge.sockets[0].write( - configSectionFrame('network', { - wifiEnabled: false, - wifiSsid: 'home', - wifiPsk: secret, - }), - ); - bridge.sockets[0].write(configSectionFrame('device', { role: 3 })); - bridge.sockets[0].write( - moduleConfigSectionFrame('telemetry', { - environmentUpdateInterval: 900, - powerMeasurementEnabled: true, - }), - ); - - await waitFor( - () => - Object.keys(svc.deviceInfo().localConfig).length === 3 && - Object.keys(svc.deviceInfo().moduleConfig).length === 1, - ); - const info = svc.deviceInfo(); - expect(Object.keys(info.localConfig).sort()).toEqual([ - 'device', - 'network', - 'security', - ]); - expect(info.moduleConfig.telemetry).toMatchObject({ - environmentUpdateInterval: 900, - powerMeasurementEnabled: true, + expect(svc.deviceInfo()).toEqual({ + myNodeNum: MY_NUM, + localConfig: {}, + moduleConfig: {}, }); - - // key material is replaced with a marker — never exposed - expect(info.localConfig.security).toMatchObject({ - publicKey: 'REDACTED', - privateKey: 'REDACTED', - adminKey: 'REDACTED', - isManaged: true, // non-secret fields pass through - }); - expect(info.localConfig.network?.wifiPsk).toBe('REDACTED'); - const json = JSON.stringify(info); - expect(json).not.toContain(secret); } finally { svc.onModuleDestroy(); - bridge.close(); - } - }); - - it('serializes proto uint64 fields as strings (JSON-safe)', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); - try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - // powermonEnables is a proto uint64 — protobuf-es decodes it as native bigint - bridge.sockets[0].write( - configSectionFrame('power', { powermonEnables: 123n }), - ); - - await waitFor(() => svc.deviceInfo().localConfig.power !== undefined); - const info = svc.deviceInfo(); - expect(info.localConfig.power?.powermonEnables).toBe('123'); // bigint -> decimal string - expect(() => JSON.stringify(info)).not.toThrow(); // no "Do not know how to serialize a BigInt" - } finally { - svc.onModuleDestroy(); - bridge.close(); - } - }); - - it('summarizes NodeInfo snr/hopsAway/viaMqtt when present', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - const svc = new MeshService(); - try { - svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); - - bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); - bridge.sockets[0].write( - nodeInfoFrame(42, { - user: { id: '!ot', longName: 'Other Node' }, - snr: -2.5, - hopsAway: 1, - viaMqtt: true, - }), - ); - - await waitFor(async () => (await svc.nodes()).length === 2); - const nodes = await svc.nodes(); - expect(nodes.find((n) => n.num === 42)).toEqual({ - num: 42, - longName: 'Other Node', - lastHeard: null, - snr: -2.5, - hopsAway: 1, - viaMqtt: true, - }); - // absent in the proto → omitted from the summary - expect(nodes.find((n) => n.num === KAMIL_NUM)).not.toHaveProperty('snr'); - } finally { - svc.onModuleDestroy(); - bridge.close(); } }); }); +// --------------------------------------------------------------------------- +// SQLite persistence +// --------------------------------------------------------------------------- + describe('MeshService SQLite persistence', () => { - it('persists an injected message row on poller tick (no manual flush)', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); + it('persists an injected message row on the poller tick (no manual flush)', async () => { process.env.MESH_POLL_MS = '50'; const db = new DatabaseSync(':memory:'); initMessagesTable(db); - const svc = new MeshService(db); + const svc = new MeshService(db, undefined, makeClient()); try { svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); svc.injectMessage('hello db', KAMIL_NUM); @@ -1949,27 +1750,22 @@ describe('MeshService SQLite persistence', () => { } finally { delete process.env.MESH_POLL_MS; svc.onModuleDestroy(); - bridge.close(); } }); - it('persists the destination (to) of captured rx packets on poller tick', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); + it('persists the destination (to) of captured rx messages on the poller tick', async () => { process.env.MESH_POLL_MS = '50'; const db = new DatabaseSync(':memory:'); initMessagesTable(db); - const svc = new MeshService(db); + const client = makeClient(); + const svc = new MeshService(db, undefined, client); try { svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); + await svc.pollStatus(); - bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3)); + client.pushInbound(inbound({ text: 'ping', at: T0 + 3 })); + await svc.pollMessages(); - // the poller tick must pick it up — no flushHistory() call here await waitFor( () => ( @@ -1983,50 +1779,42 @@ describe('MeshService SQLite persistence', () => { .get(1) as Record; expect(row.dir).toBe('rx'); expect(row.from_node).toBe(KAMIL_NUM); - expect(row.to_node).toBe(42); // rx rows carry the destination now (no schema change — to_node existed for tx) + expect(row.to_node).toBe(MY_NUM); // captured rx rows carry the destination + expect(row.channel).toBe(3); } finally { delete process.env.MESH_POLL_MS; svc.onModuleDestroy(); - bridge.close(); } }); - it('reflects the ACK status update in the database after the next tick', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); + it('reflects the delivery-state update in the database after the next tick', async () => { process.env.MESH_POLL_MS = '50'; const db = new DatabaseSync(':memory:'); initMessagesTable(db); - const svc = new MeshService(db); + const client = makeClient(); + const svc = new MeshService(db, undefined, client); try { svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); + await svc.pollStatus(); - const id = await svc.sendText(42, 'ack me'); + const id = await svc.sendText(BROADCAST_ADDR, 'ack me'); expect(svc.messages()[0]?.status).toBe('pending'); - bridge.sockets[0].write(routingFrame(id, P.Mesh.Routing_Error.NONE)); - await waitFor(() => svc.messages()[0]?.status === 'delivered'); + client.setDeliveryState(id, 'delivered'); + await svc.pollMessages(); + expect(svc.messages()[0]?.status).toBe('delivered'); // the in-place status mutation must reach SQLite on the next poller tick - // (the row may not exist before the first tick — waitFor re-checks, so stay null-safe) await waitFor( () => ( db.prepare('SELECT status FROM messages WHERE id = ?').get(id) as - | { - status: string | null; - } - | undefined + { status: string | null } | undefined )?.status === 'delivered', ); } finally { delete process.env.MESH_POLL_MS; svc.onModuleDestroy(); - bridge.close(); } }); @@ -2044,14 +1832,12 @@ describe('MeshService SQLite persistence', () => { 42, `seed-${i}`, 0, - 1_700_000_000_000 + i * 1000, + T0 + i * 1000, null, null, ); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = '1'; // closed port — no real bridge needed for seeding - const svc = new MeshService(db); + const svc = new MeshService(db, undefined, null); try { svc.onModuleInit(); expect(svc.messages()).toHaveLength(100); // newest 100 of the 150 rows @@ -2065,8 +1851,6 @@ describe('MeshService SQLite persistence', () => { expect(svc.messages()[99]).toMatchObject({ id: 1050, text: 'seed-50' }); expect(svc.messages()[0]).not.toHaveProperty('status'); // rx rows carry no status } finally { - delete process.env.MESH_HOST; - delete process.env.MESH_PORT; svc.onModuleDestroy(); } }); @@ -2085,7 +1869,7 @@ describe('MeshService SQLite persistence', () => { 42, `old-${i}`, 0, - 1_700_000_000_000 + i * 1000, + T0 + i * 1000, null, null, ); @@ -2111,35 +1895,35 @@ describe('MeshService SQLite persistence', () => { } }); - it('keeps the message history across a bridge reconnect', async () => { - const bridge = await startBridge(); - process.env.MESH_HOST = '127.0.0.1'; - process.env.MESH_PORT = String(bridge.port); - process.env.MESH_RECONNECT_MS = '50'; + it('keeps the message history across a gateway outage', async () => { + const client = makeClient(); const db = new DatabaseSync(':memory:'); initMessagesTable(db); - const svc = new MeshService(db); + const svc = new MeshService(db, undefined, client); try { svc.onModuleInit(); - await waitFor( - () => bridge.sockets.length === 1 && svc.status().connected, - ); + await svc.pollStatus(); - svc.injectMessage('survives reconnect', KAMIL_NUM); + svc.injectMessage('survives outage', KAMIL_NUM); expect(svc.messages()).toHaveLength(1); - // drop the connection; bot reconnects and must keep the history (unlike channels/nodes/telemetry) - bridge.sockets[0].destroy(); - await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected); + // the gateway goes down — history must be kept (unlike the volatile world state) + client.setStatus({ connected: false, myNodeNum: MY_NUM }); + await svc.pollStatus(); + expect(svc.status().connected).toBe(false); expect(svc.messages()).toHaveLength(1); expect(svc.messages()[0]).toMatchObject({ dir: 'rx', - text: 'survives reconnect', + text: 'survives outage', }); + + // ...and comes back + client.setStatus({ connected: true, myNodeNum: MY_NUM }); + await svc.pollStatus(); + expect(svc.status().connected).toBe(true); + expect(svc.messages()).toHaveLength(1); } finally { - delete process.env.MESH_RECONNECT_MS; svc.onModuleDestroy(); - bridge.close(); } }); }); diff --git a/src/mesh/mesh.service.ts b/src/mesh/mesh.service.ts index a09285c..e42fafe 100644 --- a/src/mesh/mesh.service.ts +++ b/src/mesh/mesh.service.ts @@ -7,33 +7,26 @@ import { OnModuleInit, ServiceUnavailableException, } from '@nestjs/common'; -import * as net from 'node:net'; // type-only import: node:sqlite is only required at runtime by whoever opens a database (mesh.module.ts / tests) import type { DatabaseSync } from 'node:sqlite'; -import type * as Proto from '@meshtastic/protobufs'; -// type-only: the instance arrives via attachCommands() in production (command.module.ts) or the constructor in tests — no runtime import cycle with src/commands +// type-only: the instance arrives via attachCommands() in production (command.module.ts) — no runtime import cycle with src/commands import type { CommandService } from '../commands/command.service.js'; import { OpencodeService } from '../opencode/opencode.service.js'; import { BROADCAST_ADDR, - PORT_NODEINFO_APP, - PORT_POSITION_APP, - PORT_ROUTING_APP, - PORT_TELEMETRY_APP, PORT_TEXT_MESSAGE_APP, - buildTextToRadio, - buildWantConfigToRadio, - decodeFromRadio, - decodeNodeInfo, - decodePosition, - decodeRouting, - decodeTelemetry, - describeRoutingError, - extractFrames, - frame, loadHwModelNames, randomPacketId, } from './codec.js'; +import { + MeshMonitorError, + nodeIdToNum, + nodeNumToId, + type MeshMonitorClient, + type MMChannel, + type MMMessage, + type MMNode, +} from './meshmonitor/mesh-monitor-client.js'; /** One entry in the message history: an inbound text (dir 'rx') or one of our sends (dir 'tx'). */ export interface MessageRecord { @@ -47,9 +40,9 @@ export interface MessageRecord { channel: number; /** epoch ms */ at: number; - /** tx only: pending until the routing ACK/NACK arrives; broadcasts stay pending (firmware strips want_ack pre-TX) */ + /** tx only: pending until the gateway reports a final delivery state; broadcasts stay pending (they have no acks) */ status?: 'pending' | 'delivered' | 'failed'; - /** tx only, when failed: human-readable Routing.Error reason */ + /** tx only, when failed: human-readable reason */ ackError?: string; } @@ -65,15 +58,15 @@ export interface ForwardedRecord { at: number; } -/** One entry in the telemetry history: a TELEMETRY_APP packet from some node. */ +/** One entry in the telemetry history: one logical telemetry packet from some node. */ export interface TelemetryRecord { /** sender node num */ from: number; /** epoch ms */ at: number; - /** meshtastic.Telemetry oneof case name (e.g. "deviceMetrics", "environmentMetrics") */ + /** meshtastic.Telemetry oneof case name ("deviceMetrics" or "environmentMetrics" — derived from the metric set) */ kind: string; - /** the variant payload, JSON-safe (proto uint64 fields as decimal strings); unset optional fields omitted */ + /** metric name → the row's { value, unit? } */ data: object; } @@ -81,8 +74,29 @@ export interface TelemetryRecord { const HISTORY_LIMIT = 100; /** Rows kept in SQLite across restarts; older rows are pruned on every poller tick. */ const DB_RETENTION = 1000; -// how often the idle watchdog checks for a silent-but-connected bridge (ms) -const IDLE_CHECK_MS = 30_000; + +// MeshMonitor gateway poll cadences (ms): status owns the initial sync, messages carry the live +// traffic, the rest refresh the read-model state the REST/MCP endpoints serve +const STATUS_POLL_MS = 15_000; +/** shorter retry after a FAILED status poll — the gateway may come back quickly, and a down gateway must report down fast */ +const STATUS_RETRY_MS = 5_000; +const MESSAGE_POLL_MS = 2_000; +const NODE_POLL_MS = 30_000; +const CHANNEL_POLL_MS = 5 * 60_000; +const TELEMETRY_POLL_MS = 60_000; +/** window size for the message poll — small is enough: the seen-set dedupes across polls and the gateway returns newest first */ +const MESSAGE_POLL_LIMIT = 50; + +/** Environment-sensor metric names (meshtastic EnvironmentMetrics) — a telemetry group carrying any of these is an environmentMetrics record. */ +const ENV_METRICS = new Set([ + 'temperature', + 'relativeHumidity', + 'barometricPressure', + 'gasResistance', + 'depth', + 'gpsAccuracy', + 'distanceFromHome', +]); // one row per MessageRecord, keyed by packet id (tx ids and injected ids are random uint32, rx uses the wire id) export const MESSAGES_DDL = `CREATE TABLE IF NOT EXISTS messages( @@ -118,10 +132,6 @@ function recordFromRow(row: Record): MessageRecord { return rec; } -/** What we know about a node so far: full NodeInfo after sync, possibly just {num}+position for live-only nodes. */ -type NodeEntry = Pick & - Partial>; - export interface NodeSummary { num: number; longName?: string; @@ -133,38 +143,27 @@ export interface NodeSummary { /** epoch seconds of the last heard packet; null when never heard */ lastHeard: number | null; position?: { lat: number; lon: number; alt?: number }; - /** signal-to-noise ratio of the last received packet, dB (NodeInfo.snr) */ + /** signal-to-noise ratio of the last received packet, dB */ snr?: number; - /** hops from this node to the one that reported it (optional in proto) */ + /** hops from this node to the one that reported it */ hopsAway?: number; /** true when the node info arrived via MQTT rather than LoRa */ viaMqtt?: boolean; } -/** Shape of deviceInfo() output: sync-captured device data, key material redacted. */ +/** + * Shape of deviceInfo() output: the bot's identity as the gateway reports it. Degraded versus + * the old TCP bridge: the gateway has no device-metadata or config endpoints, so the config + * sections are always empty (and the key material they carried was redacted anyway — nothing + * is exposed). + */ export interface DeviceInfo { myNodeNum: number | null; firmwareVersion?: string; - metadata?: Proto.Mesh.DeviceMetadata; localConfig: Record>; moduleConfig: Record>; } -// protobuf-es decodes proto uint64 fields as native bigint, which JSON.stringify rejects; -// recursively convert so deviceInfo()/telemetry() output is always JSON-safe (bigint -> decimal string). -// $-prefixed keys are protobuf-es internals ($typeName/$unknown), not device data — dropped. -function jsonSafe(value: T): T { - if (typeof value === 'bigint') return String(value) as T; - if (Array.isArray(value)) return value.map(jsonSafe) as T; - if (value !== null && typeof value === 'object') { - const out: Record = {}; - for (const [k, v] of Object.entries(value)) - if (!k.startsWith('$')) out[k] = jsonSafe(v); - return out as T; - } - return value; // primitives + undefined pass through -} - /** MESH_NOTIFY_CHANNEL is a comma-separated list of channel indices; empty/invalid entries are ignored. */ function parseNotifyChannels(raw: string): number[] { return raw @@ -174,30 +173,25 @@ function parseNotifyChannels(raw: string): number[] { .map((s) => parseInt(s, 10)); } -// key material must never leave the process — replaced with a marker so callers can see the field exists -const REDACTED = 'REDACTED'; -/** Config sections that carry secrets: security (PKI keys) and network (wifi psk). */ -function redactSection( - name: string, - value: Record, -): Record { - if (name !== 'security' && name !== 'network') return value; - const out = { ...value }; - for (const key of ['publicKey', 'privateKey', 'adminKey', 'wifiPsk']) - if (out[key] !== undefined) out[key] = REDACTED; - return out; +/** + * The destination node number of a gateway message: the explicit `toNodeNum` when present + * (0 = unset), else the broadcast sentinels, else a `!hex` node id. + */ +function messageDest(m: MMMessage): number | undefined { + if (m.toNum != null && m.toNum !== 0) return m.toNum; + if (m.toId == null) return undefined; + if (m.toId.trim().toLowerCase() === 'broadcast') return BROADCAST_ADDR; + return nodeIdToNum(m.toId) ?? undefined; } @Injectable() export class MeshService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(MeshService.name); - private readonly host = process.env.MESH_HOST ?? '192.168.1.4'; - private readonly port = parseInt(process.env.MESH_PORT ?? '4403', 10); - // ponytail: fixed-interval retry, no backoff — fine for single-bridge use - private readonly reconnectMs = parseInt( - process.env.MESH_RECONNECT_MS ?? '5000', - 10, - ); + // the MeshMonitor gateway base URL — read at construction like the other env config; also the source of the host/port reported by status() + private readonly gatewayBase = + process.env.MESHMONITOR_API_BASE ?? 'https://meshmonitor.kkhost.pl/api/v1'; + private readonly host: string; + private readonly port: number; // sends are pinned to the test channel ("test", index 3) on purpose — never the primary channel private readonly sendChannel = parseInt( process.env.MESH_SEND_CHANNEL ?? '3', @@ -210,73 +204,77 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { ); // how often the poller re-upserts the history buffer into SQLite (ms) private readonly pollMs = parseInt(process.env.MESH_POLL_MS ?? '1000', 10); - // idle watchdog: force a reconnect when an ESTABLISHED connection stops decoding frames — the zombie-bridge case where outbound sends keep working but inbound frame flow is dead (raw non-frame bytes do NOT reset the clock). Read at construction like the other env config. - private readonly idleTimeoutMs = parseInt( - process.env.MESH_IDLE_TIMEOUT_MS ?? '60000', - 10, - ); - // lazy periodic autoreconnect: unconditionally destroy + re-establish every N ms so a fresh sync happens on schedule — the bridge/radio side tends to kill inbound RX within ~20 min of a fresh sync. Read at construction like the other env config. - private readonly reconnectIntervalMs = parseInt( - process.env.MESH_RECONNECT_INTERVAL_MS ?? '300000', - 10, - ); - // cap on undecoded frame buffer: a desynced stream that never forms frames would grow it without bound — force a reconnect instead. Env-overridable so tests can trigger it (Node caps socket 'data' events at ~64 KB). - private readonly maxFrameBuffer = parseInt( - process.env.MESH_MAX_FRAME_BUFFER ?? '256000', - 10, - ); /** SQLite database backing the message history; null = persistence disabled (tests construct without one). */ private db: DatabaseSync | null = null; /** In-process opencode notifier for rx texts on the notify channel; null in tests that don't wire one. */ private readonly opencode: OpencodeService | null; - /** Event-driven `#command` auto-responder (COMMAND_CHANNEL); null until wired — via the constructor (tests) or attachCommands() (DI). */ + /** MeshMonitor gateway client; null when MESHMONITOR_API_TOKEN is unset — the service stays disconnected in that case. */ + private readonly client: MeshMonitorClient | null; + /** Event-driven `#command` auto-responder (COMMAND_CHANNEL); null until wired via attachCommands(). */ private commands: CommandService | null = null; + private pollTimer: NodeJS.Timeout | null = null; + private statusTimer: NodeJS.Timeout | null = null; + private messageTimer: NodeJS.Timeout | null = null; + private nodeTimer: NodeJS.Timeout | null = null; + private channelTimer: NodeJS.Timeout | null = null; + private telemetryTimer: NodeJS.Timeout | null = null; + private shuttingDown = false; - constructor( - db?: DatabaseSync, - opencode?: OpencodeService, - commands?: CommandService, - ) { - this.db = db ?? null; - this.opencode = opencode ?? null; - this.commands = commands ?? null; - } - - /** Wire the `#command` auto-responder after construction — used by command.module.ts to break the DI cycle (a constructor-level MeshService <-> CommandService cycle leaves one side null in NestJS's injector). */ - attachCommands(commands: CommandService): void { - this.commands = commands; - } - - private socket: net.Socket | null = null; - private buffer: Uint8Array = new Uint8Array(0); - private connected = false; + connected = false; private myNodeNum: number | null = null; - // channels streamed by the device during initial sync (before config_complete_id) - private channelMap = new Map< - number, - import('@meshtastic/protobufs').Channel.Channel - >(); - // node database: NodeInfo frames from the initial sync + live POSITION_APP updates - private nodeMap = new Map(); - // device metadata + per-section config/module-config streamed during the initial sync - private metadata: Proto.Mesh.DeviceMetadata | null = null; - private configSections = new Map>(); - private moduleConfigSections = new Map>(); - // message history (newest kept, capped at HISTORY_LIMIT) + pending tx records awaiting their routing ACK/NACK + // initial sync (nodes/channels/telemetry/message baseline) has run — once the first status poll succeeds + private synced = false; + + // channels reported by the gateway (psk already redacted by the client) + private channelTable: MMChannel[] = []; + // node database rebuilt from the gateway's node list on every nodes poll + private nodeMap = new Map(); + // message history (newest kept, capped at HISTORY_LIMIT) + pending tx records awaiting a final delivery state private history: MessageRecord[] = []; // rx texts handed off to the opencode notifier (newest first, capped at HISTORY_LIMIT); in-memory only private forwardedHistory: ForwardedRecord[] = []; private pendingTx = new Map(); // telemetry history (newest kept, capped at HISTORY_LIMIT) private telemetryHistory: TelemetryRecord[] = []; - private reconnectTimer: NodeJS.Timeout | null = null; - /** epoch ms when the last valid frame was decoded from the bridge; reset on connect — idle watchdog baseline */ - private lastPacketAt = Date.now(); - private idleTimer: NodeJS.Timeout | null = null; - private periodicReconnectTimer: NodeJS.Timeout | null = null; - private shuttingDown = false; + // message packet ids seen in the gateway's message window; the first poll baselines it without processing + private seenMessages = new Set(); + private messageBaselineDone = false; + /** Record ids created for a queued send (gateway returned packet id 0): the only records eligible for the text+destination fallback match. */ + private pendingQueued = new Set(); + // telemetry group keys deduped across polls: (node, packetId), or (node, timestamp) when the row has no packet id + private seenTelemetry = new Set(); + + constructor( + db?: DatabaseSync, + opencode?: OpencodeService, + client?: MeshMonitorClient | null, + ) { + this.db = db ?? null; + this.opencode = opencode ?? null; + this.client = client ?? null; + // display-only host/port for status(): parsed from the gateway base URL (default port per scheme when none explicit) + let u: URL | null = null; + try { + u = new URL(this.gatewayBase); + } catch { + // malformed base URL — report it as-is + } + this.host = u?.hostname ?? this.gatewayBase; + this.port = u + ? u.port + ? Number(u.port) + : u.protocol === 'http:' + ? 80 + : 443 + : 0; + } + + /** Wire the `#command` auto-responder after construction — used by command.module.ts to break the DI cycle (a constructor-level MeshService <-> CommandService cycle leaves one side null in NestJS's injector). */ + attachCommands(commands: CommandService): void { + this.commands = commands; + } onModuleInit(): void { if (this.db) { @@ -285,29 +283,35 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { // ponytail: full re-upsert of the ≤100-row buffer every tick — trivial load, zero bookkeeping this.pollTimer = setInterval(() => this.flushHistory(), this.pollMs); } - // idle watchdog: catches established-but-silent connections (zombie bridge) that the connect/retry loop never sees - this.idleTimer = setInterval(() => this.checkIdle(), IDLE_CHECK_MS); - // lazy periodic autoreconnect: unconditional fresh sync on a fixed schedule; when not connected, destroy is a no-op and the retry loop owns recovery - this.periodicReconnectTimer = setInterval(() => { - this.logger.log( - 'periodic reconnect — MESH_RECONNECT_INTERVAL_MS elapsed', - ); - this.socket?.destroy(); - }, this.reconnectIntervalMs); - this.connect(); + if (this.client) { + // status first: the initial sync (nodes + channels + telemetry + message baseline) runs on its first success, + // which is also when the other poll loops start + this.scheduleStatus(0); + } } onModuleDestroy(): void { this.shuttingDown = true; - if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + if (this.statusTimer) clearTimeout(this.statusTimer); if (this.pollTimer) clearInterval(this.pollTimer); - if (this.idleTimer) clearInterval(this.idleTimer); - if (this.periodicReconnectTimer) clearInterval(this.periodicReconnectTimer); - this.socket?.destroy(); + if (this.messageTimer) clearInterval(this.messageTimer); + if (this.nodeTimer) clearInterval(this.nodeTimer); + if (this.channelTimer) clearInterval(this.channelTimer); + if (this.telemetryTimer) clearInterval(this.telemetryTimer); + this.statusTimer = null; + this.pollTimer = null; + this.messageTimer = null; + this.nodeTimer = null; + this.channelTimer = null; + this.telemetryTimer = null; this.db?.close(); } - /** Bridge connection state: `{ connected, myNodeNum, host, port }`. */ + /** + * Gateway connection state: `{ connected, myNodeNum, host, port }`. `connected` is the + * gateway's view of the physical device; `host`/`port` identify the MeshMonitor gateway + * (parsed from MESHMONITOR_API_BASE). + */ status() { return { connected: this.connected, @@ -317,31 +321,28 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { }; } - // psk/keys are deliberately not included — never expose them over HTTP - /** Device channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` sorted by index; empty until the initial sync completes. */ + // psk/keys are deliberately not included — the client already redacts them; never expose them over HTTP + /** Device channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` sorted by index; from the gateway, refreshed on the initial sync and every 5 minutes. */ channels(): { index: number; name: string; uplinkEnabled: boolean; downlinkEnabled: boolean; }[] { - return [...this.channelMap.values()] + return [...this.channelTable] .sort((a, b) => a.index - b.index) - .map(({ index, settings }) => ({ - index, - name: settings?.name ?? '', - uplinkEnabled: settings?.uplinkEnabled ?? false, - downlinkEnabled: settings?.downlinkEnabled ?? false, + .map((c) => ({ + index: c.index, + name: c.name, + uplinkEnabled: c.uplinkEnabled ?? false, + downlinkEnabled: c.downlinkEnabled ?? false, })); } /** - * Device info captured during the initial sync: this device's node number, firmware version and - * metadata (DeviceMetadata as-is — it carries no key material), plus per-section config and - * module-config messages keyed by section name. In-memory only; empty until the sync completes, - * reset on every bridge reconnect. Key material is redacted: security publicKey/privateKey/adminKey - * and network wifiPsk are replaced with "REDACTED" — never exposed over HTTP. Proto uint64 fields - * (e.g. power.powermonEnables) come back as decimal strings so the output always JSON-serializes. + * Device info: 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. In-memory only. */ deviceInfo(): DeviceInfo { const out: DeviceInfo = { @@ -349,20 +350,16 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { localConfig: {}, moduleConfig: {}, }; - if (this.metadata) { - out.firmwareVersion = this.metadata.firmwareVersion; - out.metadata = jsonSafe(this.metadata); + if (this.myNodeNum != null) { + const own = this.nodeMap.get(this.myNodeNum); + if (own?.firmwareVersion) out.firmwareVersion = own.firmwareVersion; } - for (const [name, value] of this.configSections) - out.localConfig[name] = jsonSafe(redactSection(name, value)); - for (const [name, value] of this.moduleConfigSections) - out.moduleConfig[name] = jsonSafe(value); return out; } /** - * Lean summaries of all known nodes, sorted by node number. - * Names/hwModel/batteryLevel/snr/hopsAway/viaMqtt appear only when the device reported them; + * Lean summaries of all known nodes, sorted by node number. From the gateway's node list, + * refreshed every 30 s. Names/hwModel/batteryLevel appear only when the gateway reports them; * `lastHeard` is epoch seconds (null = never heard); `position` only when both coordinates are known. */ async nodes(): Promise { @@ -390,17 +387,14 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { // exact name match wins over substring; a string always contains itself, so an ambiguous exact set can never become unique below const exact = all.filter( (n) => - n.user && - (n.user.longName.toLowerCase() === q || - n.user.shortName.toLowerCase() === q), + n.longName?.toLowerCase() === q || n.shortName?.toLowerCase() === q, ); if (exact.length === 1) return this.summarize(exact[0], names); - const sub = all.filter( - (n) => - n.user && - (n.user.longName.toLowerCase().includes(q) || - n.user.shortName.toLowerCase().includes(q)), - ); + const sub = all.filter((n) => { + const l = n.longName?.toLowerCase(); + const s = n.shortName?.toLowerCase(); + return (l != null && l.includes(q)) || (s != null && s.includes(q)); + }); if (sub.length === 1) return this.summarize(sub[0], names); return null; } @@ -434,11 +428,11 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { } /** - * Message history, newest first: inbound TEXT_MESSAGE_APP packets (dir 'rx') and our sends (dir 'tx'). - * tx records start 'pending' and move to 'delivered'/'failed' when the routing ACK/NACK arrives; - * broadcasts stay 'pending' because firmware strips want_ack before TX. Backed by SQLite: the buffer is + * Message history, newest first: inbound text (dir 'rx') and our sends (dir 'tx'). + * tx records start 'pending' and move to 'delivered'/'failed' when the gateway reports a final + * delivery state; broadcasts stay 'pending' (they have no acks). Backed by SQLite: the buffer is * seeded from the database on startup (newest 100) and re-upserted every MESH_POLL_MS, so history - * survives process restarts and bridge reconnects; the DB keeps the newest 1000 rows. + * survives process restarts; the DB keeps the newest 1000 rows. */ messages(): MessageRecord[] { return [...this.history].reverse(); @@ -511,7 +505,7 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { * broadcast originals (and injected messages without a sender) get a broadcast. The reply always * rides the record's channel and carries replyId = messageId, so the recipient app renders it as a * threaded quote of that packet. Returns { id } with the new packet id. Throws BadRequest on invalid - * arguments, NotFound when no message has the given id (in memory or in SQLite), 503 when the bridge is down. + * arguments, NotFound when no message has the given id (in memory or in SQLite), 503 when the gateway is down. */ async respond(messageId: number, content: string): Promise<{ id: number }> { if ( @@ -540,12 +534,12 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { /** * Inject a synthetic inbound (rx) record into the message history — as if received on the mesh. - * NOT transmitted over RF: nothing is written to the bridge or air; the record exists so external - * systems can push messages into the bot's inbox to trigger communication later. `from` is optional - * (integer in 1..0xffffffff when provided) and omitted from the record when absent. The record rides - * channel 0 like a primary-channel capture and appears in messages() newest first, subject to the - * same HISTORY_LIMIT cap as real captures. Returns { id } with a random uint32 packet id (same - * generation as tx sends). Throws on empty text or an invalid `from`. + * NOT transmitted over the mesh: nothing is sent to the gateway or air; the record exists so + * external systems can push messages into the bot's inbox to trigger communication later. `from` + * is optional (integer in 1..0xffffffff when provided) and omitted from the record when absent. + * The record rides channel 0 like a primary-channel capture and appears in messages() newest first, + * subject to the same HISTORY_LIMIT cap as real captures. Returns { id } with a random uint32 + * packet id (same generation as tx sends). Throws on empty text or an invalid `from`. */ injectMessage(text: string, from?: number): { id: number } { if (typeof text !== 'string' || text.length === 0) @@ -599,38 +593,85 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { } /** - * Telemetry records, newest first: TELEMETRY_APP packets from any node — device metrics - * (battery/voltage/uptime/channel+air utilization) and environment sensor readings where nodes have them. - * `kind` is the meshtastic.Telemetry oneof case name; `data` is that variant's payload with proto uint64 - * fields as decimal strings, unset optional fields omitted. In-memory only, last 100, reset on reconnect. + * Telemetry records, newest first: the gateway's long-format feed (one row per metric) grouped + * back into logical packets — one record per (node, packetId) group, so a multi-metric reading + * arrives as a single entry. `kind` is derived from the metric set (any environment-sensor metric + * → "environmentMetrics", else "deviceMetrics"); `data` maps each metric name to its + * `{ value, unit? }`. In-memory only, last 100, reset on restart. */ telemetry(): TelemetryRecord[] { return [...this.telemetryHistory].reverse(); } - /** Send `text` to node `to`; returns the packet id. Rides the pinned test channel unless an explicit `channel` index is given (e.g. battery alerts on "testowy"). Optional `replyId` marks the message as a reply to an earlier packet (the recipient app renders it as a threaded quote). Throws ServiceUnavailable when the bridge is down. */ + /** + * Send `text` to node `to`; returns the packet id. Broadcasts go to the pinned test channel + * (index 3) unless an explicit `channel` index is given (e.g. battery alerts on "testowy"); + * a specific node is a direct message (the gateway DM, no channel). Optional `replyId` marks the + * message as a reply to an earlier packet (the recipient app renders it as a threaded quote). + * Throws ServiceUnavailable when the gateway/device is down, and when the gateway rejects the + * send (recorded as a failed tx in the history); a 413 (message too long) becomes a BadRequest. + */ async sendText( to: number, text: string, replyId?: number, channel?: number, ): Promise { - if (!this.connected || !this.socket) + if (!this.client || !this.connected) throw new ServiceUnavailableException('bridge not connected'); const ch = channel ?? this.sendChannel; - const { bytes, id } = await buildTextToRadio(to, text, ch, replyId); + const isBroadcast = to === BROADCAST_ADDR; + let result: { packetId: number; deliveryState: string }; + try { + result = isBroadcast + ? await this.client.send({ text, channel: ch, replyId }) + : await this.client.send({ text, toNodeId: nodeNumToId(to), replyId }); + } catch (err) { + // a rejected send is recorded as a failed tx (visible in the history) and rethrown — the caller surfaces it + this.logger.warn(`send failed: ${(err as Error).message}`); + const rec: MessageRecord = { + id: randomPacketId(), + dir: 'tx', + to, + text, + channel: ch, + at: Date.now(), + status: 'failed', + ackError: (err as Error).message, + }; + this.pushHistory(rec); + if (err instanceof MeshMonitorError && err.status === 413) + throw new BadRequestException(err.message); + throw err instanceof Error + ? new ServiceUnavailableException(err.message) + : new ServiceUnavailableException(String(err)); + } + // the gateway assigns the packet id; it can be 0 for a queued/auto-split send — use a generated id until the message poll resolves the record + const id = result.packetId || randomPacketId(); + // a direct message rides the primary channel in mesh terms (the gateway reports it as -1) — the pinned send channel applies to broadcasts only const rec: MessageRecord = { id, dir: 'tx', to, text, - channel: ch, + channel: isBroadcast ? ch : 0, at: Date.now(), status: 'pending', }; + if ( + result.deliveryState === 'delivered' || + result.deliveryState === 'confirmed' + ) + rec.status = 'delivered'; + else if (result.deliveryState === 'failed') { + rec.status = 'failed'; + rec.ackError = 'delivery failed (gateway)'; + } this.pushHistory(rec); - this.pendingTx.set(id, rec); - this.socket.write(bytes); + if (rec.status === 'pending') { + this.pendingTx.set(id, rec); + if (!result.packetId) this.pendingQueued.add(id); + } return id; } @@ -647,253 +688,258 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { this.telemetryHistory.shift(); } - private summarize(n: NodeEntry, names: Map): NodeSummary { - const s: NodeSummary = { num: n.num, lastHeard: n.lastHeard || null }; - if (n.user?.longName) s.longName = n.user.longName; - if (n.user?.shortName) s.shortName = n.user.shortName; + private summarize(n: MMNode, names: Map): NodeSummary { + const s: NodeSummary = { num: n.num, lastHeard: n.lastHeard ?? null }; + if (n.longName) s.longName = n.longName; + if (n.shortName) s.shortName = n.shortName; // omit UNSET (enum value 0) rather than report "UNSET" - const hwName = n.user?.hwModel ? names.get(n.user.hwModel) : undefined; + const hwName = n.hwModel != null ? names.get(n.hwModel) : undefined; if (hwName && hwName !== 'UNSET') s.hwModel = hwName; - // optional proto fields: undefined when the device never reported them - const battery = n.deviceMetrics?.batteryLevel; - if (battery !== undefined) s.batteryLevel = battery; - // non-optional proto scalars decode with their defaults (snr=0, viaMqtt=false), so a default value means "not reported" + if (n.batteryLevel !== undefined) s.batteryLevel = n.batteryLevel; + // a reported snr of 0 is the "not measured" default — keep the old summary convention if (n.snr !== undefined && n.snr !== 0) s.snr = n.snr; - if (n.hopsAway !== undefined) s.hopsAway = n.hopsAway; // optional in proto → truly absent when not reported + if (n.hopsAway !== undefined) s.hopsAway = n.hopsAway; if (n.viaMqtt === true) s.viaMqtt = true; - const p = n.position; - if (p?.latitudeI !== undefined && p.longitudeI !== undefined) { - // alt included only when the device sent it — protobuf-es leaves absent optionals undefined, so an explicit 0 (sea level) is kept - s.position = { lat: p.latitudeI * 1e-7, lon: p.longitudeI * 1e-7 }; - if (p.altitude !== undefined) s.position.alt = p.altitude; - } + // flat gateway coordinates (present only when both are known) + if (n.position) s.position = n.position; return s; } - private connect(): void { - if (this.shuttingDown) return; - const socket = net.createConnection({ host: this.host, port: this.port }); - // TCP-level keepalive: cheap insurance against a dead peer that the idle watchdog (application level) complements - socket.setKeepAlive(true, 30_000); - this.socket = socket; - socket.on('connect', () => { - this.connected = true; - // fresh connection — reset the idle clock so it can't self-trigger before any traffic arrives - this.lastPacketAt = Date.now(); - // fresh sync on every (re)connect — drop stale channels/nodes/device info/telemetry from the old session; - // message history is NOT reset: it's an append-only log backed by SQLite and survives reconnects. - // The undecoded frame buffer goes too: leftover desync garbage must not re-trigger the overflow backstop on a healthy new stream. - this.buffer = new Uint8Array(0); - this.channelMap = new Map(); - this.nodeMap = new Map(); - this.metadata = null; - this.configSections.clear(); - this.moduleConfigSections.clear(); - this.telemetryHistory = []; - this.pendingTx.clear(); - this.logger.log(`bridge connected ${this.host}:${this.port}`); - const nonce = Math.floor(Math.random() * 0xffffffff) || 1; - buildWantConfigToRadio(nonce) - .then((bytes) => socket.write(frame(bytes))) - .catch((err: unknown) => - this.logger.error(`want_config failed: ${String(err)}`), - ); - this.logger.log(`requested initial sync (want_config_id=${nonce})`); - }); - socket.on('data', (chunk) => { - const merged = new Uint8Array(this.buffer.length + chunk.length); - merged.set(this.buffer, 0); - merged.set(chunk, this.buffer.length); - // buffer-overflow backstop: a desynced stream that never forms frames would grow the buffer without bound — cap it and self-heal via reconnect - if (merged.length > this.maxFrameBuffer) { - this.logger.error( - 'frame buffer overflow — stream desync, forcing reconnect', - ); - socket.destroy(); - return; - } - this.buffer = merged; - const { packets, rest } = extractFrames(this.buffer); - this.buffer = rest; - // the watchdog tracks DECODED FRAMES, not raw bytes: non-frame noise (keepalives, desynced streams) must not keep a dead connection alive - if (packets.length) this.lastPacketAt = Date.now(); - for (const p of packets) { - decodeFromRadio(p) - .then((fr) => this.handleFromRadio(fr)) - .catch((err: unknown) => - this.logger.error(`decode failed: ${String(err)}`), - ); - } - }); - const onDown = () => { - this.connected = false; - this.scheduleReconnect(); - }; - socket.on('error', (err) => { - this.logger.warn(`bridge error: ${err.message}`); - onDown(); - }); - socket.on('close', onDown); - } + // --------------------------------------------------------------------------- + // Gateway poll loops + // --------------------------------------------------------------------------- - private scheduleReconnect(): void { + private scheduleStatus(delay: number): void { if (this.shuttingDown) return; - if (this.reconnectTimer) clearTimeout(this.reconnectTimer); - this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectMs); + if (this.statusTimer) clearTimeout(this.statusTimer); + this.statusTimer = setTimeout(() => void this.pollStatus(), delay); } /** - * Idle watchdog tick: force a reconnect when an ESTABLISHED connection has decoded no frames from - * the bridge for MESH_IDLE_TIMEOUT_MS — the zombie case where outbound sends keep working but inbound - * frame flow is dead (raw non-frame bytes arriving on the socket do not count). Destroying the socket - * routes through 'close' → onDown → scheduleReconnect, so the existing connect/retry loop re-establishes - * with a fresh sync; no new retry machinery here. Public so tests can drive a tick without waiting for - * the real interval (like flushHistory()). + * Status poll: the gateway's view of the source (device) health. On the FIRST success it runs + * the initial sync (nodes + channels + telemetry + the message-window baseline) and starts the + * other poll loops. On failure it reports disconnected and retries on the shorter backoff. + * Public so tests can drive a tick without waiting for the real timer (like flushHistory()). */ - checkIdle(): void { - if (!this.connected) return; // disconnected state is owned by the connect/retry loop - const idle = Date.now() - this.lastPacketAt; - if (idle > this.idleTimeoutMs) { - this.logger.warn( - `no decoded bridge frames for ${idle} ms — forcing reconnect`, - ); - this.socket?.destroy(); + async pollStatus(): Promise { + if (!this.client) return; + try { + const s = await this.client.getStatus(); + this.connected = s.connected; + if (s.myNodeNum) this.myNodeNum = s.myNodeNum; + if (!this.synced) { + this.synced = true; // set before any await — a concurrent call skips the sync + await this.pollNodes(); + await this.pollChannels(); + await this.pollTelemetry(); + await this.pollMessages(); // baseline the message window + this.startLoops(); + this.logger.log( + `meshmonitor connected: node ${s.myNodeNum ?? '?'}${s.longName ? ` (${s.longName})` : ''}`, + ); + } + this.scheduleStatus(STATUS_POLL_MS); + } catch (err) { + this.connected = false; + this.logger.warn(`status poll failed: ${(err as Error).message}`); + this.scheduleStatus(STATUS_RETRY_MS); } } - private async handleFromRadio( - fr: import('@meshtastic/protobufs').Mesh.FromRadio, - ): Promise { - const v = fr.payloadVariant; - switch (v?.case) { - case 'myInfo': - this.myNodeNum = v.value.myNodeNum; - this.logger.log(`my node num: ${this.myNodeNum}`); - break; - case 'channel': - // device streams one Channel per table slot during initial sync - this.channelMap.set(v.value.index, v.value); - break; - case 'nodeInfo': - // device streams the whole node DB during initial sync - this.nodeMap.set(v.value.num, v.value); - break; - case 'metadata': - // DeviceMetadata streamed once during initial sync (no key material in it) - this.metadata = v.value; - break; - case 'config': { - // device streams one Config per section during initial sync - const sec = v.value.payloadVariant; - if (sec?.case) this.configSections.set(sec.case, sec.value); - break; + /** Start the steady-state poll loops once (idempotent — called from the initial sync). */ + private startLoops(): void { + if (this.messageTimer) return; + this.messageTimer = setInterval( + () => void this.pollMessages(), + MESSAGE_POLL_MS, + ); + this.nodeTimer = setInterval(() => void this.pollNodes(), NODE_POLL_MS); + this.channelTimer = setInterval( + () => void this.pollChannels(), + CHANNEL_POLL_MS, + ); + this.telemetryTimer = setInterval( + () => void this.pollTelemetry(), + TELEMETRY_POLL_MS, + ); + } + + /** + * Nodes poll: rebuild the node database from the gateway's node list (names, battery, + * position, firmware, lastHeard). Public so tests can drive a tick. + */ + async pollNodes(): Promise { + if (!this.client) return; + try { + const nodes = await this.client.getNodes(); + this.nodeMap = new Map(nodes.map((n) => [n.num, n])); + } catch (err) { + this.logger.warn(`nodes poll failed: ${(err as Error).message}`); + } + } + + /** + * Channels poll: refresh the channel table from the gateway (psk already redacted). + * Public so tests can drive a tick. + */ + async pollChannels(): Promise { + if (!this.client) return; + try { + this.channelTable = await this.client.getChannels(); + } catch (err) { + this.logger.warn(`channels poll failed: ${(err as Error).message}`); + } + } + + /** + * Messages poll: fetch the gateway's recent message window (newest first) and process what is + * new. The first call only BASELINES the seen-set — the window holds stale history, and the + * baseline is what keeps a restart from reprocessing it (plus a SQLite dedupe as backstop). + * Afterwards: outbound records (a requestId is present) resolve the matching pending tx from + * its delivery state; inbound text (portnum 1, no requestId) becomes an rx history record and + * passes through the notify/command gates. Public so tests can drive a tick. + */ + async pollMessages(): Promise { + if (!this.client) return; + let msgs: MMMessage[]; + try { + msgs = await this.client.getMessages({ limit: MESSAGE_POLL_LIMIT }); + } catch (err) { + this.logger.warn(`messages poll failed: ${(err as Error).message}`); + return; + } + const baseline = !this.messageBaselineDone; + this.messageBaselineDone = true; + // The gateway returns newest first; the history buffer keeps oldest first (messages() reverses it) — + // so process the window oldest first. Outbound entries are deliberately NOT seen-set deduped: + // the gateway re-reports the same packet id with an updated deliveryState, and re-reading them is + // exactly how pending tx records resolve (pendingTx membership is their idempotence key). + for (const m of [...msgs].reverse()) { + if (m.requestId == null) { + if (this.seenMessages.has(m.packetId)) continue; + this.seenMessages.add(m.packetId); } - case 'moduleConfig': { - const mod = v.value.payloadVariant; - if (mod?.case) this.moduleConfigSections.set(mod.case, mod.value); - break; - } - case 'configCompleteId': - this.logger.log( - `bridge ready (config complete), ${this.channelMap.size} channels, ${this.nodeMap.size} nodes`, - ); - break; - case 'packet': { - const pkt = v.value; - const decoded = - pkt.payloadVariant?.case === 'decoded' - ? pkt.payloadVariant.value - : undefined; - if (decoded && decoded.portnum === PORT_TEXT_MESSAGE_APP) { - const text = new TextDecoder().decode(decoded.payload); - this.logger.log(`[mesh] ${pkt.from} -> ${pkt.to}: "${text}"`); - this.pushHistory({ - id: pkt.id, - dir: 'rx', - from: pkt.from, - to: pkt.to, // destination (broadcast addr for broadcasts) — lets a later reply tell DM vs broadcast apart - text, - channel: pkt.channel ?? 0, - at: Date.now(), - }); - this.notifyRx(pkt.channel ?? 0, text, pkt.from, pkt.id, pkt.to); - // event-driven #command auto-responder (COMMAND_CHANNEL) — fire-and-forget like the notifier - void this.commands?.onRx(pkt.channel ?? 0, text, pkt.from, pkt.id); - } else if (decoded && decoded.portnum === PORT_POSITION_APP) { - // live position update: the Position is serialized in Data.payload - let pos: Proto.Mesh.Position | undefined; - try { - pos = await decodePosition(decoded.payload); - } catch { - pos = undefined; // malformed payload — skip, keep whatever we had - } - if (pos?.latitudeI !== undefined && pos.longitudeI !== undefined) { - const num = pkt.from; - this.nodeMap.set(num, { - ...this.nodeMap.get(num), - num, - position: pos, - }); - } - } else if (decoded && decoded.portnum === PORT_NODEINFO_APP) { - // live node-info update: NodeInfo serialized in Data.payload; deviceMetrics carries - // battery_level so /api/nodes and the battery alerts see fresh values between syncs - let ni: Proto.Mesh.NodeInfo | undefined; - try { - ni = await decodeNodeInfo(decoded.payload); - } catch { - ni = undefined; // malformed payload — skip, keep whatever we had - } - if (ni?.deviceMetrics !== undefined) { - const num = pkt.from; - this.nodeMap.set(num, { - ...this.nodeMap.get(num), - num, - deviceMetrics: ni.deviceMetrics, - }); - } - } else if (decoded && decoded.portnum === PORT_ROUTING_APP) { - // ACK/NACK for our want_ack sends: Routing serialized in Data.payload, - // Data.requestId = the original packet id we sent - let routing: Proto.Mesh.Routing | undefined; - try { - routing = await decodeRouting(decoded.payload); - } catch { - routing = undefined; // malformed payload — skip - } - if (routing?.variant.case === 'errorReason' && decoded.requestId) { - const rec = this.pendingTx.get(decoded.requestId); - if (rec) { - const reason = routing.variant.value; - if (reason === 0) - rec.status = 'delivered'; // Routing_Error.NONE - else { - rec.status = 'failed'; - rec.ackError = await describeRoutingError(reason); - } - this.pendingTx.delete(decoded.requestId); - } - } - } else if (decoded && decoded.portnum === PORT_TELEMETRY_APP) { - // live telemetry: the Telemetry is serialized in Data.payload - let tel: Proto.Telemetry.Telemetry | undefined; - try { - tel = await decodeTelemetry(decoded.payload); - } catch { - tel = undefined; // malformed payload — skip - } - const variant = tel?.variant; - if (variant?.case) { - this.pushTelemetry({ - from: pkt.from, - at: Date.now(), - kind: variant.case, - data: jsonSafe(variant.value), - }); - } + if (baseline) continue; // first poll: baseline only, no processing + this.processMessage(m); + } + // evicted history records can no longer be resolved — drop their pending entries + if (this.pendingTx.size > 0) + for (const id of [...this.pendingTx.keys()]) + if (!this.history.some((r) => r.id === id)) { + this.pendingTx.delete(id); + this.pendingQueued.delete(id); } - break; + } + + private processMessage(m: MMMessage): void { + if (m.requestId != null) { + // outbound — resolve the matching pending tx from the delivery state. + // Normal sends: the record id equals the gateway's packet id (exact match, no ambiguity). + // Queued sends (gateway assigned id 0 at send time, so the record carries a generated id): + // match by text + destination, but only among records still awaiting an id — otherwise a + // stale delivered entry could falsely resolve a NEWER pending send of the same text. + const rec = + this.pendingTx.get(m.packetId) ?? + [...this.pendingTx.values()].find( + (r) => + this.pendingQueued.has(r.id) && + r.text === m.text && + r.to === messageDest(m), + ); + if (!rec) return; + if (m.deliveryState === 'delivered' || m.deliveryState === 'confirmed') + rec.status = 'delivered'; + else if (m.deliveryState === 'failed') { + rec.status = 'failed'; + rec.ackError = 'delivery failed (gateway)'; + } else { + return; // still pending — nothing to update yet } + this.pendingTx.delete(rec.id); + this.pendingQueued.delete(rec.id); + return; + } + if (m.portnum !== PORT_TEXT_MESSAGE_APP) return; // not a text message — nothing to record + // dedupe against the persisted history: a packet id already captured as an rx record (in + // memory or in SQLite from a previous run) is not processed again + const existing = this.findRecord(m.packetId); + if (existing?.dir === 'rx') return; + const from = + m.fromNum ?? + (m.fromId ? (nodeIdToNum(m.fromId) ?? undefined) : undefined); + const to = messageDest(m); + // a direct message rides the primary channel in mesh terms — the gateway reports it as channel -1 + const channel = m.channel == null ? 0 : m.channel === -1 ? 0 : m.channel; + const at = m.at > 0 ? m.at : Date.now(); + const rec: MessageRecord = { + id: m.packetId, + dir: 'rx', + ...(from !== undefined ? { from } : {}), + ...(to !== undefined ? { to } : {}), + text: m.text, + channel, + at, + }; + this.pushHistory(rec); + this.logger.log( + `[mesh] ${from ?? 'unknown'} -> ${to === BROADCAST_ADDR ? 'broadcast' : (to ?? '?')}: "${m.text}"`, + ); + this.notifyRx(channel, m.text, from, m.packetId, to); + // event-driven #command auto-responder (COMMAND_CHANNEL) — fire-and-forget like the notifier + void this.commands?.onRx(channel, m.text, from, m.packetId); + } + + /** + * Telemetry poll: the gateway serves recent telemetry in LONG format — one row per metric + * (telemetryType/value/unit), sometimes without a packet id. Rows sharing a (node, packetId) + * belong to one logical telemetry packet and are grouped into a single record (kind derived + * from the metric set, data = metric name → { value, unit? }); rows without a packet id are + * grouped by (node, timestamp) so a repeated window never duplicates them. + * Public so tests can drive a tick. + */ + async pollTelemetry(): Promise { + if (!this.client) return; + let rows: import('./meshmonitor/mesh-monitor-client.js').MMTelemetry[]; + try { + rows = await this.client.getTelemetry(); + } catch (err) { + this.logger.warn(`telemetry poll failed: ${(err as Error).message}`); + return; + } + const groups = new Map< + string, + { from: number; at: number; metrics: Record } + >(); + for (const r of rows) { + const row = r.data as { packetId?: unknown } | undefined; + const pid = row && typeof row.packetId === 'number' ? row.packetId : null; + const key = + pid != null ? `p:${r.fromNum}:${pid}` : `t:${r.fromNum}:${r.at}`; + let g = groups.get(key); + if (!g) { + g = { from: r.fromNum, at: r.at, metrics: {} }; + groups.set(key, g); + } + if (r.kind) g.metrics[r.kind] = cleanMetricRow(r.data); + } + for (const [key, g] of groups) { + if (this.seenTelemetry.has(key)) continue; + this.seenTelemetry.add(key); + const isEnv = Object.keys(g.metrics).some((m) => ENV_METRICS.has(m)); + this.pushTelemetry({ + from: g.from, + at: g.at, + kind: isEnv ? 'environmentMetrics' : 'deviceMetrics', + data: g.metrics, + }); } } } + +/** One telemetry row's payload, stripped of the transport fields (packet id, channel, rx snr, gps accuracy). */ +function cleanMetricRow(d: object): { value: unknown; unit?: string } { + const o = d as Record; + const out: { value: unknown; unit?: string } = { value: o.value }; + if (typeof o.unit === 'string' && o.unit !== '') out.unit = o.unit; + return out; +} diff --git a/src/mesh/meshmonitor/fake-mesh-monitor-client.spec.ts b/src/mesh/meshmonitor/fake-mesh-monitor-client.spec.ts new file mode 100644 index 0000000..c941a86 --- /dev/null +++ b/src/mesh/meshmonitor/fake-mesh-monitor-client.spec.ts @@ -0,0 +1,298 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { FakeMeshMonitorClient } from './fake-mesh-monitor-client.js'; +import { BROADCAST_ADDR } from './mesh-monitor-client.js'; + +const BOT_NUM = 3994646728; // !ee1978c8 +const KAMIL_NUM = 2927200947; // !ae798ab3 + +describe('FakeMeshMonitorClient', () => { + let fake: FakeMeshMonitorClient; + + beforeEach(() => { + fake = new FakeMeshMonitorClient({ nodeNum: BOT_NUM }); + }); + + // ------------------------------------------------------------------ + // status + // ------------------------------------------------------------------ + + describe('getStatus', () => { + it('returns a default status until configured', async () => { + expect(await new FakeMeshMonitorClient().getStatus()).toEqual({ + connected: true, + myNodeNum: 0, + }); + }); + + it('returns the configured status (defensively copied)', async () => { + fake.setStatus({ connected: false, myNodeNum: BOT_NUM, longName: 'Bot' }); + const s = await fake.getStatus(); + s.connected = true; // mutate the returned copy + expect((await fake.getStatus()).connected).toBe(false); + }); + }); + + // ------------------------------------------------------------------ + // nodes + // ------------------------------------------------------------------ + + describe('getNodes / getNode', () => { + beforeEach(() => { + fake.setNodes([ + { + num: BOT_NUM, + longName: 'SP8MK Bot', + shortName: '8MKb', + position: { lat: 51, lon: 22 }, + }, + { num: KAMIL_NUM, longName: 'Kamil', shortName: 'KK' }, + ]); + }); + + it('returns all configured nodes', async () => { + expect(await fake.getNodes()).toHaveLength(2); + }); + + it('finds a node by number', async () => { + expect((await fake.getNode(KAMIL_NUM))?.longName).toBe('Kamil'); + }); + + it('finds a node by !hex id', async () => { + expect((await fake.getNode('!ae798ab3'))?.longName).toBe('Kamil'); + expect((await fake.getNode('!ee1978c8'))?.longName).toBe('SP8MK Bot'); + }); + + it('finds a node by numeric string', async () => { + expect((await fake.getNode(String(KAMIL_NUM)))?.longName).toBe('Kamil'); + }); + + it('finds a node by name (case-insensitive)', async () => { + expect((await fake.getNode('kamil'))?.num).toBe(KAMIL_NUM); + expect((await fake.getNode('8mkB'))?.num).toBe(BOT_NUM); + }); + + it('returns null for an unknown node', async () => { + expect(await fake.getNode(999)).toBeNull(); + expect(await fake.getNode('!deadbeef')).toBeNull(); + expect(await fake.getNode('nobody')).toBeNull(); + }); + }); + + 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) + // ------------------------------------------------------------------ + + describe('getChannels / getTelemetry', () => { + it('returns configured channels and telemetry', async () => { + fake.setChannels([ + { index: 0, name: '', psk: 'REDACTED' }, + { index: 3, name: 'test' }, + ]); + fake.setTelemetry([ + { fromNum: BOT_NUM, at: 1, kind: 'airUtilTx', data: { value: 0.1 } }, + ]); + expect(await fake.getChannels()).toHaveLength(2); + expect((await fake.getTelemetry())[0].kind).toBe('airUtilTx'); + }); + + it('returns empty arrays by default', async () => { + expect(await fake.getChannels()).toEqual([]); + expect(await fake.getTelemetry()).toEqual([]); + }); + }); + + // ------------------------------------------------------------------ + // pushInbound + getMessages + // ------------------------------------------------------------------ + + describe('getMessages', () => { + it('returns inbound messages newest first', async () => { + fake.pushInbound({ packetId: 1, text: 'first', channel: 0, at: 100 }); + fake.pushInbound({ packetId: 2, text: 'second', channel: 0, at: 200 }); + const msgs = await fake.getMessages(); + expect(msgs.map((m) => m.text)).toEqual(['second', 'first']); + }); + + it('filters by channel', async () => { + fake.pushInbound({ packetId: 1, text: 'a', channel: 0, at: 1 }); + fake.pushInbound({ packetId: 2, text: 'b', channel: 3, at: 2 }); + const msgs = await fake.getMessages({ channel: 3 }); + expect(msgs.map((m) => m.text)).toEqual(['b']); + }); + + it('filters by toNodeId and fromNodeId', async () => { + fake.pushInbound({ + packetId: 1, + fromId: '!ee1978c8', + toId: '!ffffffff', + text: 'bcast', + channel: 3, + at: 1, + }); + fake.pushInbound({ + packetId: 2, + fromId: '!ae798ab3', + toId: '!ee1978c8', + text: 'dm', + channel: -1, + at: 2, + }); + expect( + (await fake.getMessages({ toNodeId: '!ffffffff' })).map((m) => m.text), + ).toEqual(['bcast']); + expect( + (await fake.getMessages({ fromNodeId: '!ae798ab3' })).map( + (m) => m.text, + ), + ).toEqual(['dm']); + }); + + it('applies limit (newest N) after filtering', async () => { + fake.pushInbound({ packetId: 1, text: 'a', channel: 0, at: 1 }); + fake.pushInbound({ packetId: 2, text: 'b', channel: 0, at: 2 }); + fake.pushInbound({ packetId: 3, text: 'c', channel: 3, at: 3 }); + // newest-first: c, b, a — limit 2 → c, b + expect((await fake.getMessages({ limit: 2 })).map((m) => m.text)).toEqual( + ['c', 'b'], + ); + // limit + channel: only c is on ch3 + expect( + (await fake.getMessages({ limit: 2, channel: 3 })).map((m) => m.text), + ).toEqual(['c']); + }); + + it('returns a defensive copy (mutating the result does not leak)', async () => { + fake.pushInbound({ packetId: 1, text: 'x', channel: 0, at: 1 }); + const m = (await fake.getMessages())[0]; + m.text = 'mutated'; + expect((await fake.getMessages())[0].text).toBe('x'); + }); + }); + + // ------------------------------------------------------------------ + // send + // ------------------------------------------------------------------ + + describe('send', () => { + it('records a directed send and mirrors it as an outbound message', async () => { + const out = await fake.send({ text: 'hi kamil', toNodeId: '!ae798ab3' }); + expect(out.deliveryState).toBe('pending'); + expect(out.packetId).toBeGreaterThan(0); + + expect(fake.sends).toHaveLength(1); + expect(fake.sends[0]).toMatchObject({ + text: 'hi kamil', + toNodeId: '!ae798ab3', + packetId: out.packetId, + }); + + const msgs = await fake.getMessages(); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toMatchObject({ + packetId: out.packetId, + fromNum: BOT_NUM, + fromId: '!ee1978c8', + toNum: KAMIL_NUM, + toId: '!ae798ab3', + text: 'hi kamil', + channel: -1, // DM rides channel -1 + requestId: out.packetId, + deliveryState: 'pending', + }); + }); + + it('records a broadcast send with the broadcast destination', async () => { + const out = await fake.send({ text: 'hello mesh', channel: 3 }); + const msgs = await fake.getMessages(); + expect(msgs[0]).toMatchObject({ + toNum: BROADCAST_ADDR, + toId: '!ffffffff', + channel: 3, + text: 'hello mesh', + }); + expect(out.packetId).toBe(msgs[0].packetId); + }); + + it('assigns a distinct, non-zero packet id per send', async () => { + const a = await fake.send({ text: 'one', toNodeId: '!ae798ab3' }); + const b = await fake.send({ text: 'two', channel: 0 }); + expect(a.packetId).not.toBe(b.packetId); + expect(a.packetId).toBeGreaterThan(0); + expect(b.packetId).toBeGreaterThan(0); + }); + + it('carries replyId onto the recorded message', async () => { + await fake.send({ text: 'replying', toNodeId: '!ae798ab3', replyId: 55 }); + expect((await fake.getMessages())[0].replyId).toBe(55); + expect(fake.sends[0].replyId).toBe(55); + }); + + it('rejects XOR violations exactly like the HTTP client', async () => { + await expect( + fake.send({ text: 'x', channel: 3, toNodeId: '!1' }), + ).rejects.toThrow(/mutually exclusive/); + await expect(fake.send({ text: 'x' })).rejects.toThrow( + /required \(XOR\)/, + ); + await expect(fake.send({ text: '', channel: 3 })).rejects.toThrow( + /non-empty/, + ); + // none of the rejected sends were recorded + expect(fake.sends).toHaveLength(0); + expect(await fake.getMessages()).toHaveLength(0); + }); + + it('clearTraffic() resets sends + messages + the packet-id counter', async () => { + await fake.send({ text: 'a', channel: 0 }); + fake.pushInbound({ packetId: 1, text: 'in', channel: 0, at: 1 }); + fake.clearTraffic(); + expect(fake.sends).toHaveLength(0); + expect(await fake.getMessages()).toHaveLength(0); + // counter reset → first id is 1 again + expect((await fake.send({ text: 'b', channel: 0 })).packetId).toBe(1); + }); + }); +}); diff --git a/src/mesh/meshmonitor/fake-mesh-monitor-client.ts b/src/mesh/meshmonitor/fake-mesh-monitor-client.ts new file mode 100644 index 0000000..5533ca5 --- /dev/null +++ b/src/mesh/meshmonitor/fake-mesh-monitor-client.ts @@ -0,0 +1,250 @@ +/** + * 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), 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. + */ +import { + BROADCAST_ADDR, + assertSendTarget, + nodeIdToNum, + nodeNumToId, + type MMChannel, + type MMMessage, + type MMNode, + type MMStatus, + type MMTelemetry, + type MeshMonitorClient, +} from './mesh-monitor-client.js'; + +/** One recorded {@link FakeMeshMonitorClient.send} call (input + assigned ids). */ +export interface FakeSendRecord { + text: string; + toNodeId?: string; + channel?: number; + replyId?: number; + /** Non-zero uint32 assigned by the fake (monotonic per instance). */ + packetId: number; + deliveryState: string; + messageId?: string; +} + +/** + * A send as it would be stored by the gateway: the fake mirrors what the real + * `POST /sources/{id}/messages` returns and what a later `GET .../messages` + * would surface for an outbound packet. + */ +export interface FakeMessageInput { + text: string; + toNodeId?: string; + channel?: number; + replyId?: number; +} + +export class FakeMeshMonitorClient implements MeshMonitorClient { + private status: MMStatus; + private nodes: MMNode[] = []; + private channels: MMChannel[] = []; + private telemetry: MMTelemetry[] = []; + /** Inbound + outbound messages, newest first (matches the gateway order). */ + private messages: MMMessage[] = []; + /** The bot's own node number — used as the sender of outbound messages. */ + private readonly localNum: number; + private packetSeq = 0; + + /** Every `send()` in call order. */ + readonly sends: FakeSendRecord[] = []; + + 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, + }); + } + + // ------------------------------------------------------------------ + // Test-side configuration + // ------------------------------------------------------------------ + + setStatus(s: MMStatus): void { + this.status = { ...s }; + } + + setNodes(n: MMNode[]): void { + this.nodes = n.map((x) => ({ ...x })); + } + + setChannels(c: MMChannel[]): void { + this.channels = c.map((x) => ({ ...x })); + } + + setTelemetry(t: MMTelemetry[]): void { + 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. + */ + pushInbound(m: MMMessage): void { + this.messages.unshift({ ...m }); + } + + /** 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 + // ------------------------------------------------------------------ + + async getStatus(): Promise { + return { ...this.status }; + } + + async getNodes(): Promise { + return this.nodes.map((n) => ({ ...n })); + } + + async getNode(nodeIdOrNum: string | number): Promise { + const hit = this.nodes.find((n) => this.nodeMatches(n, nodeIdOrNum)); + return hit ? { ...hit } : null; + } + + /** 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(); + // `!hex` id + const asNum = nodeIdToNum(s); + if (asNum != null) return n.num === asNum; + // plain numeric string + if (/^[0-9]+$/.test(s)) return n.num === Number(s); + // name (case-insensitive, exact long/short — like the gateway lookup) + const lower = s.toLowerCase(); + return ( + n.longName?.toLowerCase() === lower || + n.shortName?.toLowerCase() === lower + ); + } + + async getChannels(): Promise { + return this.channels.map((c) => ({ ...c })); + } + + async getTelemetry(): Promise { + return this.telemetry.map((t) => ({ ...t })); + } + + async getMessages(opts?: { + limit?: number; + channel?: number; + toNodeId?: string; + fromNodeId?: string; + }): Promise { + let list = this.messages; + if (opts?.channel != null) + list = list.filter((m) => m.channel === opts.channel); + if (opts?.toNodeId != null) + list = list.filter((m) => m.toId === opts.toNodeId); + if (opts?.fromNodeId != null) + list = list.filter((m) => m.fromId === opts.fromNodeId); + if (opts?.limit != null) list = list.slice(0, opts.limit); + return list.map((m) => ({ ...m })); + } + + async send(msg: FakeMessageInput): Promise<{ + packetId: number; + deliveryState: string; + messageId?: string; + }> { + // identical validation to the HTTP client (shared assertSendTarget) + assertSendTarget(msg); + + this.packetSeq = (this.packetSeq + 1) >>> 0; + if (this.packetSeq === 0) this.packetSeq = 1; // keep it a non-zero uint32 + const packetId = this.packetSeq; + const deliveryState = 'pending'; + const messageId = `fake-${packetId}`; + + this.sends.push({ + text: msg.text, + toNodeId: msg.toNodeId, + channel: msg.channel, + replyId: msg.replyId, + packetId, + deliveryState, + messageId, + }); + + // Mirror the gateway: an outbound message is readable back via getMessages(). + const toNum = + msg.toNodeId != null + ? (nodeIdToNum(msg.toNodeId) ?? undefined) + : BROADCAST_ADDR; + const toId = msg.toNodeId ?? nodeNumToId(BROADCAST_ADDR); + this.messages.unshift({ + packetId, + fromNum: this.localNum, + fromId: nodeNumToId(this.localNum), + toNum, + toId, + text: msg.text, + // a channel send is a broadcast (channel >= 0); a DM rides channel -1 + channel: msg.channel ?? -1, + portnum: 1, + requestId: packetId, + replyId: msg.replyId, + at: Date.now(), + deliveryState, + }); + + return { packetId, deliveryState, messageId }; + } +} diff --git a/src/mesh/meshmonitor/mesh-monitor-client.spec.ts b/src/mesh/meshmonitor/mesh-monitor-client.spec.ts new file mode 100644 index 0000000..5b1734d --- /dev/null +++ b/src/mesh/meshmonitor/mesh-monitor-client.spec.ts @@ -0,0 +1,777 @@ +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + BROADCAST_ADDR, + HttpMeshMonitorClient, + MeshMonitorError, + createMeshMonitorClientFromEnv, + nodeIdToNum, + nodeNumToId, +} from './mesh-monitor-client.js'; + +/** Test-only bearer token — never the real one (which lives only in .env). */ +const TOKEN = 'mm_v1_test_token_not_real'; +const SOURCE = 'srctest'; +/** The gateway serves under `/api/v1`; the client's baseUrl carries it. */ +const BASE = '/api/v1'; + +interface RecordedRequest { + method: string; + url: string; + pathname: string; + query?: string; + headers: http.IncomingHttpHeaders; + body: string; +} + +interface CannedResponse { + status?: number; + body: unknown; +} + +type Routes = Record; + +interface Gateway { + baseUrl: string; + requests: RecordedRequest[]; + close: () => Promise; +} + +function splitQuery(url: string): [string, string | undefined] { + const i = url.indexOf('?'); + return i === -1 ? [url, undefined] : [url.slice(0, i), url.slice(i + 1)]; +} + +/** + * In-process `node:http` stand-in for the MeshMonitor gateway. Routes are keyed + * by `METHOD pathname` (query stripped). Every request is recorded so tests can + * assert the exact path, headers and body the client emitted. + */ +function startGateway(routes: Routes): Promise { + const requests: RecordedRequest[] = []; + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + const url = req.url ?? '/'; + const [pathname, query] = splitQuery(url); + requests.push({ + method: req.method ?? 'GET', + url, + pathname, + query, + headers: req.headers, + body, + }); + const route = routes[`${req.method} ${pathname}`]; + const status = route ? (route.status ?? 200) : 404; + res.writeHead(status, { 'content-type': 'application/json' }); + res.end( + JSON.stringify( + route + ? route.body + : { success: false, error: `no route ${req.method} ${pathname}` }, + ), + ); + }); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const port = (server.address() as AddressInfo).port; + resolve({ + baseUrl: `http://127.0.0.1:${port}${BASE}`, + requests, + close: () => + new Promise((r) => { + server.closeAllConnections?.(); + server.close(() => r()); + }), + }); + }); + }); +} + +/** Route key for a source-scoped path, e.g. route('GET', '/status'). */ +const route = (method: string, path: string): string => + `${method} ${BASE}/sources/${SOURCE}${path}`; + +let gw: Gateway | null = null; + +beforeEach(() => { + gw = null; +}); + +afterEach(async () => { + await gw?.close(); + gw = null; + for (const k of [ + 'MESHMONITOR_API_TOKEN', + 'MESHMONITOR_API_BASE', + 'MESHMONITOR_SOURCE', + 'MESHMONITOR_TIMEOUT_MS', + ]) + delete process.env[k]; +}); + +function makeClient(baseUrl: string): HttpMeshMonitorClient { + return new HttpMeshMonitorClient({ + baseUrl, + sourceId: SOURCE, + token: TOKEN, + timeoutMs: 5000, + }); +} + +// --------------------------------------------------------------------------- +// Helpers: node id <-> number, broadcast address +// --------------------------------------------------------------------------- + +describe('nodeNumToId / nodeIdToNum', () => { + it('maps a uint32 node number to an 8-char !hex id', () => { + expect(nodeNumToId(3994646728)).toBe('!ee1978c8'); + expect(nodeNumToId(2927200947)).toBe('!ae798ab3'); + expect(nodeNumToId(0)).toBe('!00000000'); + expect(nodeNumToId(4294967295)).toBe('!ffffffff'); + }); + + it('parses an !hex id back to a number (round-trip)', () => { + expect(nodeIdToNum('!ee1978c8')).toBe(3994646728); + expect(nodeIdToNum('!AE798AB3')).toBe(2927200947); // case-insensitive + expect(nodeIdToNum('!ffffffff')).toBe(4294967295); + for (const n of [1, 255, 0xdeadbeef, 4294967295]) + expect(nodeIdToNum(nodeNumToId(n))).toBe(n); + }); + + it('rejects malformed ids with null', () => { + expect(nodeIdToNum('ee1978c8')).toBeNull(); + expect(nodeIdToNum('!zzzz')).toBeNull(); + expect(nodeIdToNum('!1234567890')).toBeNull(); // > 8 hex digits + expect(nodeIdToNum('')).toBeNull(); + }); + + it('exposes the broadcast address as 0xffffffff / !ffffffff', () => { + expect(BROADCAST_ADDR).toBe(4294967295); + expect(nodeNumToId(BROADCAST_ADDR)).toBe('!ffffffff'); + }); +}); + +// --------------------------------------------------------------------------- +// getStatus +// --------------------------------------------------------------------------- + +describe('HttpMeshMonitorClient.getStatus', () => { + it('GETs the source status, sends the Bearer token, maps raw → MMStatus', async () => { + gw = await startGateway({ + [route('GET', '/status')]: { + body: { + success: true, + data: { + localNodeNum: 3994646728, + localNodeId: '!ee1978c8', + longName: 'SP8MK Bot', + shortName: '8MKb', + connected: true, + nodeResponsive: true, + }, + }, + }, + }); + const s = await makeClient(gw.baseUrl).getStatus(); + expect(s).toEqual({ + connected: true, + myNodeNum: 3994646728, + longName: 'SP8MK Bot', + nodeResponsive: true, + }); + + const req = gw.requests[0]; + expect(req.pathname).toBe(`${BASE}/sources/${SOURCE}/status`); + expect(req.headers.authorization).toBe(`Bearer ${TOKEN}`); + }); + + it('defaults connected=false / myNodeNum=0 when fields are absent', async () => { + gw = await startGateway({ + [route('GET', '/status')]: { body: { success: true, data: {} } }, + }); + const s = await makeClient(gw.baseUrl).getStatus(); + expect(s.connected).toBe(false); + expect(s.myNodeNum).toBe(0); + expect(s.longName).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// getNodes +// --------------------------------------------------------------------------- + +describe('HttpMeshMonitorClient.getNodes', () => { + it('GETs with active/sinceDays, maps flat position + second-based lastHeard', async () => { + gw = await startGateway({ + [route('GET', '/nodes')]: { + body: { + success: true, + count: 2, + data: [ + { + nodeNum: 3994646728, + nodeId: '!ee1978c8', + longName: 'SP8MK Bot', + shortName: '8MKb', + hwModel: 63, + batteryLevel: 37, + voltage: 3.607, + firmwareVersion: '2.7.15.567b8ea', + rssi: null, + snr: 0, + lastHeard: 1791834334, + latitude: 51.253837, + longitude: 22.528193, + altitude: null, + hopsAway: null, + viaMqtt: false, + }, + { + nodeNum: 2927200947, + nodeId: '!ae798ab3', + longName: 'Lubmesh Node', + shortName: 'LM', + hwModel: 14, + batteryLevel: 88, + lastHeard: 0, + latitude: null, + longitude: null, + snr: -8.25, + rssi: -118, + viaMqtt: false, + }, + ], + }, + }, + }); + const nodes = await makeClient(gw.baseUrl).getNodes(); + expect(nodes).toEqual([ + { + num: 3994646728, + longName: 'SP8MK Bot', + shortName: '8MKb', + hwModel: 63, + batteryLevel: 37, + voltage: 3.607, + firmwareVersion: '2.7.15.567b8ea', + snr: 0, + lastHeard: 1791834334, + position: { lat: 51.253837, lon: 22.528193 }, + }, + { + num: 2927200947, + longName: 'Lubmesh Node', + shortName: 'LM', + hwModel: 14, + batteryLevel: 88, + snr: -8.25, + rssi: -118, + }, + ]); + + const req = gw.requests[0]; + expect(req.pathname).toBe(`${BASE}/sources/${SOURCE}/nodes`); + expect(req.query).toBe('active=true&sinceDays=7'); + expect(req.headers.authorization).toBe(`Bearer ${TOKEN}`); + }); +}); + +// --------------------------------------------------------------------------- +// getNode +// --------------------------------------------------------------------------- + +describe('HttpMeshMonitorClient.getNode', () => { + it('resolves a numeric argument to !hex and maps the node detail', async () => { + gw = await startGateway({ + [route('GET', '/nodes/!ee1978c8')]: { + body: { + success: true, + data: { + nodeNum: 3994646728, + nodeId: '!ee1978c8', + longName: 'SP8MK Bot', + batteryLevel: 37, + lastHeard: 1791834334, + latitude: 51.253837, + longitude: 22.528193, + altitude: null, + viaMqtt: false, + }, + }, + }, + }); + const n = await makeClient(gw.baseUrl).getNode(3994646728); + expect(n?.num).toBe(3994646728); + expect(n?.longName).toBe('SP8MK Bot'); + expect(n?.position).toEqual({ lat: 51.253837, lon: 22.528193 }); + expect(gw.requests[0].pathname).toBe( + `${BASE}/sources/${SOURCE}/nodes/!ee1978c8`, + ); + }); + + it('passes an explicit !hex id through unchanged', async () => { + gw = await startGateway({ + [route('GET', '/nodes/!ae798ab3')]: { + body: { + success: true, + data: { nodeNum: 2927200947, longName: 'X' }, + }, + }, + }); + const n = await makeClient(gw.baseUrl).getNode('!ae798ab3'); + expect(n?.num).toBe(2927200947); + expect(gw.requests[0].pathname).toBe( + `${BASE}/sources/${SOURCE}/nodes/!ae798ab3`, + ); + }); + + it('returns null on a 404 (unknown node) instead of throwing', async () => { + gw = await startGateway({ + [route('GET', '/nodes/!00000001')]: { + status: 404, + body: { success: false, error: 'node not found' }, + }, + }); + await expect(makeClient(gw.baseUrl).getNode(1)).resolves.toBeNull(); + }); + + it('re-throws non-404 errors (e.g. 500)', async () => { + gw = await startGateway({ + [route('GET', '/nodes/!ee1978c8')]: { + status: 500, + body: { success: false, error: 'boom' }, + }, + }); + await expect( + makeClient(gw.baseUrl).getNode(3994646728), + ).rejects.toMatchObject({ name: 'MeshMonitorError', status: 500 }); + }); +}); + +// --------------------------------------------------------------------------- +// getChannels +// --------------------------------------------------------------------------- + +describe('HttpMeshMonitorClient.getChannels', () => { + it('sorts by index and redacts the psk (marker when set, omitted when null)', async () => { + gw = await startGateway({ + [route('GET', '/channels')]: { + body: { + success: true, + count: 3, + data: [ + { + id: 0, + name: '', + displayName: 'Primary', + uplinkEnabled: true, + downlinkEnabled: false, + pskSet: true, + psk: 'AQ==', + }, + { + id: 2, + name: 'testowy', + displayName: 'testowy', + uplinkEnabled: false, + downlinkEnabled: false, + pskSet: false, + psk: null, + }, + { + id: 1, + name: 'LubMesh', + displayName: 'LubMesh', + uplinkEnabled: true, + downlinkEnabled: false, + pskSet: true, + psk: 'LQ==', + }, + ], + }, + }, + }); + const ch = await makeClient(gw.baseUrl).getChannels(); + expect(ch.map((c) => c.index)).toEqual([0, 1, 2]); + expect(ch).toEqual([ + { + index: 0, + name: '', + psk: 'REDACTED', + uplinkEnabled: true, + downlinkEnabled: false, + }, + { + index: 1, + name: 'LubMesh', + psk: 'REDACTED', + uplinkEnabled: true, + downlinkEnabled: false, + }, + { + index: 2, + name: 'testowy', + uplinkEnabled: false, + downlinkEnabled: false, + }, + ]); + // the raw base64 key must never leak into the mapped output + expect(JSON.stringify(ch)).not.toContain('AQ=='); + expect(JSON.stringify(ch)).not.toContain('LQ=='); + }); +}); + +// --------------------------------------------------------------------------- +// getTelemetry +// --------------------------------------------------------------------------- + +describe('HttpMeshMonitorClient.getTelemetry', () => { + it('maps long-format rows to { fromNum, at(ms), kind, data }', async () => { + gw = await startGateway({ + [route('GET', '/telemetry')]: { + body: { + success: true, + count: 3, + data: [ + { + nodeNum: 3994646728, + telemetryType: 'airUtilTx', + timestamp: 1791834214540, + value: 0.1517, + unit: '%', + packetId: 3971503092, + channel: null, + gpsAccuracy: null, + rxSnr: null, + }, + { + nodeNum: 4059237616, + telemetryType: 'altitude', + timestamp: 1791834193282, + value: 185, + unit: 'm', + packetId: 3637205748, + channel: 0, + gpsAccuracy: null, + rxSnr: null, + }, + { + nodeNum: 1475656844, + telemetryType: 'linkQuality', + timestamp: 1791834161250, + value: 9, + unit: 'quality', + packetId: null, + channel: null, + gpsAccuracy: null, + rxSnr: null, + }, + ], + }, + }, + }); + const t = await makeClient(gw.baseUrl).getTelemetry(); + expect(t).toEqual([ + { + fromNum: 3994646728, + at: 1791834214540, + kind: 'airUtilTx', + data: { value: 0.1517, unit: '%', packetId: 3971503092 }, + }, + { + fromNum: 4059237616, + at: 1791834193282, + kind: 'altitude', + data: { value: 185, unit: 'm', packetId: 3637205748, channel: 0 }, + }, + { + fromNum: 1475656844, + at: 1791834161250, + kind: 'linkQuality', + data: { value: 9, unit: 'quality' }, + }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// getMessages +// --------------------------------------------------------------------------- + +describe('HttpMeshMonitorClient.getMessages', () => { + it('maps messages (packetId from the id string, ms timestamps), newest first', async () => { + gw = await startGateway({ + [route('GET', '/messages')]: { + body: { + success: true, + count: 2, + data: [ + { + id: 'src_3994646728_268035341', + fromNodeNum: 3994646728, + fromNodeId: '!ee1978c8', + toNodeNum: 4294967295, + toNodeId: 'broadcast', + text: 'hi ch3', + channel: 3, + portnum: 1, + requestId: 268035341, + timestamp: 1791832779000, + replyId: 1807244554, + rxSnr: null, + viaMqtt: null, + deliveryState: 'delivered', + }, + { + id: 'src_2927200947_3264248582', + fromNodeNum: 2927200947, + fromNodeId: '!ae798ab3', + toNodeNum: 4294967295, + toNodeId: '!ffffffff', + text: 'weather', + channel: 1, + portnum: 1, + requestId: null, + timestamp: 1791828092137, + replyId: null, + rxSnr: -7.5, + viaMqtt: false, + deliveryState: null, + }, + ], + }, + }, + }); + const msgs = await makeClient(gw.baseUrl).getMessages(); + expect(msgs).toEqual([ + { + packetId: 268035341, + fromNum: 3994646728, + fromId: '!ee1978c8', + toNum: 4294967295, + toId: 'broadcast', + text: 'hi ch3', + channel: 3, + portnum: 1, + requestId: 268035341, + replyId: 1807244554, + at: 1791832779000, + deliveryState: 'delivered', + }, + { + packetId: 3264248582, + fromNum: 2927200947, + fromId: '!ae798ab3', + toNum: 4294967295, + toId: '!ffffffff', + text: 'weather', + channel: 1, + portnum: 1, + at: 1791828092137, + snr: -7.5, + }, + ]); + }); + + it('encodes the limit/channel/toNodeId/fromNodeId filters into the query', async () => { + gw = await startGateway({ + [route('GET', '/messages')]: { body: { success: true, data: [] } }, + }); + await makeClient(gw.baseUrl).getMessages({ + limit: 10, + channel: 3, + toNodeId: '!ffffffff', + fromNodeId: '!ae798ab3', + }); + // URLSearchParams percent-encodes `!` as %21 — assert on the decoded values + const q = new URLSearchParams(gw.requests[0].query); + expect(q.get('limit')).toBe('10'); + expect(q.get('channel')).toBe('3'); + expect(q.get('toNodeId')).toBe('!ffffffff'); + expect(q.get('fromNodeId')).toBe('!ae798ab3'); + }); +}); + +// --------------------------------------------------------------------------- +// send +// --------------------------------------------------------------------------- + +describe('HttpMeshMonitorClient.send', () => { + it('sends a DM: POSTs { text, toNodeId, replyId } and returns the packet id', async () => { + gw = await startGateway({ + [route('POST', '/messages')]: { + body: { + success: true, + data: { messageId: 'mid-1', requestId: 42, deliveryState: 'pending' }, + }, + }, + }); + const out = await makeClient(gw.baseUrl).send({ + text: 'hello', + toNodeId: '!ae798ab3', + replyId: 7, + }); + expect(out).toEqual({ + packetId: 42, + deliveryState: 'pending', + messageId: 'mid-1', + }); + + const req = gw.requests[0]; + expect(req.pathname).toBe(`${BASE}/sources/${SOURCE}/messages`); + expect(req.headers['content-type']).toBe('application/json'); + expect(req.headers.authorization).toBe(`Bearer ${TOKEN}`); + expect(JSON.parse(req.body)).toEqual({ + text: 'hello', + toNodeId: '!ae798ab3', + replyId: 7, + }); + }); + + it('sends a broadcast: POSTs { text, channel } (no toNodeId)', async () => { + gw = await startGateway({ + [route('POST', '/messages')]: { + body: { + success: true, + data: { messageId: 'mid-2', requestId: 99, deliveryState: 'pending' }, + }, + }, + }); + const out = await makeClient(gw.baseUrl).send({ + text: 'hi all', + channel: 3, + }); + expect(out.packetId).toBe(99); + const body = JSON.parse(gw.requests[0].body); + expect(body).toEqual({ text: 'hi all', channel: 3 }); + expect(body).not.toHaveProperty('toNodeId'); + expect(body).not.toHaveProperty('replyId'); + }); + + it('omits replyId when not provided', async () => { + gw = await startGateway({ + [route('POST', '/messages')]: { + body: { + success: true, + data: { requestId: 5, deliveryState: 'pending' }, + }, + }, + }); + await makeClient(gw.baseUrl).send({ text: 'x', toNodeId: '!ee1978c8' }); + const body = JSON.parse(gw.requests[0].body); + expect(body).toEqual({ text: 'x', toNodeId: '!ee1978c8' }); + expect(body).not.toHaveProperty('replyId'); + }); + + it('defaults deliveryState to pending when the response omits it', async () => { + gw = await startGateway({ + [route('POST', '/messages')]: { + body: { success: true, data: { requestId: 3 } }, + }, + }); + const out = await makeClient(gw.baseUrl).send({ text: 'y', channel: 0 }); + expect(out.deliveryState).toBe('pending'); + expect(out.packetId).toBe(3); + expect(out.messageId).toBeUndefined(); + }); + + it('rejects a send that sets BOTH channel and toNodeId (XOR) before any request', async () => { + gw = await startGateway({ + [route('POST', '/messages')]: { body: { success: true } }, + }); + await expect( + makeClient(gw.baseUrl).send({ text: 'x', channel: 3, toNodeId: '!1' }), + ).rejects.toThrow(/mutually exclusive/); + expect(gw.requests).toHaveLength(0); + }); + + it('rejects a send with NEITHER channel nor toNodeId (XOR) before any request', async () => { + gw = await startGateway({ + [route('POST', '/messages')]: { body: { success: true } }, + }); + await expect(makeClient(gw.baseUrl).send({ text: 'x' })).rejects.toThrow( + /required \(XOR\)/, + ); + expect(gw.requests).toHaveLength(0); + }); + + it('rejects an empty text before any request', async () => { + gw = await startGateway({ + [route('POST', '/messages')]: { body: { success: true } }, + }); + await expect( + makeClient(gw.baseUrl).send({ text: '', channel: 3 }), + ).rejects.toThrow(/non-empty/); + expect(gw.requests).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Error handling (non-2xx → MeshMonitorError with the HTTP status) +// --------------------------------------------------------------------------- + +describe('HttpMeshMonitorClient error handling', () => { + for (const status of [401, 413, 429, 503]) { + it(`maps HTTP ${status} to a MeshMonitorError carrying the status`, async () => { + gw = await startGateway({ + [route('GET', '/status')]: { + status, + body: { success: false, error: `code ${status}` }, + }, + }); + const err = await makeClient(gw.baseUrl) + .getStatus() + .catch((e) => e); + expect(err).toBeInstanceOf(MeshMonitorError); + expect(err.status).toBe(status); + expect(err.message).toContain(`HTTP ${status}`); + }); + } + + it('truncates a long error body to 300 chars', async () => { + const longMsg = 'x'.repeat(500); + gw = await startGateway({ + [route('GET', '/status')]: { + status: 500, + body: { success: false, error: longMsg }, + }, + }); + const err = await makeClient(gw.baseUrl) + .getStatus() + .catch((e) => e); + expect(err).toBeInstanceOf(MeshMonitorError); + expect(err.status).toBe(500); + expect((err.body ?? '').length).toBe(300); + }); +}); + +// --------------------------------------------------------------------------- +// createMeshMonitorClientFromEnv +// --------------------------------------------------------------------------- + +describe('createMeshMonitorClientFromEnv', () => { + it('returns null when no token is configured', () => { + delete process.env.MESHMONITOR_API_TOKEN; + expect(createMeshMonitorClientFromEnv()).toBeNull(); + }); + + it('builds an HttpMeshMonitorClient from env with default base + source', () => { + process.env.MESHMONITOR_API_TOKEN = TOKEN; + const c = createMeshMonitorClientFromEnv(); + expect(c).toBeInstanceOf(HttpMeshMonitorClient); + }); + + it('honours MESHMONITOR_API_BASE and MESHMONITOR_SOURCE overrides without throwing', () => { + process.env.MESHMONITOR_API_TOKEN = TOKEN; + process.env.MESHMONITOR_API_BASE = 'http://example.org/base/'; + process.env.MESHMONITOR_SOURCE = 'custom-src'; + expect(createMeshMonitorClientFromEnv()).toBeInstanceOf( + HttpMeshMonitorClient, + ); + }); +}); diff --git a/src/mesh/meshmonitor/mesh-monitor-client.ts b/src/mesh/meshmonitor/mesh-monitor-client.ts new file mode 100644 index 0000000..2a0164a --- /dev/null +++ b/src/mesh/meshmonitor/mesh-monitor-client.ts @@ -0,0 +1,547 @@ +/** + * MeshMonitor HTTP gateway client — a stateless REST replacement for the raw + * Meshtastic protobuf-over-TCP bridge. Every request just carries + * `Authorization: Bearer ` (no session/handshake/subscribe). + * + * The OpenAPI spec served by the gateway is OUTDATED — the live JSON shapes + * confirmed by curl (see the notes below and the spec in this module's tests) + * differ from it. Trust the mappers here: + * - canonical resource paths are source-scoped: `/sources/{sourceId}/…` + * (the legacy root `/channels` and `/telemetry` 404 on this deployment) + * - node position is FLAT: `latitude`/`longitude`/`altitude` (no nested `position{}`) + * - node `lastHeard`/`firstHeard` are EPOCH SECONDS; `createdAt`/`positionTimestamp` are ms + * - message `timestamp`/`rxTime`/`createdAt` are EPOCH MILLISECONDS + * - telemetry is LONG-format: one row per metric with `telemetryType`/`value`/`unit` + * - a message's packet id is the 3rd segment of its `id` string + * (`sourceId_fromNodeNum_packetId`) and equals `requestId` for outbound + */ + +export const BROADCAST_ADDR = 0xffffffff; + +/** uint32 node number → Meshtastic `!hex` node id (3994646728 → `!ee1978c8`). */ +export function nodeNumToId(num: number): string { + return '!' + (num >>> 0).toString(16).padStart(8, '0'); +} + +/** `!hex` node id → uint32 node number (the inverse of {@link nodeNumToId}). */ +export function nodeIdToNum(id: string): number | null { + const m = /^!([0-9a-fA-F]{1,8})$/.exec(id.trim()); + if (!m) return null; + return parseInt(m[1], 16) >>> 0; +} + +/** + * Shared send-target validation: the gateway requires EXACTLY ONE of + * `channel` (broadcast to a channel) or `toNodeId` (direct message) — an + * XOR. Enforced here so both the HTTP client and the test fake fail + * identically on a malformed send. + */ +export function assertSendTarget(msg: { + text: string; + toNodeId?: string; + channel?: number; +}): void { + if (typeof msg.text !== 'string' || msg.text.length === 0) + throw new Error('text must be a non-empty string'); + const hasChannel = msg.channel != null; + const hasTo = msg.toNodeId != null; + if (hasChannel && hasTo) + throw new Error('channel and toNodeId are mutually exclusive (XOR)'); + if (!hasChannel && !hasTo) + throw new Error('one of channel or toNodeId is required (XOR)'); +} + +// --------------------------------------------------------------------------- +// Typed shapes the downstream MeshService consumes (gateway JSON → these) +// --------------------------------------------------------------------------- + +/** The source's health / identity. */ +export interface MMStatus { + /** Whether the physical device behind the source is currently connected. */ + connected: boolean; + /** The physical node number the source relays through (the bot's "my" node). */ + myNodeNum: number; + longName?: string; + nodeResponsive?: boolean; +} + +/** One mesh node as the gateway reports it. */ +export interface MMNode { + /** uint32 node number. */ + num: number; + longName?: string; + shortName?: string; + /** meshtastic.HardwareModel enum VALUE (int) — the service maps it to a name. */ + hwModel?: number; + /** 0-100 = battery percent (device convention: >100 = mains powered). */ + batteryLevel?: number; + voltage?: number; + firmwareVersion?: string; + rssi?: number; + snr?: number; + /** EPOCH SECONDS of the last contact — same unit as the existing NodeSummary.lastHeard. Omitted when unknown/never heard. */ + lastHeard?: number; + /** Only present when both coordinates are known. */ + position?: { lat: number; lon: number; alt?: number }; + hopsAway?: number; + /** True when the node info arrived via MQTT rather than LoRa. */ + viaMqtt?: boolean; +} + +/** A configured channel; the psk is ALWAYS redacted. */ +export interface MMChannel { + index: number; + name: string; + /** Always the redaction marker — the raw key never leaves the client. */ + psk?: string; + uplinkEnabled?: boolean; + downlinkEnabled?: boolean; +} + +/** A telemetry reading (one per metric, from the gateway's long-format feed). */ +export interface MMTelemetry { + fromNum: number; + /** EPOCH MILLISECONDS. */ + at: number; + /** The metric name (e.g. "airUtilTx", "altitude", "batteryLevel"). */ + kind: string; + /** The metric payload: the row's value + unit (plus other non-null fields). */ + data: object; +} + +/** A mesh message (inbound or outbound), newest first. */ +export interface MMMessage { + /** uint32 packet id — the 3rd segment of the gateway `id` (== `requestId` for outbound). */ + packetId: number; + fromNum?: number; + fromId?: string; + toNum?: number; + toId?: string; + text: string; + /** Channel index; -1 = direct message. */ + channel: number; + portnum?: number; + /** Outbound only: the packet id used to match delivery ACKs. */ + requestId?: number; + /** The packet id this message quotes / replies to (threaded quote). */ + replyId?: number; + /** EPOCH MILLISECONDS. */ + at: number; + snr?: number; + viaMqtt?: boolean; + deliveryState?: 'pending' | 'delivered' | 'confirmed' | 'failed' | null; +} + +/** + * The transport the MeshService talks to. Phase 2 wires the real + * {@link HttpMeshMonitorClient} (or the test {@link FakeMeshMonitorClient}) + * into the service; this interface is the stable contract between them. + */ +export interface MeshMonitorClient { + /** Source health / identity (`GET /sources/{id}/status`). */ + getStatus(): Promise; + /** All known nodes (`GET /sources/{id}/nodes`). */ + getNodes(): Promise; + /** One node by node number, numeric string, or `!hex` id; null when missing. */ + getNode(nodeIdOrNum: string | number): Promise; + /** Configured channels, psk redacted (`GET /sources/{id}/channels`). */ + getChannels(): Promise; + /** Telemetry readings (`GET /sources/{id}/telemetry`). */ + getTelemetry(): Promise; + /** + * Recent messages, newest first (`GET /sources/{id}/messages`). `since` is + * ignored by this deployment — callers dedupe by `packetId`/`id`. + */ + getMessages(opts?: { + limit?: number; + channel?: number; + toNodeId?: string; + fromNodeId?: string; + }): Promise; + /** + * Send a text message (`POST /sources/{id}/messages`). Exactly one of + * `toNodeId` (DM) or `channel` (broadcast) is required (XOR); `replyId` + * quotes an earlier packet. Returns the assigned packet id, delivery state + * and the gateway's message id. + */ + send(msg: { + text: string; + toNodeId?: string; + channel?: number; + replyId?: number; + }): Promise<{ packetId: number; deliveryState: string; messageId?: string }>; +} + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +/** A non-2xx response from the gateway. `status` is the HTTP status; `body` the (truncated) server payload. */ +export class MeshMonitorError extends Error { + constructor( + message: string, + readonly status?: number, + readonly body?: string, + ) { + super(message); + this.name = 'MeshMonitorError'; + } +} + +// --------------------------------------------------------------------------- +// Raw gateway JSON shapes (confirmed live — the spec is outdated) +// --------------------------------------------------------------------------- + +interface RawStatus { + localNodeNum?: number; + localNodeId?: string; + longName?: string | null; + shortName?: string | null; + connected?: boolean; + nodeResponsive?: boolean; +} + +interface RawNode { + nodeNum?: number; + nodeId?: string; + longName?: string | null; + shortName?: string | null; + hwModel?: number | null; + role?: number | null; + batteryLevel?: number | null; + voltage?: number | null; + firmwareVersion?: string | null; + rssi?: number | null; + snr?: number | null; + /** EPOCH SECONDS (not ms). */ + lastHeard?: number | null; + latitude?: number | null; + longitude?: number | null; + altitude?: number | null; + hopsAway?: number | null; + viaMqtt?: boolean | null; +} + +interface RawChannel { + id?: number; + name?: string | null; + displayName?: string | null; + role?: number | null; + uplinkEnabled?: boolean | null; + downlinkEnabled?: boolean | null; + psk?: string | null; + pskSet?: boolean | null; +} + +interface RawTelemetry { + nodeNum?: number | null; + nodeId?: string | null; + telemetryType?: string | null; + /** EPOCH MILLISECONDS. */ + timestamp?: number | null; + value?: unknown; + unit?: string | null; + packetId?: number | null; + channel?: number | null; + gpsAccuracy?: number | null; + rxSnr?: number | null; +} + +interface RawMessage { + id?: string | null; + fromNodeNum?: number | null; + fromNodeId?: string | null; + toNodeNum?: number | null; + toNodeId?: string | null; + text?: string | null; + channel?: number | null; + portnum?: number | null; + requestId?: number | null; + replyId?: number | null; + /** EPOCH MILLISECONDS. */ + timestamp?: number | null; + rxSnr?: number | null; + viaMqtt?: boolean | null; + deliveryState?: string | null; +} + +// --------------------------------------------------------------------------- +// Mappers (raw JSON → typed shapes) +// --------------------------------------------------------------------------- + +function mapStatus(d: RawStatus): MMStatus { + const out: MMStatus = { + connected: d.connected ?? false, + myNodeNum: d.localNodeNum ?? 0, + }; + if (d.longName != null) out.longName = d.longName; + if (d.nodeResponsive != null) out.nodeResponsive = d.nodeResponsive; + return out; +} + +function mapNode(n: RawNode): MMNode { + const out: MMNode = { num: n.nodeNum ?? 0 }; + if (n.longName) out.longName = n.longName; + if (n.shortName) out.shortName = n.shortName; + if (n.hwModel != null) out.hwModel = n.hwModel; + if (n.batteryLevel != null) out.batteryLevel = n.batteryLevel; + if (n.voltage != null) out.voltage = n.voltage; + if (n.firmwareVersion) out.firmwareVersion = n.firmwareVersion; + if (n.rssi != null) out.rssi = n.rssi; + if (n.snr != null) out.snr = n.snr; + // gateway lastHeard is epoch SECONDS and 0 = never heard — keep the unit, drop zero + if (n.lastHeard != null && n.lastHeard > 0) out.lastHeard = n.lastHeard; + if (n.hopsAway != null) out.hopsAway = n.hopsAway; + if (n.viaMqtt === true) out.viaMqtt = true; + // flat coordinates (NOT a nested position{} object) — present only when both are known + if (n.latitude != null && n.longitude != null) { + out.position = { lat: n.latitude, lon: n.longitude }; + if (n.altitude != null) out.position.alt = n.altitude; + } + return out; +} + +function mapChannel(c: RawChannel): MMChannel { + const out: MMChannel = { + index: c.id ?? 0, + // the gateway's configured name ("" for the unconfigured primary); it also + // exposes a human-readable `displayName` (e.g. "Primary") if ever needed + name: c.name ?? '', + }; + // the psk (base64 key) is never exposed — replaced with a marker when set + if (c.psk != null) out.psk = 'REDACTED'; + if (c.uplinkEnabled != null) out.uplinkEnabled = c.uplinkEnabled; + if (c.downlinkEnabled != null) out.downlinkEnabled = c.downlinkEnabled; + return out; +} + +function mapTelemetry(t: RawTelemetry): MMTelemetry { + const data: Record = {}; + if (t.value !== undefined && t.value !== null) data.value = t.value; + if (t.unit != null) data.unit = t.unit; + if (t.packetId != null) data.packetId = t.packetId; + if (t.channel != null) data.channel = t.channel; + if (t.gpsAccuracy != null) data.gpsAccuracy = t.gpsAccuracy; + if (t.rxSnr != null) data.rxSnr = t.rxSnr; + return { + fromNum: t.nodeNum ?? 0, + at: t.timestamp ?? 0, // epoch ms + kind: t.telemetryType ?? '', + data, + }; +} + +/** Packet id = 3rd segment of the gateway `id` (`sourceId_fromNodeNum_packetId`). */ +function parsePacketId(id?: string | null): number { + if (!id) return 0; + const last = id.split('_').pop() ?? ''; + const n = Number(last); + return Number.isFinite(n) ? n : 0; +} + +function mapMessage(m: RawMessage): MMMessage { + const out: MMMessage = { + packetId: parsePacketId(m.id), + text: m.text ?? '', + channel: m.channel ?? 0, + at: m.timestamp ?? 0, // epoch ms + }; + if (m.fromNodeNum != null) out.fromNum = m.fromNodeNum; + if (m.fromNodeId != null) out.fromId = m.fromNodeId; + if (m.toNodeNum != null) out.toNum = m.toNodeNum; + if (m.toNodeId != null) out.toId = m.toNodeId; + if (m.portnum != null) out.portnum = m.portnum; + if (m.requestId != null) out.requestId = m.requestId; + if (m.replyId != null) out.replyId = m.replyId; + if (m.rxSnr != null) out.snr = m.rxSnr; + if (m.viaMqtt === true) out.viaMqtt = true; + if (m.deliveryState != null) + out.deliveryState = m.deliveryState as MMMessage['deliveryState']; + return out; +} + +// --------------------------------------------------------------------------- +// HTTP implementation +// --------------------------------------------------------------------------- + +export interface HttpMeshMonitorClientOptions { + /** Base URL of the gateway API, e.g. `https://meshmonitor.kkhost.pl/api/v1`. */ + baseUrl: string; + /** Source id (a UUID, or the literal `default` alias). */ + sourceId: string; + /** Bearer token (`mm_v1_…`). */ + token: string; + /** Per-request timeout in ms (default 15000). */ + timeoutMs?: number; + /** `GET /nodes?active=` (default true). */ + nodeActive?: boolean; + /** `GET /nodes?sinceDays=` (default 7). */ + nodeSinceDays?: number; +} + +/** + * Native-`fetch` MeshMonitor client. Stateless — every request carries the + * Bearer token; there is no session to establish or keep alive. + */ +export class HttpMeshMonitorClient implements MeshMonitorClient { + private readonly baseUrl: string; + private readonly sourceId: string; + private readonly token: string; + private readonly timeoutMs: number; + private readonly nodeActive: boolean; + private readonly nodeSinceDays: number; + + constructor(opts: HttpMeshMonitorClientOptions) { + this.baseUrl = opts.baseUrl.replace(/\/+$/, ''); + this.sourceId = opts.sourceId; + this.token = opts.token; + this.timeoutMs = opts.timeoutMs ?? 15000; + this.nodeActive = opts.nodeActive ?? true; + this.nodeSinceDays = opts.nodeSinceDays ?? 7; + } + + /** Issue a request and parse the JSON envelope; throw {@link MeshMonitorError} on non-2xx. */ + private async request(path: string, init?: RequestInit): Promise { + const url = `${this.baseUrl}${path}`; + const res = await fetch(url, { + ...init, + headers: { + ...(init?.headers as Record | undefined), + authorization: `Bearer ${this.token}`, + }, + signal: AbortSignal.timeout(this.timeoutMs), + }); + if (!res.ok) { + const body = (await res.text().catch(() => '')).slice(0, 300); + throw new MeshMonitorError( + `MeshMonitor ${init?.method ?? 'GET'} ${path} failed: HTTP ${res.status}${body ? ` ${body}` : ''}`, + res.status, + body, + ); + } + return (await res.json()) as T; + } + + async getStatus(): Promise { + const body = await this.request<{ data?: RawStatus }>( + `/sources/${this.sourceId}/status`, + ); + return mapStatus(body.data ?? {}); + } + + async getNodes(): Promise { + const q = new URLSearchParams(); + if (this.nodeActive) q.set('active', 'true'); + if (this.nodeSinceDays != null) + q.set('sinceDays', String(this.nodeSinceDays)); + const qs = q.toString(); + const body = await this.request<{ data?: RawNode[] }>( + `/sources/${this.sourceId}/nodes${qs ? `?${qs}` : ''}`, + ); + return (body.data ?? []).map(mapNode); + } + + async getNode(nodeIdOrNum: string | number): Promise { + const id = + typeof nodeIdOrNum === 'number' ? nodeNumToId(nodeIdOrNum) : nodeIdOrNum; + try { + const body = await this.request<{ data?: RawNode | null }>( + `/sources/${this.sourceId}/nodes/${encodeURIComponent(id.trim())}`, + ); + return body.data ? mapNode(body.data) : null; + } catch (err) { + // an unknown node is a 404 — surface it as "no such node", not an error + if (err instanceof MeshMonitorError && err.status === 404) return null; + throw err; + } + } + + async getChannels(): Promise { + const body = await this.request<{ data?: RawChannel[] }>( + `/sources/${this.sourceId}/channels`, + ); + return (body.data ?? []).map(mapChannel).sort((a, b) => a.index - b.index); + } + + async getTelemetry(): Promise { + const body = await this.request<{ data?: RawTelemetry[] }>( + `/sources/${this.sourceId}/telemetry`, + ); + return (body.data ?? []).map(mapTelemetry); + } + + async getMessages(opts?: { + limit?: number; + channel?: number; + toNodeId?: string; + fromNodeId?: string; + }): Promise { + const q = new URLSearchParams(); + if (opts?.limit != null) q.set('limit', String(opts.limit)); + if (opts?.channel != null) q.set('channel', String(opts.channel)); + if (opts?.toNodeId != null) q.set('toNodeId', opts.toNodeId); + if (opts?.fromNodeId != null) q.set('fromNodeId', opts.fromNodeId); + const qs = q.toString(); + const body = await this.request<{ data?: RawMessage[] }>( + `/sources/${this.sourceId}/messages${qs ? `?${qs}` : ''}`, + ); + // the gateway returns newest first — keep that order + return (body.data ?? []).map(mapMessage); + } + + async send(msg: { + text: string; + toNodeId?: string; + channel?: number; + replyId?: number; + }): Promise<{ packetId: number; deliveryState: string; messageId?: string }> { + assertSendTarget(msg); + const body: Record = { text: msg.text }; + if (msg.channel != null) body.channel = msg.channel; + if (msg.toNodeId != null) body.toNodeId = msg.toNodeId; + if (msg.replyId != null) body.replyId = msg.replyId; + + const res = await this.request<{ + data?: { + messageId?: string; + requestId?: number; + deliveryState?: string; + }; + }>(`/sources/${this.sourceId}/messages`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + + const d = res.data ?? {}; + const out: { + packetId: number; + deliveryState: string; + messageId?: string; + } = { + // 201 carries the packet id as requestId; a 202 (auto-split/queued) may omit it → 0 + packetId: d.requestId ?? 0, + deliveryState: d.deliveryState ?? 'pending', + }; + if (d.messageId != null) out.messageId = d.messageId; + return out; + } +} + +/** + * Build the real client from the environment, or `null` when + * `MESHMONITOR_API_TOKEN` is unset (the caller then keeps the TCP bridge). + * Used by Phase 2 to hand MeshService a client instance. + */ +export function createMeshMonitorClientFromEnv(): MeshMonitorClient | null { + const token = process.env.MESHMONITOR_API_TOKEN; + if (!token) return null; + return new HttpMeshMonitorClient({ + baseUrl: + process.env.MESHMONITOR_API_BASE ?? + 'https://meshmonitor.kkhost.pl/api/v1', + sourceId: process.env.MESHMONITOR_SOURCE ?? 'default', + token, + timeoutMs: parseInt(process.env.MESHMONITOR_TIMEOUT_MS ?? '15000', 10), + }); +} diff --git a/src/mesh/meshmonitor/meshmonitor.module.ts b/src/mesh/meshmonitor/meshmonitor.module.ts new file mode 100644 index 0000000..128e8f9 --- /dev/null +++ b/src/mesh/meshmonitor/meshmonitor.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common'; +import { + createMeshMonitorClientFromEnv, + type MeshMonitorClient, +} from './mesh-monitor-client.js'; + +/** + * DI token for the MeshMonitor client. Phase 2 imports {@link MeshMonitorModule} + * into `MeshModule` and injects this token to decide between the HTTP gateway + * (token set → a real {@link MeshMonitorClient}) and the legacy TCP bridge + * (token unset → `null`). + */ +export const MESH_MONITOR_CLIENT = 'MESH_MONITOR_CLIENT'; + +/** + * Thin provider around {@link createMeshMonitorClientFromEnv()}. It builds the + * real `HttpMeshMonitorClient` from the environment + * (`MESHMONITOR_API_TOKEN`, `MESHMONITOR_API_BASE`, `MESHMONITOR_SOURCE`, + * `MESHMONITOR_TIMEOUT_MS`) or `null` when no token is configured — the caller + * then keeps the existing TCP bridge. Not imported anywhere yet: Phase 2 wires + * it into `MeshModule`. + */ +@Module({ + providers: [ + { + provide: MESH_MONITOR_CLIENT, + useFactory: (): MeshMonitorClient | null => + createMeshMonitorClientFromEnv(), + }, + ], + exports: [MESH_MONITOR_CLIENT], +}) +export class MeshMonitorModule {}