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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user