Node database: collect nodes during sync, expose GET /api/nodes (#7)
CI / build + unit (push) Successful in 39s
CI / build + unit (push) Successful in 39s
This commit was merged in pull request #7.
This commit is contained in:
@@ -27,14 +27,74 @@ function startBridge(onSocket?: (s: net.Socket) => void): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(cond: () => boolean, timeoutMs = 5000): Promise<void> {
|
||||
async function waitFor(
|
||||
cond: () => boolean | Promise<boolean>,
|
||||
timeoutMs = 5000,
|
||||
): Promise<void> {
|
||||
const t0 = Date.now();
|
||||
while (!cond()) {
|
||||
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;
|
||||
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 },
|
||||
@@ -230,3 +290,150 @@ describe('MeshService send', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user