Files
MeshBot/src/mesh/mesh.service.spec.ts
T
2026-09-24 16:19:32 +02:00

615 lines
18 KiB
TypeScript

import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import * as net from 'node:net';
import type { AddressInfo } from 'node:net';
import { describe, expect, it } from 'vitest';
import { extractFrames, frame } from './codec.js';
import { MeshService } from './mesh.service.js';
function startBridge(onSocket?: (s: net.Socket) => void): Promise<{
port: number;
sockets: net.Socket[];
close: () => void;
}> {
const sockets: net.Socket[] = [];
const server = net.createServer((socket) => {
onSocket?.(socket); // attached at accept time, before any data can arrive
sockets.push(socket);
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve({
port: (server.address() as AddressInfo).port,
sockets,
close: () => server.close(),
});
});
});
}
async function waitFor(
cond: () => boolean | Promise<boolean>,
timeoutMs = 5000,
): Promise<void> {
const t0 = Date.now();
while (!(await cond())) {
if (Date.now() - t0 > timeoutMs) throw new Error('timed out waiting');
await new Promise((r) => setTimeout(r, 10));
}
}
function nodeInfoFrame(
num: number,
fields?: Partial<P.Mesh.NodeInfo>,
): Uint8Array {
const ni = create(P.Mesh.NodeInfoSchema, { num, ...fields });
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'nodeInfo', value: ni },
}),
),
);
}
// live position update: POSITION_APP packet with a serialized Position in Data.payload
function positionPacketFrame(
from: number,
pos: Partial<P.Mesh.Position>,
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.POSITION_APP,
payload: toBinary(
P.Mesh.PositionSchema,
create(P.Mesh.PositionSchema, pos),
),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from,
id: 1,
payloadVariant: { case: 'decoded', value: data },
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'packet', value: pkt },
}),
),
);
}
const KAMIL_NUM = 123456789;
// inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload
function textPacketFrame(
from: number,
to: number,
text: string,
channel = 0,
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP,
payload: new TextEncoder().encode(text),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from,
to,
id: 1,
channel,
payloadVariant: { case: 'decoded', value: data },
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'packet', value: pkt },
}),
),
);
}
// ACK/NACK for our sends: ROUTING_APP packet with Data.requestId = original id and a serialized Routing in the payload
function routingFrame(
requestId: number,
errorReason: P.Mesh.Routing_Error,
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.ROUTING_APP,
requestId,
payload: toBinary(
P.Mesh.RoutingSchema,
create(P.Mesh.RoutingSchema, {
variant: { case: 'errorReason', value: errorReason },
}),
),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from: 1,
id: 2,
payloadVariant: { case: 'decoded', value: data },
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'packet', value: pkt },
}),
),
);
}
function kamilNodeInfo(): Partial<P.Mesh.NodeInfo> {
return {
user: {
id: '!kamil',
longName: 'Kamil SP8MK/P',
shortName: 'KK',
hwModel: P.Mesh.HardwareModel.TBEAM,
},
position: { latitudeI: 521049637, longitudeI: 209833152, altitude: 123 },
lastHeard: 1758000000,
deviceMetrics: { batteryLevel: 87 },
};
}
function channelFrame(
index: number,
settings?: { name?: string; uplinkEnabled?: boolean },
): Uint8Array {
const ch = create(P.Channel.ChannelSchema, {
index,
...(settings
? {
// psk must never leak into channels() output
settings: {
name: settings.name ?? '',
psk: new Uint8Array(16).fill(7),
uplinkEnabled: settings.uplinkEnabled ?? false,
},
}
: {}),
});
const fr = create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'channel', value: ch },
});
return frame(toBinary(P.Mesh.FromRadioSchema, fr));
}
describe('MeshService channel pipeline', () => {
it('collects channels from FromRadio frames and redacts keys', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
expect(svc.channels()).toEqual([]);
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
bridge.sockets[0].write(
channelFrame(2, { name: 'LongFast', uplinkEnabled: true }),
);
bridge.sockets[0].write(channelFrame(0)); // channel with no settings at all
await waitFor(() => svc.channels().length === 2);
expect(svc.channels()).toEqual([
{ index: 0, name: '', uplinkEnabled: false, downlinkEnabled: false },
{
index: 2,
name: 'LongFast',
uplinkEnabled: true,
downlinkEnabled: false,
},
]);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('resets channels on reconnect (no stale data)', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
process.env.MESH_RECONNECT_MS = '50';
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
bridge.sockets[0].write(channelFrame(1, { name: 'Stale' }));
await waitFor(() => svc.channels().length === 1);
// drop the connection; bot reconnects and must start with an empty table
bridge.sockets[0].destroy();
await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected);
await waitFor(() => svc.channels().length === 0);
} finally {
delete process.env.MESH_RECONNECT_MS;
svc.onModuleDestroy();
bridge.close();
}
});
});
describe('MeshService connect', () => {
it('sends want_config_id on connect and applies the initial sync', async () => {
const out: Uint8Array[] = [];
const bridge = await startBridge((s) => s.on('data', (c) => out.push(c)));
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(() => svc.status().connected && out.length > 0);
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
let off = 0;
for (const c of out) {
merged.set(c, off);
off += c.length;
}
const { packets } = extractFrames(merged);
expect(packets.length).toBeGreaterThan(0);
const tr = fromBinary(P.Mesh.ToRadioSchema, packets[0]);
expect(tr.payloadVariant?.case).toBe('wantConfigId');
const nonce =
tr.payloadVariant && tr.payloadVariant.case === 'wantConfigId'
? tr.payloadVariant.value
: -1;
expect(nonce).toBeGreaterThan(0);
// feed back the initial sync: myInfo, then configCompleteId echoing the nonce
bridge.sockets[0].write(
frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'myInfo', value: { myNodeNum: 1234 } },
}),
),
),
);
bridge.sockets[0].write(
frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'configCompleteId', value: nonce },
}),
),
),
);
await waitFor(() => svc.status().myNodeNum === 1234);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
});
describe('MeshService send', () => {
it('sends on the test channel (index 2) by default, never primary', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
delete process.env.MESH_SEND_CHANNEL; // default path: "testowy" (index 2)
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
const out: Uint8Array[] = [];
bridge.sockets[0].on('data', (c) => out.push(c));
const id = await svc.sendText(42, 'hello testowy');
expect(id).toBeGreaterThan(0);
const readOutbound = (): P.Mesh.MeshPacket | undefined => {
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
let off = 0;
for (const c of out) {
merged.set(c, off);
off += c.length;
}
const { packets } = extractFrames(merged);
// outbound now starts with the want_config_id frame — find the text packet
for (const p of packets) {
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
if (tr.payloadVariant?.case === 'packet')
return tr.payloadVariant.value;
}
return undefined;
};
await waitFor(() => readOutbound() !== undefined);
const pkt = readOutbound()!;
expect(pkt.channel).toBe(2); // "testowy" — never the primary channel (0)
expect(pkt.to).toBe(42);
const decoded =
pkt.payloadVariant?.case === 'decoded'
? pkt.payloadVariant.value
: undefined;
expect(
new TextDecoder().decode(decoded?.payload ?? new Uint8Array()),
).toBe('hello testowy');
} finally {
delete process.env.MESH_SEND_CHANNEL;
svc.onModuleDestroy();
bridge.close();
}
});
});
describe('MeshService node database', () => {
it('collects NodeInfo during sync and summarizes nodes', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
bridge.sockets[0].write(nodeInfoFrame(987654321)); // bare entry: no user/position/metrics yet
await waitFor(async () => (await svc.nodes()).length === 2);
expect(await svc.nodes()).toEqual([
{
num: KAMIL_NUM,
longName: 'Kamil SP8MK/P',
shortName: 'KK',
hwModel: 'TBEAM',
batteryLevel: 87,
lastHeard: 1758000000,
position: { lat: 521049637 * 1e-7, lon: 209833152 * 1e-7, alt: 123 },
},
{ num: 987654321, lastHeard: null }, // names/hwModel/battery/position omitted when absent
]);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('creates a stub entry for POSITION_APP packets from unknown nodes', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
bridge.sockets[0].write(
positionPacketFrame(555, {
latitudeI: 10000000,
longitudeI: -20000000,
}),
);
await waitFor(async () => (await svc.nodes()).length === 1);
expect(await svc.nodes()).toEqual([
{ num: 555, lastHeard: null, position: { lat: 1, lon: -2 } }, // no alt sent -> omitted
]);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('looks up nodes by exact num, numeric string, exact name and unique substring', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
bridge.sockets[0].write(
nodeInfoFrame(42, {
user: { id: '!ot', longName: 'Other Node', shortName: 'OT' },
}),
);
await waitFor(async () => (await svc.nodes()).length === 2);
expect((await svc.node(KAMIL_NUM))?.longName).toBe('Kamil SP8MK/P'); // exact num
expect((await svc.node(String(KAMIL_NUM)))?.num).toBe(KAMIL_NUM); // numeric string
expect((await svc.node('kamil sp8mk/p'))?.shortName).toBe('KK'); // case-insensitive exact name
expect((await svc.node('kamil'))?.num).toBe(KAMIL_NUM); // unique substring
expect(await svc.node('zzz-nope')).toBeNull(); // no match
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('returns null for ambiguous name matches', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
// two nodes whose names both contain "kamil" and share the short name "KK"
bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
bridge.sockets[0].write(
nodeInfoFrame(777, {
user: { id: '!kj', longName: 'Kamil Jr.', shortName: 'KK' },
}),
);
await waitFor(async () => (await svc.nodes()).length === 2);
expect(await svc.node('kamil')).toBeNull(); // substring matches two nodes
expect(await svc.node('kk')).toBeNull(); // exact name matches two nodes
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('resets the node database on reconnect (no stale data)', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
process.env.MESH_RECONNECT_MS = '50';
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
await waitFor(async () => (await svc.nodes()).length === 1);
// drop the connection; bot reconnects and must start with an empty node DB
bridge.sockets[0].destroy();
await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected);
await waitFor(async () => (await svc.nodes()).length === 0);
} finally {
delete process.env.MESH_RECONNECT_MS;
svc.onModuleDestroy();
bridge.close();
}
});
});
describe('MeshService message history', () => {
it('captures inbound text as rx records', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 2));
await waitFor(() => svc.messages().length === 1);
expect(svc.messages()).toEqual([
{
id: 1,
dir: 'rx',
from: KAMIL_NUM,
text: 'ping',
channel: 2,
at: expect.any(Number),
},
]);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('tracks a tx send pending -> delivered on the routing ACK', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
const id = await svc.sendText(42, 'hello testowy');
expect(svc.messages()[0]).toMatchObject({
id,
dir: 'tx',
to: 42,
text: 'hello testowy',
channel: 2,
status: 'pending',
});
bridge.sockets[0].write(routingFrame(id, P.Mesh.Routing_Error.NONE));
await waitFor(() => svc.messages()[0]?.status === 'delivered');
expect(svc.messages()[0]).toMatchObject({
id,
dir: 'tx',
status: 'delivered',
});
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('marks a tx failed with the routing error reason on NACK', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
const id = await svc.sendText(42, 'will fail');
expect(svc.messages()[0]?.status).toBe('pending');
bridge.sockets[0].write(routingFrame(id, P.Mesh.Routing_Error.TIMEOUT));
await waitFor(() => svc.messages()[0]?.status === 'failed');
expect(svc.messages()[0]).toMatchObject({
id,
status: 'failed',
ackError: 'TIMEOUT', // human-readable Routing.Error name
});
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('caps history at 100 records, dropping the oldest', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
for (let i = 0; i < 105; i++)
bridge.sockets[0].write(textPacketFrame(42, KAMIL_NUM, `m-${i}`));
await waitFor(() => svc.messages().length === 100, 10000);
expect(svc.messages()[0]?.text).toBe('m-104'); // newest first
expect(svc.messages()[99]?.text).toBe('m-5'); // m-0..m-4 dropped
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
});