From 1b712f7eeeb54a74a065f8312f554679f3124ad5 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 25 Sep 2026 13:02:56 +0000 Subject: [PATCH] battery: send alerts on channel "testowy" (index 2), not the pinned send channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner request. BatteryAlertService reads new env BATTERY_ALERT_CHANNEL (default 2) at construction and passes it through MeshService.sendText, which gains an optional trailing channel argument defaulting to the pinned MESH_SEND_CHANNEL — every other sender is untouched. Threshold, cooldown, merged one-message-per-cycle format and @mentions unchanged. --- CHANGELOG.md | 5 ++++ package.json | 2 +- src/battery/battery.service.spec.ts | 37 +++++++++++++++++++++++++---- src/battery/battery.service.ts | 14 +++++++++-- src/mesh/mesh.service.ts | 19 ++++++++------- 5 files changed, 60 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb93719..dc02195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v0.13.1 — 2026-09-25 13:02 + +- Battery-low alerts now ride channel "testowy" (index 2) instead of the pinned send channel ("test", index 3): `BatteryAlertService` reads new env `BATTERY_ALERT_CHANNEL` at construction (default `2`, same pattern as its other env config) and passes it through the existing `MeshService.sendText(to, text, replyId?, channel?)` path — which gained an optional trailing `channel` argument defaulting to the pinned `MESH_SEND_CHANNEL`, so every other sender (API, MCP, weather) is untouched. Threshold, cooldown, merged one-message-per-cycle format and @mentions are unchanged. +- Tests: battery specs now assert the send carries channel index 2 by default plus a `BATTERY_ALERT_CHANNEL` override case; the end-to-end case asserts the alert frame rides channel 2 while the pinned send channel stays at its default 3. All pre-existing specs stay green. + ## 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 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. diff --git a/package.json b/package.json index cdb565c..0d6ae26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.13.0", + "version": "0.13.1", "description": "", "author": "", "private": true, diff --git a/src/battery/battery.service.spec.ts b/src/battery/battery.service.spec.ts index f38592c..3ad7516 100644 --- a/src/battery/battery.service.spec.ts +++ b/src/battery/battery.service.spec.ts @@ -33,9 +33,10 @@ function makeService( } beforeEach(() => { - // defaults under test: threshold 20, cooldown 6 h + // defaults under test: threshold 20, cooldown 6 h, channel 2 ("testowy") delete process.env.BATTERY_ALERT_THRESHOLD; delete process.env.BATTERY_ALERT_COOLDOWN_MS; + delete process.env.BATTERY_ALERT_CHANNEL; }); describe('mention', () => { @@ -79,11 +80,13 @@ describe('BatteryAlertService check threshold', () => { await svc.check(); - // <= choice: exactly 20 qualifies too — both in ONE batched message + // <= choice: exactly 20 qualifies too — both in ONE batched message, on the default channel 2 expect(sendText).toHaveBeenCalledTimes(1); expect(sendText).toHaveBeenCalledWith( BROADCAST_ADDR, '⚠️ battery low: @!00000001 19%, @!00000002 20%', + undefined, + 2, ); }); @@ -99,6 +102,8 @@ describe('BatteryAlertService check threshold', () => { expect(sendText).toHaveBeenCalledWith( BROADCAST_ADDR, '⚠️ battery low: @!00000001 50%', + undefined, + 2, ); } finally { delete process.env.BATTERY_ALERT_THRESHOLD; @@ -127,9 +132,31 @@ describe('BatteryAlertService check threshold', () => { expect(sendText.mock.calls[0]).toEqual([ BROADCAST_ADDR, '⚠️ battery low: @!00000abc (A) 5%, @!00000def (B) 3%', + undefined, + 2, // default channel "testowy" — not the pinned send channel (3) ]); }); + it('sends on a custom BATTERY_ALERT_CHANNEL when set', async () => { + process.env.BATTERY_ALERT_CHANNEL = '5'; + const sendText = vi.fn(async (..._args: unknown[]) => 1); + try { + const svc = makeService([node(0xabc, 5)], sendText); + + await svc.check(); + + expect(sendText).toHaveBeenCalledTimes(1); + expect(sendText.mock.calls[0]).toEqual([ + BROADCAST_ADDR, + '⚠️ battery low: @!00000abc 5%', + undefined, + 5, + ]); + } finally { + delete process.env.BATTERY_ALERT_CHANNEL; + } + }); + it('never throws when a send fails', async () => { const svc = makeService( [node(1, 5)], @@ -197,11 +224,11 @@ describe('BatteryAlertService check cooldown', () => { }); describe('BatteryAlertService check end-to-end (real MeshService)', () => { - it('broadcasts the alert on the pinned send channel (index 3 by default)', async () => { + it('broadcasts the alert on "testowy" (channel index 2), not the pinned send channel', 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) + delete process.env.MESH_SEND_CHANNEL; // default pinned send channel is 3 — the alert must NOT ride it const mesh = new RealMeshService(); try { mesh.onModuleInit(); @@ -241,7 +268,7 @@ describe('BatteryAlertService check end-to-end (real MeshService)', () => { await waitFor(() => readOutbound().length === 1); const pkt = readOutbound()[0]; - expect(pkt.channel).toBe(3); // "test" — never the primary channel (0) + expect(pkt.channel).toBe(2); // "testowy" — not the pinned send channel (3) or primary (0) const data = pkt.payloadVariant?.case === 'decoded' ? pkt.payloadVariant.value diff --git a/src/battery/battery.service.ts b/src/battery/battery.service.ts index 042acdd..362614e 100644 --- a/src/battery/battery.service.ts +++ b/src/battery/battery.service.ts @@ -33,6 +33,11 @@ export class BatteryAlertService { process.env.BATTERY_ALERT_COOLDOWN_MS ?? '21600000', // default 6 h 10, ); + // alerts ride "testowy" (index 2), not the pinned send channel — owner decision + private readonly alertChannel = parseInt( + process.env.BATTERY_ALERT_CHANNEL ?? '2', + 10, + ); // ponytail: in-memory cooldown map — resets on process restart (a node may get one duplicate alert after a deploy) private lastAlertAt = new Map(); @@ -62,8 +67,13 @@ export class BatteryAlertService { } 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)); + // one broadcast per cycle for the whole batch — never one message per node; rides BATTERY_ALERT_CHANNEL ("testowy", default index 2) via the same MeshService.sendText path as every other bot send + await this.mesh.sendText( + BROADCAST_ADDR, + formatAlert(nodes), + undefined, + this.alertChannel, + ); const now = Date.now(); for (const n of nodes) this.lastAlertAt.set(n.num, now); } diff --git a/src/mesh/mesh.service.ts b/src/mesh/mesh.service.ts index 349f4d8..da51ac2 100644 --- a/src/mesh/mesh.service.ts +++ b/src/mesh/mesh.service.ts @@ -490,22 +490,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { return [...this.telemetryHistory].reverse(); } - /** Send `text` to node `to` on the pinned test channel; returns the packet id. Optional `replyId` marks the message as a reply to an earlier packet (the recipient app renders it as a threaded quote). Throws ServiceUnavailable when the bridge is down. */ - async sendText(to: number, text: string, replyId?: number): Promise { + /** Send `text` to node `to`; returns the packet id. Rides the pinned test channel unless an explicit `channel` index is given (e.g. battery alerts on "testowy"). Optional `replyId` marks the message as a reply to an earlier packet (the recipient app renders it as a threaded quote). Throws ServiceUnavailable when the bridge is down. */ + async sendText( + to: number, + text: string, + replyId?: number, + channel?: number, + ): Promise { if (!this.connected || !this.socket) throw new ServiceUnavailableException('bridge not connected'); - const { bytes, id } = await buildTextToRadio( - to, - text, - this.sendChannel, - replyId, - ); + const ch = channel ?? this.sendChannel; + const { bytes, id } = await buildTextToRadio(to, text, ch, replyId); const rec: MessageRecord = { id, dir: 'tx', to, text, - channel: this.sendChannel, + channel: ch, at: Date.now(), status: 'pending', };