battery: low-battery alert module (v0.13.0)

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: <X>% (<name>)' 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.
This commit is contained in:
2026-09-25 12:39:45 +00:00
parent 8ddd91d4b5
commit d0b71e6e91
7 changed files with 339 additions and 2 deletions
+63
View File
@@ -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<number, number>();
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<void> {
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<void> {
// 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());
}
}