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

Merged
kamilkrzywda merged 2 commits from feat/meshmonitor-connection into main 2026-10-13 03:10:15 +02:00
5 changed files with 1821 additions and 0 deletions
Showing only changes of commit f80340ae6e - Show all commits
@@ -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);
});
});
});
@@ -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<MMStatus> {
return { ...this.status };
}
async getNodes(): Promise<MMNode[]> {
return this.nodes.map((n) => ({ ...n }));
}
async getNode(nodeIdOrNum: string | number): Promise<MMNode | null> {
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<MMChannel[]> {
return this.channels.map((c) => ({ ...c }));
}
async getTelemetry(): Promise<MMTelemetry[]> {
return this.telemetry.map((t) => ({ ...t }));
}
async getMessages(opts?: {
limit?: number;
channel?: number;
toNodeId?: string;
fromNodeId?: string;
}): Promise<MMMessage[]> {
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 };
}
}
@@ -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<string, CannedResponse>;
interface Gateway {
baseUrl: string;
requests: RecordedRequest[];
close: () => Promise<void>;
}
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<Gateway> {
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<void>((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,
);
});
});
+547
View File
@@ -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 <token>` (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<MMStatus>;
/** All known nodes (`GET /sources/{id}/nodes`). */
getNodes(): Promise<MMNode[]>;
/** One node by node number, numeric string, or `!hex` id; null when missing. */
getNode(nodeIdOrNum: string | number): Promise<MMNode | null>;
/** Configured channels, psk redacted (`GET /sources/{id}/channels`). */
getChannels(): Promise<MMChannel[]>;
/** Telemetry readings (`GET /sources/{id}/telemetry`). */
getTelemetry(): Promise<MMTelemetry[]>;
/**
* 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<MMMessage[]>;
/**
* 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<string, unknown> = {};
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<T>(path: string, init?: RequestInit): Promise<T> {
const url = `${this.baseUrl}${path}`;
const res = await fetch(url, {
...init,
headers: {
...(init?.headers as Record<string, string> | 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<MMStatus> {
const body = await this.request<{ data?: RawStatus }>(
`/sources/${this.sourceId}/status`,
);
return mapStatus(body.data ?? {});
}
async getNodes(): Promise<MMNode[]> {
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<MMNode | null> {
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<MMChannel[]> {
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<MMTelemetry[]> {
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<MMMessage[]> {
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<string, unknown> = { 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),
});
}
@@ -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 {}