Merge pull request 'v0.13.0 — battery-low alert module (merged broadcasts, @mentions, cooldown)' (#33) from feat/battery-alerts into main

This commit is contained in:
2026-09-25 14:53:01 +02:00
10 changed files with 471 additions and 2 deletions
+8
View File
@@ -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 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> (<longName or shortName>) <X>%` with additional nodes appended as `, @!<hex> (<name>) <Y>%` (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<nodeNum, lastAlertEpochMs>` — 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, 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
- 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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mesh-bot",
"version": "0.12.8",
"version": "0.13.0",
"description": "",
"author": "",
"private": true,
+2 -1
View File
@@ -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 {}
+11
View File
@@ -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 {}
+260
View File
@@ -0,0 +1,260 @@
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<typeof vi.fn>,
): 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 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 ", <entry>" 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(
'⚠️ battery low: @!00000abc (KK) 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))]);
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();
// <= choice: exactly 20 qualifies too — both in ONE batched message
expect(sendText).toHaveBeenCalledTimes(1);
expect(sendText).toHaveBeenCalledWith(
BROADCAST_ADDR,
'⚠️ battery low: @!00000001 19%, @!00000002 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,
'⚠️ battery low: @!00000001 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('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')],
sendText,
);
await svc.check();
expect(sendText).toHaveBeenCalledTimes(1); // never one message per node
expect(sendText.mock.calls[0]).toEqual([
BROADCAST_ADDR,
'⚠️ battery low: @!00000abc (A) 5%, @!00000def (B) 3%',
]);
});
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();
}
});
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)', () => {
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(
'⚠️ battery low: @!00000abc (Alpha) 5%',
);
} finally {
delete process.env.MESH_HOST;
delete process.env.MESH_PORT;
delete process.env.MESH_SEND_CHANNEL;
mesh.onModuleDestroy();
bridge.close();
}
});
});
+70
View File
@@ -0,0 +1,70 @@
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 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)}${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()
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<number, number>();
constructor(private readonly mesh: MeshService) {}
/** 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<void> {
try {
const now = Date.now();
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}`);
}
}
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(nodes: NodeSummary[]): Promise<void> {
// 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);
}
}
+19
View File
@@ -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);
});
});
+10
View File
@@ -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<import('@meshtastic/protobufs').Mesh.NodeInfo> {
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,
+71
View File
@@ -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';
+19
View File
@@ -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