diff --git a/CHANGELOG.md b/CHANGELOG.md index c451a16..eb93719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,11 @@ ## v0.13.0 — 2026-09-25 12:34 -- New non-AI `src/battery` module: `BatteryAlertModule` + `BatteryAlertService`, wired into `app.module.ts` exactly like the weather module (ScheduleModule.forRoot() + MeshModule). Every `BATTERY_CHECK_INTERVAL_MS` ms (default 60000, read at construction) it scans the same node state that backs `GET /api/nodes` and broadcasts a single-packet alert for every node whose reported battery is at or below `BATTERY_ALERT_THRESHOLD` percent (default 20). The comparison is `<=` on purpose — a node sitting exactly at the threshold counts as low; tests pin this choice. -- Alert format: `⚠️ @!<8-hex-lowercase-node-num> battery low: % ()` — Meshtastic mention syntax (`@!` + zero-padded 8-char lowercase hex, e.g. `@!09a2b3c4`), always well under the ~190-char single-packet limit; the name part falls back longName → shortName and is omitted entirely when the node reported no name. Broadcasts ride the pinned send channel via the existing `MeshService.sendText(BROADCAST_ADDR, text)` path (MESH_SEND_CHANNEL, default index 3) — no new transport. -- Anti-spam: per-node cooldown of `BATTERY_ALERT_COOLDOWN_MS` ms (default 21600000 = 6 h) in an in-memory `Map` — a node is alerted at most once per window; the map resets on process restart by design (a node may get one duplicate alert after a deploy). +- New non-AI `src/battery` module: `BatteryAlertModule` + `BatteryAlertService`, wired into `app.module.ts` exactly like the weather module (ScheduleModule.forRoot() + MeshModule). Every `BATTERY_CHECK_INTERVAL_MS` ms (default 60000, read at construction) it scans the same node state that backs `GET /api/nodes` and collects every node whose reported battery is at or below `BATTERY_ALERT_THRESHOLD` percent (default 20); when any qualify it sends AT MOST ONE broadcast per cycle containing all of them — never one message per node, and nothing at all when none qualify. The comparison is `<=` on purpose — a node sitting exactly at the threshold counts as low; tests pin this choice. +- Alert format: `⚠️ battery low: @!<8-hex-lowercase-node-num> () %` with additional nodes appended as `, @! () %` (the single-node case is the same template with one entry) — Meshtastic mention syntax (`@!` + zero-padded 8-char lowercase hex, e.g. `@!09a2b3c4`), entries kept tight so common cases stay near the ~190-char single-packet limit; the name part falls back longName → shortName and is omitted entirely when a node reported no name. Broadcasts ride the pinned send channel via the existing `MeshService.sendText(BROADCAST_ADDR, text)` path (MESH_SEND_CHANNEL, default index 3) — no new transport. +- Anti-spam: per-node cooldown of `BATTERY_ALERT_COOLDOWN_MS` ms (default 21600000 = 6 h) in an in-memory `Map` — only nodes outside their window are included in a batch and the alert timestamp is recorded for every node in it after sending, so a node is alerted at most once per window; the map resets on process restart by design (a node may get one duplicate alert after a deploy). - Live battery updates: `batteryLevel` was previously only populated from NodeInfo frames during the initial sync. Live NODEINFO_APP packets (port 4, serialized meshtastic.NodeInfo in Data.payload) now update `deviceMetrics` in the node database too — new `decodeNodeInfo()` + `PORT_NODEINFO_APP` in codec.ts, one branch in the existing packet-handling path — so alerts and `/api/nodes` reflect current values between syncs. Unknown nodes get a stub entry when they report metrics live; malformed payloads are skipped like every other port. -- Tests: new `src/battery/battery.service.spec.ts` (mention hex padding/lowercase, full message construction incl. name fallbacks and the 190-char ceiling, threshold boundary with default + custom env, no-battery skip, once-per-node broadcast target, send-failure never throws, cooldown suppression within window + re-alert after expiry via fake timers) plus a DI spec mirroring weather's; an end-to-end case drives a real MeshService over the fake bridge and asserts the alert frame rides channel index 3 with the exact text. `mesh.service.spec.ts` gains a NODEINFO_APP live-update case (stale sync battery refreshed, other fields preserved, unknown-node stub). All pre-existing specs stay green. +- Tests: new `src/battery/battery.service.spec.ts` (mention hex padding/lowercase, batched message construction incl. multi-node append, name fallbacks and the 190-char ceiling, threshold boundary with default + custom env, no-battery skip, all qualifying nodes merged into exactly one broadcast, send-failure never throws, cooldown suppression within window + re-alert after expiry via fake timers, in-cooldown node excluded from a batch while an out-of-window node still triggers the single message) plus a DI spec mirroring weather's; an end-to-end case drives a real MeshService over the fake bridge and asserts the alert frame rides channel index 3 with the exact text. `mesh.service.spec.ts` gains a NODEINFO_APP live-update case (stale sync battery refreshed, other fields preserved, unknown-node stub). All pre-existing specs stay green. ## v0.12.8 — 2026-09-25 12:04 diff --git a/src/battery/battery.service.spec.ts b/src/battery/battery.service.spec.ts index 9ca6793..f38592c 100644 --- a/src/battery/battery.service.spec.ts +++ b/src/battery/battery.service.spec.ts @@ -47,21 +47,27 @@ describe('mention', () => { }); 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('builds the single-node message with longName', () => { + expect(formatAlert([node(0x1234abcd, 15, 'Kamil SP8MK/P')])).toBe( + '⚠️ battery low: @!1234abcd (Kamil SP8MK/P) 15%', ); }); + it('appends additional nodes as ", " in one line', () => { + expect( + formatAlert([node(0xabc, 5, 'A'), node(0xdef, 3, undefined, 'B')]), + ).toBe('⚠️ battery low: @!00000abc (A) 5%, @!00000def (B) 3%'); + }); + 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, undefined, 'KK')])).toBe( + '⚠️ battery low: @!00000abc (KK) 9%', ); - expect(formatAlert(node(0xabc, 9))).toBe('⚠️ @!00000abc battery low: 9%'); + expect(formatAlert([node(0xabc, 9)])).toBe('⚠️ battery low: @!00000abc 9%'); }); it('stays under the ~190-char single-packet limit', () => { - const msg = formatAlert(node(0xffffffff, 5, 'A'.repeat(64))); + const msg = formatAlert([node(0xffffffff, 5, 'A'.repeat(64))]); expect(msg.length).toBeLessThan(190); }); }); @@ -73,16 +79,11 @@ describe('BatteryAlertService check threshold', () => { await svc.check(); - expect(sendText).toHaveBeenCalledTimes(2); // <= choice: exactly 20 alerts too - expect(sendText).toHaveBeenNthCalledWith( - 1, + // <= choice: exactly 20 qualifies too — both in ONE batched message + expect(sendText).toHaveBeenCalledTimes(1); + expect(sendText).toHaveBeenCalledWith( BROADCAST_ADDR, - '⚠️ @!00000001 battery low: 19%', - ); - expect(sendText).toHaveBeenNthCalledWith( - 2, - BROADCAST_ADDR, - '⚠️ @!00000002 battery low: 20%', + '⚠️ battery low: @!00000001 19%, @!00000002 20%', ); }); @@ -97,7 +98,7 @@ describe('BatteryAlertService check threshold', () => { expect(sendText).toHaveBeenCalledTimes(1); expect(sendText).toHaveBeenCalledWith( BROADCAST_ADDR, - '⚠️ @!00000001 battery low: 50%', + '⚠️ battery low: @!00000001 50%', ); } finally { delete process.env.BATTERY_ALERT_THRESHOLD; @@ -113,7 +114,7 @@ describe('BatteryAlertService check threshold', () => { expect(sendText).not.toHaveBeenCalled(); }); - it('alerts once per qualifying node with the broadcast target', async () => { + it('merges all qualifying nodes into exactly ONE broadcast 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')], @@ -122,14 +123,10 @@ describe('BatteryAlertService check threshold', () => { await svc.check(); - expect(sendText).toHaveBeenCalledTimes(2); + expect(sendText).toHaveBeenCalledTimes(1); // never one message per node 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)', + '⚠️ battery low: @!00000abc (A) 5%, @!00000def (B) 3%', ]); }); @@ -169,6 +166,34 @@ describe('BatteryAlertService check cooldown', () => { vi.useRealTimers(); } }); + + it('excludes a node inside its cooldown from the batch while another outside it still triggers one message', async () => { + vi.useFakeTimers(); + try { + const sendText = vi.fn(async (..._args: unknown[]) => 1); + // only B is known at first — alert it, then A appears while B is still in cooldown + const nodes = [node(0xdef, 3)]; + const svc = makeService(nodes, sendText); + + await svc.check(); + expect(sendText).toHaveBeenCalledTimes(1); + expect(sendText.mock.calls[0][1]).toBe('⚠️ battery low: @!00000def 3%'); + + nodes.push(node(0xabc, 5)); // A is due, B is inside its window + await svc.check(); + expect(sendText).toHaveBeenCalledTimes(2); // one message — only A + expect(sendText.mock.calls[1][1]).toBe('⚠️ battery low: @!00000abc 5%'); + + vi.advanceTimersByTime(6 * 3_600_000); // B's cooldown expires too + await svc.check(); + expect(sendText).toHaveBeenCalledTimes(3); // both due again — one batched message with both + expect(sendText.mock.calls[2][1]).toBe( + '⚠️ battery low: @!00000def 3%, @!00000abc 5%', + ); + } finally { + vi.useRealTimers(); + } + }); }); describe('BatteryAlertService check end-to-end (real MeshService)', () => { @@ -222,7 +247,7 @@ describe('BatteryAlertService check end-to-end (real MeshService)', () => { ? pkt.payloadVariant.value : undefined; expect(new TextDecoder().decode(data?.payload ?? new Uint8Array())).toBe( - '⚠️ @!00000abc battery low: 5% (Alpha)', + '⚠️ battery low: @!00000abc (Alpha) 5%', ); } finally { delete process.env.MESH_HOST; diff --git a/src/battery/battery.service.ts b/src/battery/battery.service.ts index f0c825a..042acdd 100644 --- a/src/battery/battery.service.ts +++ b/src/battery/battery.service.ts @@ -9,11 +9,16 @@ export function mention(nodeNum: number): string { return `@!${nodeNum.toString(16).padStart(8, '0')}`; } -/** One short single-packet alert line, e.g. `⚠️ @!09a2b3c4 battery low: 15% (Kamil SP8MK/P)` — always well under the ~190-char single-packet limit. */ -export function formatAlert(node: NodeSummary): string { +/** One tight entry per node, e.g. `@!09a2b3c4 (Kamil SP8MK/P) 15%`. */ +export function formatEntry(node: NodeSummary): string { // no name reported → omit the paren part rather than invent one const name = node.longName ?? node.shortName; - return `⚠️ ${mention(node.num)} battery low: ${node.batteryLevel}%${name ? ` (${name})` : ''}`; + return `${mention(node.num)}${name ? ` (${name})` : ''} ${node.batteryLevel}%`; +} + +/** One short single-packet alert line for a whole batch, e.g. `⚠️ battery low: @!09a2b3c4 (Kamil SP8MK/P) 15%, @!0def5678 (KK) 9%` — entries kept tight so common cases stay near the ~190-char single-packet limit. */ +export function formatAlert(nodes: NodeSummary[]): string { + return `⚠️ battery low: ${nodes.map(formatEntry).join(', ')}`; } @Injectable() @@ -33,15 +38,16 @@ export class BatteryAlertService { constructor(private readonly mesh: MeshService) {} - /** Interval scan of the same node state that backs GET /api/nodes; broadcasts an alert per low-battery node not recently alerted. Never throws — a failed check must not crash the app. */ + /** Interval scan of the same node state that backs GET /api/nodes; collects every low-battery node not recently alerted and sends AT MOST ONE broadcast per cycle containing all of them. Never throws — a failed check must not crash the app. */ @Interval(parseInt(process.env.BATTERY_CHECK_INTERVAL_MS ?? '60000', 10)) async check(): Promise { try { const now = Date.now(); - for (const node of await this.mesh.nodes()) { - if (!this.shouldAlert(node, now)) continue; - await this.alert(node); - } + const due = (await this.mesh.nodes()).filter((n) => + this.shouldAlert(n, now), + ); + if (due.length === 0) return; // nothing to report — send nothing + await this.alert(due); } catch (err) { this.logger.warn(`[battery] check failed: ${(err as Error).message}`); } @@ -55,9 +61,10 @@ export class BatteryAlertService { return last === undefined || now - last >= this.cooldownMs; } - private async alert(node: NodeSummary): Promise { - // broadcast on the pinned send channel — same path as every other bot send (MeshService.sendText) - await this.mesh.sendText(BROADCAST_ADDR, formatAlert(node)); - this.lastAlertAt.set(node.num, Date.now()); + private async alert(nodes: NodeSummary[]): Promise { + // one broadcast per cycle for the whole batch — never one message per node; rides the pinned send channel like every other bot send (MeshService.sendText) + await this.mesh.sendText(BROADCAST_ADDR, formatAlert(nodes)); + const now = Date.now(); + for (const n of nodes) this.lastAlertAt.set(n.num, now); } }