import { fromBinary } from '@bufbuild/protobuf'; import * as P from '@meshtastic/protobufs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BROADCAST_ADDR, extractFrames } from '../mesh/codec.js'; import { nodeInfoFrame, startBridge, waitFor } from '../mesh/fake-bridge.js'; import type { MeshService, NodeSummary } from '../mesh/mesh.service.js'; import { MeshService as RealMeshService } from '../mesh/mesh.service.js'; import { BatteryAlertService, formatAlert, mention, } from './battery.service.js'; function node( num: number, batteryLevel?: number, longName?: string, shortName?: string, ): NodeSummary { const n: NodeSummary = { num, lastHeard: null }; if (batteryLevel !== undefined) n.batteryLevel = batteryLevel; if (longName) n.longName = longName; if (shortName) n.shortName = shortName; return n; } function makeService( nodes: NodeSummary[], sendText?: ReturnType, ): BatteryAlertService { const mesh = { nodes: async () => nodes, sendText } as unknown as MeshService; return new BatteryAlertService(mesh); } beforeEach(() => { // defaults under test: threshold 20, cooldown 6 h delete process.env.BATTERY_ALERT_THRESHOLD; delete process.env.BATTERY_ALERT_COOLDOWN_MS; }); describe('mention', () => { it('zero-pads to 8 chars and lowercases the hex', () => { expect(mention(0xabc)).toBe('@!00000abc'); // padded expect(mention(0xffffffff)).toBe('@!ffffffff'); // already 8 chars — no padding needed expect(mention(0xdeadbeef)).toBe('@!deadbeef'); // lowercase }); }); describe('formatAlert', () => { it('builds the full single-packet message with longName', () => { expect(formatAlert(node(0x1234abcd, 15, 'Kamil SP8MK/P'))).toBe( '⚠️ @!1234abcd battery low: 15% (Kamil SP8MK/P)', ); }); it('falls back to shortName and omits the name part when neither is known', () => { expect(formatAlert(node(0xabc, 9, undefined, 'KK'))).toBe( '⚠️ @!00000abc battery low: 9% (KK)', ); expect(formatAlert(node(0xabc, 9))).toBe('⚠️ @!00000abc battery low: 9%'); }); it('stays under the ~190-char single-packet limit', () => { const msg = formatAlert(node(0xffffffff, 5, 'A'.repeat(64))); expect(msg.length).toBeLessThan(190); }); }); describe('BatteryAlertService check threshold', () => { it('alerts at and below the default threshold (20) but not above', async () => { const sendText = vi.fn(async (..._args: unknown[]) => 1); const svc = makeService([node(1, 19), node(2, 20), node(3, 21)], sendText); await svc.check(); expect(sendText).toHaveBeenCalledTimes(2); // <= choice: exactly 20 alerts too expect(sendText).toHaveBeenNthCalledWith( 1, BROADCAST_ADDR, '⚠️ @!00000001 battery low: 19%', ); expect(sendText).toHaveBeenNthCalledWith( 2, BROADCAST_ADDR, '⚠️ @!00000002 battery low: 20%', ); }); it('honors a custom BATTERY_ALERT_THRESHOLD', async () => { process.env.BATTERY_ALERT_THRESHOLD = '50'; const sendText = vi.fn(async (..._args: unknown[]) => 1); try { const svc = makeService([node(1, 50), node(2, 51)], sendText); await svc.check(); expect(sendText).toHaveBeenCalledTimes(1); expect(sendText).toHaveBeenCalledWith( BROADCAST_ADDR, '⚠️ @!00000001 battery low: 50%', ); } finally { delete process.env.BATTERY_ALERT_THRESHOLD; } }); it('skips nodes without a reported battery level', async () => { const sendText = vi.fn(async (..._args: unknown[]) => 1); const svc = makeService([node(1), node(2, 87)], sendText); // no battery / healthy await svc.check(); expect(sendText).not.toHaveBeenCalled(); }); it('alerts once per qualifying node with the broadcast target', async () => { const sendText = vi.fn(async (..._args: unknown[]) => 1); const svc = makeService( [node(0xabc, 5, 'A'), node(0xdef, 3, undefined, 'B')], sendText, ); await svc.check(); expect(sendText).toHaveBeenCalledTimes(2); expect(sendText.mock.calls[0]).toEqual([ BROADCAST_ADDR, '⚠️ @!00000abc battery low: 5% (A)', ]); expect(sendText.mock.calls[1]).toEqual([ BROADCAST_ADDR, '⚠️ @!00000def battery low: 3% (B)', ]); }); it('never throws when a send fails', async () => { const svc = makeService( [node(1, 5)], vi.fn(async (..._args: unknown[]) => { throw new Error('bridge down'); }), ); await expect(svc.check()).resolves.toBeUndefined(); }); }); describe('BatteryAlertService check cooldown', () => { it('suppresses re-alerts within the window and allows them after expiry (default 6 h)', async () => { vi.useFakeTimers(); try { const sendText = vi.fn(async (..._args: unknown[]) => 1); const svc = makeService([node(0xabc, 5)], sendText); await svc.check(); expect(sendText).toHaveBeenCalledTimes(1); // first alert await svc.check(); expect(sendText).toHaveBeenCalledTimes(1); // immediate re-check — suppressed vi.advanceTimersByTime(6 * 3_600_000 - 1); await svc.check(); expect(sendText).toHaveBeenCalledTimes(1); // still inside the window vi.advanceTimersByTime(1); await svc.check(); expect(sendText).toHaveBeenCalledTimes(2); // cooldown elapsed — re-alerted } finally { vi.useRealTimers(); } }); }); describe('BatteryAlertService check end-to-end (real MeshService)', () => { it('broadcasts the alert on the pinned send channel (index 3 by default)', 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 mesh = new RealMeshService(); try { mesh.onModuleInit(); await waitFor( () => bridge.sockets.length === 1 && mesh.status().connected, ); bridge.sockets[0].write( nodeInfoFrame(0xabc, { user: { id: '!a', longName: 'Alpha' }, deviceMetrics: { batteryLevel: 5 }, }), ); await waitFor(async () => (await mesh.nodes()).length === 1); const out: Uint8Array[] = []; bridge.sockets[0].on('data', (c) => out.push(c)); const svc = new BatteryAlertService(mesh); await svc.check(); const readOutbound = (): P.Mesh.MeshPacket[] => { 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 starts with the want_config_id frame — collect only text packets, in order return packets.flatMap((p) => { const tr = fromBinary(P.Mesh.ToRadioSchema, p); if (tr.payloadVariant?.case === 'packet') return [tr.payloadVariant.value]; return []; }); }; await waitFor(() => readOutbound().length === 1); const pkt = readOutbound()[0]; expect(pkt.channel).toBe(3); // "test" — never the primary channel (0) const data = pkt.payloadVariant?.case === 'decoded' ? pkt.payloadVariant.value : undefined; expect(new TextDecoder().decode(data?.payload ?? new Uint8Array())).toBe( '⚠️ @!00000abc battery low: 5% (Alpha)', ); } finally { delete process.env.MESH_HOST; delete process.env.MESH_PORT; delete process.env.MESH_SEND_CHANNEL; mesh.onModuleDestroy(); bridge.close(); } }); });