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, ); // 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(); 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 { 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 { // 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); } }