From f80340ae6e5890958260610bb86b579bc3044187 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 12 Oct 2026 20:29:50 +0000 Subject: [PATCH] feat(meshmonitor): add stateless MeshMonitor HTTP-gateway client + fake + module Phase 1 of the MeshMonitor HTTP-gateway migration: a stateless REST client that a later phase wires into MeshService to replace the raw Meshtastic protobuf-over-TCP bridge. - mesh-monitor-client.ts: MeshMonitorClient interface, HttpMeshMonitorClient (native fetch, Bearer auth, injectable base/source/poll params), mappers for the gateway's actual JSON shapes (source-scoped paths, flat node position, second-based node lastHeard vs ms message/telemetry timestamps, long-format telemetry, packet id = 3rd segment of the id string), MeshMonitorError, nodeNumToId/nodeIdToNum, shared assertSendTarget XOR, and createMeshMonitorClientFromEnv() (null when MESHMONITOR_API_TOKEN unset). - fake-mesh-monitor-client.ts: in-memory double mirroring the client contract (set*/pushInbound config, sends[] recorder, outbound mirrored into getMessages, newest-first, limit/channel/toNodeId/fromNodeId filters). - meshmonitor.module.ts: thin Nest provider exposing the env-built client (or null) under MESH_MONITOR_CLIENT for Phase 2's MeshModule. - specs: HttpMeshMonitorClient against an in-process node:http gateway (raw->typed mapping per endpoint, Authorization: Bearer header, send body shape with channel/toNodeId XOR + replyId passthrough, 401/413/429/503 -> MeshMonitorError); FakeMeshMonitorClient behavior. No MeshService changes, no version bump, no changelog, no PR. --- .../fake-mesh-monitor-client.spec.ts | 255 ++++++ .../meshmonitor/fake-mesh-monitor-client.ts | 209 +++++ .../meshmonitor/mesh-monitor-client.spec.ts | 777 ++++++++++++++++++ src/mesh/meshmonitor/mesh-monitor-client.ts | 547 ++++++++++++ src/mesh/meshmonitor/meshmonitor.module.ts | 33 + 5 files changed, 1821 insertions(+) create mode 100644 src/mesh/meshmonitor/fake-mesh-monitor-client.spec.ts create mode 100644 src/mesh/meshmonitor/fake-mesh-monitor-client.ts create mode 100644 src/mesh/meshmonitor/mesh-monitor-client.spec.ts create mode 100644 src/mesh/meshmonitor/mesh-monitor-client.ts create mode 100644 src/mesh/meshmonitor/meshmonitor.module.ts 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..880148f --- /dev/null +++ b/src/mesh/meshmonitor/fake-mesh-monitor-client.spec.ts @@ -0,0 +1,255 @@ +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({ localNum: BOT_NUM }); + }); + + // ------------------------------------------------------------------ + // status + // ------------------------------------------------------------------ + + describe('getStatus', () => { + it('returns a default status until configured', async () => { + expect(await fake.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(); + }); + }); + + // ------------------------------------------------------------------ + // 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..a99eb8d --- /dev/null +++ b/src/mesh/meshmonitor/fake-mesh-monitor-client.ts @@ -0,0 +1,209 @@ +/** + * In-memory stand-in for the {@link MeshMonitorClient}, mirroring the + * `FakeBridge` pattern used by the TCP-bridge tests. It lets Phase 2 exercise + * `MeshService` against the HTTP-client contract without touching the network: + * - set the world with the `set*`/`pushInbound` helpers below + * - every `send()` is recorded in {@link FakeMeshMonitorClient.sends} AND + * appears as an outbound entry in `getMessages()` (newest first) + * + * 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 = { connected: true, myNodeNum: 0 }; + 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?: { localNum?: number }) { + this.localNum = opts?.localNum ?? 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 })); + } + + /** + * 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 configured world). */ + clearTraffic(): void { + this.sends.length = 0; + this.messages.length = 0; + this.packetSeq = 0; + } + + // ------------------------------------------------------------------ + // 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 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, like the existing REST 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 {}