import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; import * as P from '@meshtastic/protobufs'; import { describe, expect, it } from 'vitest'; import { BROADCAST_ADDR, extractFrames, frame } from './codec.js'; import { KAMIL_NUM, kamilNodeInfo, nodeInfoFrame, startBridge, waitFor, } from './fake-bridge.js'; import { MeshService } from './mesh.service.js'; // live position update: POSITION_APP packet with a serialized Position in Data.payload function positionPacketFrame( from: number, pos: { latitudeI?: number; longitudeI?: number }, ): Uint8Array { const data = create(P.Mesh.DataSchema, { portnum: P.Portnums.PortNum.POSITION_APP, payload: toBinary( P.Mesh.PositionSchema, create(P.Mesh.PositionSchema, pos), ), }); const pkt = create(P.Mesh.MeshPacketSchema, { from, id: 1, payloadVariant: { case: 'decoded', value: data }, }); return frame( toBinary( P.Mesh.FromRadioSchema, create(P.Mesh.FromRadioSchema, { payloadVariant: { case: 'packet', value: pkt }, }), ), ); } // 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 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 3) by default, never primary', async () => { const bridge = await startBridge(); process.env.MESH_HOST = '127.0.0.1'; process.env.MESH_PORT = String(bridge.port); delete process.env.MESH_SEND_CHANNEL; // default path: "test" (index 3) const svc = new MeshService(); try { svc.onModuleInit(); await waitFor( () => bridge.sockets.length === 1 && svc.status().connected, ); const out: Uint8Array[] = []; bridge.sockets[0].on('data', (c) => out.push(c)); const id = await svc.sendText(42, 'hello test'); expect(id).toBeGreaterThan(0); const readOutbound = (): P.Mesh.MeshPacket | undefined => { const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0)); let off = 0; for (const c of out) { merged.set(c, off); off += c.length; } const { packets } = extractFrames(merged); // outbound now starts with the want_config_id frame — find the text packet for (const p of packets) { const tr = fromBinary(P.Mesh.ToRadioSchema, p); if (tr.payloadVariant?.case === 'packet') return tr.payloadVariant.value; } return undefined; }; await waitFor(() => readOutbound() !== undefined); const pkt = readOutbound()!; expect(pkt.channel).toBe(3); // "test" — never the primary channel (0) expect(pkt.to).toBe(42); const decoded = pkt.payloadVariant?.case === 'decoded' ? pkt.payloadVariant.value : undefined; expect( new TextDecoder().decode(decoded?.payload ?? new Uint8Array()), ).toBe('hello test'); } finally { delete process.env.MESH_SEND_CHANNEL; svc.onModuleDestroy(); bridge.close(); } }); }); 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 test'); expect(svc.messages()[0]).toMatchObject({ id, dir: 'tx', to: 42, text: 'hello test', channel: 3, status: 'pending', }); bridge.sockets[0].write(routingFrame(id, P.Mesh.Routing_Error.NONE)); await waitFor(() => svc.messages()[0]?.status === 'delivered'); expect(svc.messages()[0]).toMatchObject({ id, dir: 'tx', status: 'delivered', }); } 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(); } }); }); describe('MeshService resolveDest', () => { it('resolves broadcast sentinels, numbers and names to node numbers', async () => { const bridge = await startBridge(); process.env.MESH_HOST = '127.0.0.1'; process.env.MESH_PORT = String(bridge.port); const svc = new MeshService(); try { svc.onModuleInit(); await waitFor( () => bridge.sockets.length === 1 && svc.status().connected, ); bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); await waitFor(async () => (await svc.nodes()).length === 1); expect(await svc.resolveDest(undefined)).toBe(BROADCAST_ADDR); // omitted → broadcast expect(await svc.resolveDest('')).toBe(BROADCAST_ADDR); // empty → broadcast expect(await svc.resolveDest('!b')).toBe(BROADCAST_ADDR); expect(await svc.resolveDest('BROADCAST')).toBe(BROADCAST_ADDR); // case-insensitive expect(await svc.resolveDest(42)).toBe(42); // number passthrough expect(await svc.resolveDest(String(KAMIL_NUM))).toBe(KAMIL_NUM); // numeric string expect(await svc.resolveDest('Kamil SP8MK/P')).toBe(KAMIL_NUM); // exact longName } finally { svc.onModuleDestroy(); bridge.close(); } }); it('throws for unknown names and invalid node numbers', async () => { const bridge = await startBridge(); process.env.MESH_HOST = '127.0.0.1'; process.env.MESH_PORT = String(bridge.port); const svc = new MeshService(); try { svc.onModuleInit(); await waitFor( () => bridge.sockets.length === 1 && svc.status().connected, ); bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); await waitFor(async () => (await svc.nodes()).length === 1); await expect(svc.resolveDest('zzz-nope')).rejects.toThrow( "no node matching 'zzz-nope'", ); for (const bad of [0, -1, 42.5, BROADCAST_ADDR + 1]) { await expect(svc.resolveDest(bad)).rejects.toThrow( /invalid node number/, ); } await expect(svc.resolveDest('0')).rejects.toThrow(/invalid node number/); } finally { svc.onModuleDestroy(); bridge.close(); } }); });