CI / build + unit (pull_request) Successful in 1m0s
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:
@!<hex> (<name>) <X>%[, @!<hex> (<name>) <Y>%]' — single-node case uses
the same one-entry template. Cooldown semantics unchanged.
71 lines
3.3 KiB
TypeScript
71 lines
3.3 KiB
TypeScript
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);
|
|
}
|
|
}
|