From 8ddd91d4b5ce495e9108703649742b0006897233 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 25 Sep 2026 12:39:45 +0000 Subject: [PATCH 1/3] mesh: update node batteryLevel from live NODEINFO_APP packets battery_level was only populated during the initial sync; NodeInfo frames streamed as NODEINFO_APP (port 4) now refresh deviceMetrics in the node database so /api/nodes and downstream consumers see current values between syncs. decodeNodeInfo + PORT_NODEINFO_APP added to the codec, one branch in the existing packet-handling path. --- src/mesh/codec.ts | 10 +++++ src/mesh/mesh.service.spec.ts | 71 +++++++++++++++++++++++++++++++++++ src/mesh/mesh.service.ts | 19 ++++++++++ 3 files changed, 100 insertions(+) diff --git a/src/mesh/codec.ts b/src/mesh/codec.ts index 68f51e6..cebc3ef 100644 --- a/src/mesh/codec.ts +++ b/src/mesh/codec.ts @@ -12,6 +12,8 @@ export const BROADCAST_ADDR = 0xffffffff; export const PORT_TEXT_MESSAGE_APP = 1; // verified against installed @meshtastic/protobufs Portnums.PortNum.POSITION_APP export const PORT_POSITION_APP = 3; +// verified against installed @meshtastic/protobufs Portnums.PortNum.NODEINFO_APP +export const PORT_NODEINFO_APP = 4; // verified against installed @meshtastic/protobufs Portnums.PortNum.ROUTING_APP export const PORT_ROUTING_APP = 5; // verified against installed @meshtastic/protobufs Portnums.PortNum.TELEMETRY_APP @@ -75,6 +77,14 @@ export async function decodePosition( return fromBinary(P.Mesh.PositionSchema, bytes); } +/** NODEINFO_APP packets carry a serialized meshtastic.NodeInfo in Data.payload (live node info; deviceMetrics carries battery_level). */ +export async function decodeNodeInfo( + bytes: Uint8Array, +): Promise { + const P = await loadProto(); + return fromBinary(P.Mesh.NodeInfoSchema, bytes); +} + /** ROUTING_APP packets carry a serialized meshtastic.Routing in Data.payload (ACK/NACK for want_ack sends). */ export async function decodeRouting( bytes: Uint8Array, diff --git a/src/mesh/mesh.service.spec.ts b/src/mesh/mesh.service.spec.ts index cc0699a..fc10c7c 100644 --- a/src/mesh/mesh.service.spec.ts +++ b/src/mesh/mesh.service.spec.ts @@ -44,6 +44,36 @@ function positionPacketFrame( ); } +// live node-info update: NODEINFO_APP packet with a serialized NodeInfo in Data.payload +function nodeInfoPacketFrame( + from: number, + fields: { + user?: { id?: string; longName?: string }; + deviceMetrics?: { batteryLevel?: number }; + }, +): Uint8Array { + const data = create(P.Mesh.DataSchema, { + portnum: P.Portnums.PortNum.NODEINFO_APP, + payload: toBinary( + P.Mesh.NodeInfoSchema, + create(P.Mesh.NodeInfoSchema, { num: from, ...fields }), + ), + }); + 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, @@ -418,6 +448,47 @@ describe('MeshService node database', () => { } }); + it('updates batteryLevel from live NODEINFO_APP packets (and stubs 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, + ); + + // sync entry with a stale battery, then a live NODEINFO_APP update + bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); + await waitFor(async () => (await svc.nodes()).length === 1); + expect((await svc.nodes())[0]?.batteryLevel).toBe(87); + + bridge.sockets[0].write( + nodeInfoPacketFrame(KAMIL_NUM, { deviceMetrics: { batteryLevel: 9 } }), + ); + + await waitFor(async () => (await svc.nodes())[0]?.batteryLevel === 9); + // other fields of the sync entry survive the live update + expect(await svc.node(KAMIL_NUM)).toMatchObject({ + longName: 'Kamil SP8MK/P', + shortName: 'KK', + batteryLevel: 9, + }); + + // unknown node: a NODEINFO_APP with deviceMetrics creates a stub entry + bridge.sockets[0].write( + nodeInfoPacketFrame(555, { deviceMetrics: { batteryLevel: 3 } }), + ); + + await waitFor(async () => (await svc.nodes()).length === 2); + expect((await svc.node(555))?.batteryLevel).toBe(3); + } 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'; diff --git a/src/mesh/mesh.service.ts b/src/mesh/mesh.service.ts index 3cfeba0..349f4d8 100644 --- a/src/mesh/mesh.service.ts +++ b/src/mesh/mesh.service.ts @@ -11,6 +11,7 @@ import type { DatabaseSync } from 'node:sqlite'; import type * as Proto from '@meshtastic/protobufs'; import { BROADCAST_ADDR, + PORT_NODEINFO_APP, PORT_POSITION_APP, PORT_ROUTING_APP, PORT_TELEMETRY_APP, @@ -18,6 +19,7 @@ import { buildTextToRadio, buildWantConfigToRadio, decodeFromRadio, + decodeNodeInfo, decodePosition, decodeRouting, decodeTelemetry, @@ -676,6 +678,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { position: pos, }); } + } else if (decoded && decoded.portnum === PORT_NODEINFO_APP) { + // live node-info update: NodeInfo serialized in Data.payload; deviceMetrics carries + // battery_level so /api/nodes and the battery alerts see fresh values between syncs + let ni: Proto.Mesh.NodeInfo | undefined; + try { + ni = await decodeNodeInfo(decoded.payload); + } catch { + ni = undefined; // malformed payload — skip, keep whatever we had + } + if (ni?.deviceMetrics !== undefined) { + const num = pkt.from; + this.nodeMap.set(num, { + ...this.nodeMap.get(num), + num, + deviceMetrics: ni.deviceMetrics, + }); + } } else if (decoded && decoded.portnum === PORT_ROUTING_APP) { // ACK/NACK for our want_ack sends: Routing serialized in Data.payload, // Data.requestId = the original packet id we sent -- 2.54.0 From d0b71e6e9109a9556c27e48ece7859562a9319ea Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 25 Sep 2026 12:39:45 +0000 Subject: [PATCH 2/3] battery: low-battery alert module (v0.13.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BatteryAlertModule + BatteryAlertService wired into app.module like the weather module. Every BATTERY_CHECK_INTERVAL_MS (default 60 s) the service scans the node state backing GET /api/nodes and broadcasts a single-packet '⚠️ @!<8-hex> battery low: % ()' alert for every node at or below BATTERY_ALERT_THRESHOLD percent (default 20, <= on purpose) not alerted within BATTERY_ALERT_COOLDOWN_MS (default 6 h, in-memory map — resets on restart). Sends ride the pinned send channel via MeshService.sendText like every other bot send. --- CHANGELOG.md | 8 + package.json | 2 +- src/app.module.ts | 3 +- src/battery/battery.module.ts | 11 ++ src/battery/battery.service.spec.ts | 235 ++++++++++++++++++++++++++++ src/battery/battery.service.ts | 63 ++++++++ src/battery/di.spec.ts | 19 +++ 7 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 src/battery/battery.module.ts create mode 100644 src/battery/battery.service.spec.ts create mode 100644 src/battery/battery.service.ts create mode 100644 src/battery/di.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a4ab1e..c451a16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 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). +- 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. + ## v0.12.8 — 2026-09-25 12:04 - Docs (MESHTASTIC.md response protocol): new standing rule per owner request ("remember to always replay on reply on channel when question is send here") — questions received via mesh are **answered on the channel**: every question sent via mesh gets its actual answer broadcast back on the same channel when work completes; an acknowledgement alone is not sufficient, and the answer must not stay only in the private agent session. All existing bullets (always-respond-first, channel-only/no DMs, owner authority 2594370888, language, NO_RESPONSE quirk, self-echo note) unchanged. No code changes — all 64 tests untouched and green. diff --git a/package.json b/package.json index 45e56e1..cdb565c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.12.8", + "version": "0.13.0", "description": "", "author": "", "private": true, diff --git a/src/app.module.ts b/src/app.module.ts index 063d97b..73e7afc 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; +import { BatteryAlertModule } from './battery/battery.module.js'; import { MeshModule } from './mesh/mesh.module.js'; import { WeatherModule } from './weather/weather.module.js'; @Module({ - imports: [MeshModule, WeatherModule], + imports: [MeshModule, WeatherModule, BatteryAlertModule], }) export class AppModule {} diff --git a/src/battery/battery.module.ts b/src/battery/battery.module.ts new file mode 100644 index 0000000..15e4035 --- /dev/null +++ b/src/battery/battery.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { MeshModule } from '../mesh/mesh.module.js'; +import { BatteryAlertService } from './battery.service.js'; + +@Module({ + imports: [ScheduleModule.forRoot(), MeshModule], + providers: [BatteryAlertService], + exports: [BatteryAlertService], +}) +export class BatteryAlertModule {} diff --git a/src/battery/battery.service.spec.ts b/src/battery/battery.service.spec.ts new file mode 100644 index 0000000..9ca6793 --- /dev/null +++ b/src/battery/battery.service.spec.ts @@ -0,0 +1,235 @@ +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(); + } + }); +}); diff --git a/src/battery/battery.service.ts b/src/battery/battery.service.ts new file mode 100644 index 0000000..f0c825a --- /dev/null +++ b/src/battery/battery.service.ts @@ -0,0 +1,63 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Interval } from '@nestjs/schedule'; +import { BROADCAST_ADDR } from '../mesh/codec.js'; +import type { NodeSummary } from '../mesh/mesh.service.js'; +import { MeshService } from '../mesh/mesh.service.js'; + +/** Meshtastic mention of a node: `@!` + 8-char zero-padded lowercase hex, e.g. `@!09a2b3c4`. */ +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 { + // 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})` : ''}`; +} + +@Injectable() +export class BatteryAlertService { + private readonly logger = new Logger(BatteryAlertService.name); + // read at construction, like MeshService's env config + private readonly threshold = parseInt( + process.env.BATTERY_ALERT_THRESHOLD ?? '20', + 10, + ); + private readonly cooldownMs = parseInt( + process.env.BATTERY_ALERT_COOLDOWN_MS ?? '21600000', // default 6 h + 10, + ); + // ponytail: in-memory cooldown map — resets on process restart (a node may get one duplicate alert after a deploy) + private lastAlertAt = new Map(); + + 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(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); + } + } catch (err) { + this.logger.warn(`[battery] check failed: ${(err as Error).message}`); + } + } + + private shouldAlert(node: NodeSummary, now: number): boolean { + if (node.batteryLevel === undefined) return false; // no battery reported — skip + // <= on purpose: a node at exactly the threshold is already "low" (spec allowed either < or <=; tests pin this choice) + if (!(node.batteryLevel <= this.threshold)) return false; + const last = this.lastAlertAt.get(node.num); + 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()); + } +} diff --git a/src/battery/di.spec.ts b/src/battery/di.spec.ts new file mode 100644 index 0000000..604f6b0 --- /dev/null +++ b/src/battery/di.spec.ts @@ -0,0 +1,19 @@ +import { Test } from '@nestjs/testing'; +import { describe, expect, it } from 'vitest'; +import { MeshService } from '../mesh/mesh.service.js'; +import { BatteryAlertModule } from './battery.module.js'; +import { BatteryAlertService } from './battery.service.js'; + +describe('DI wiring (module graph)', () => { + // compile() instantiates providers but does not run lifecycle hooks, so no TCP connection is opened. + it('resolves BatteryAlertService with MeshService provided by MeshModule', async () => { + process.env.MESH_DB_PATH = ':memory:'; // keep openMessageDb from touching the repo's ./data + const moduleRef = await Test.createTestingModule({ + imports: [BatteryAlertModule], + }).compile(); + expect(moduleRef.get(BatteryAlertService)).toBeInstanceOf( + BatteryAlertService, + ); + expect(moduleRef.get(MeshService)).toBeInstanceOf(MeshService); + }); +}); -- 2.54.0 From fe8d2dbe0bff6ebf8fa379d02baa6c43942c7ebd Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 25 Sep 2026 12:51:25 +0000 Subject: [PATCH 3/3] battery: merge multiple low-battery nodes into one broadcast per cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner follow-up: 'if multiple ppl shows at once — merge messages to one'. check() now collects every qualifying node (at/below threshold and not within its per-node cooldown) and sends AT MOST ONE broadcast per cycle containing all of them, recording the alert timestamp for each after sending; nothing is sent when none qualify. Format: '⚠️ battery low: @! () %[, @! () %]' — single-node case uses the same one-entry template. Cooldown semantics unchanged. --- CHANGELOG.md | 8 +-- src/battery/battery.service.spec.ts | 75 +++++++++++++++++++---------- src/battery/battery.service.ts | 31 +++++++----- 3 files changed, 73 insertions(+), 41 deletions(-) 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); } } -- 2.54.0