battery: merge multiple low-battery nodes into one broadcast per cycle
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.
This commit is contained in:
2026-09-25 12:51:25 +00:00
parent d0b71e6e91
commit fe8d2dbe0b
3 changed files with 73 additions and 41 deletions
+50 -25
View File
@@ -47,21 +47,27 @@ describe('mention', () => {
});
describe('formatAlert', () => {
it('builds the full single-packet message with longName', () => {
expect(formatAlert(node(0x1234abcd, 15, 'Kamil SP8MK/P'))).toBe(
'⚠️ @!1234abcd battery low: 15% (Kamil SP8MK/P)',
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(
'⚠️ @!00000abc battery low: 9% (KK)',
expect(formatAlert([node(0xabc, 9, undefined, 'KK')])).toBe(
'⚠️ battery low: @!00000abc (KK) 9%',
);
expect(formatAlert(node(0xabc, 9))).toBe('⚠️ @!00000abc battery low: 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)));
const msg = formatAlert([node(0xffffffff, 5, 'A'.repeat(64))]);
expect(msg.length).toBeLessThan(190);
});
});
@@ -73,16 +79,11 @@ describe('BatteryAlertService check threshold', () => {
await svc.check();
expect(sendText).toHaveBeenCalledTimes(2); // <= choice: exactly 20 alerts too
expect(sendText).toHaveBeenNthCalledWith(
1,
// <= choice: exactly 20 qualifies too — both in ONE batched message
expect(sendText).toHaveBeenCalledTimes(1);
expect(sendText).toHaveBeenCalledWith(
BROADCAST_ADDR,
'⚠️ @!00000001 battery low: 19%',
);
expect(sendText).toHaveBeenNthCalledWith(
2,
BROADCAST_ADDR,
'⚠️ @!00000002 battery low: 20%',
'⚠️ battery low: @!00000001 19%, @!00000002 20%',
);
});
@@ -97,7 +98,7 @@ describe('BatteryAlertService check threshold', () => {
expect(sendText).toHaveBeenCalledTimes(1);
expect(sendText).toHaveBeenCalledWith(
BROADCAST_ADDR,
'⚠️ @!00000001 battery low: 50%',
'⚠️ battery low: @!00000001 50%',
);
} finally {
delete process.env.BATTERY_ALERT_THRESHOLD;
@@ -113,7 +114,7 @@ describe('BatteryAlertService check threshold', () => {
expect(sendText).not.toHaveBeenCalled();
});
it('alerts once per qualifying node with the broadcast target', async () => {
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')],
@@ -122,14 +123,10 @@ describe('BatteryAlertService check threshold', () => {
await svc.check();
expect(sendText).toHaveBeenCalledTimes(2);
expect(sendText).toHaveBeenCalledTimes(1); // never one message per node
expect(sendText.mock.calls[0]).toEqual([
BROADCAST_ADDR,
'⚠️ @!00000abc battery low: 5% (A)',
]);
expect(sendText.mock.calls[1]).toEqual([
BROADCAST_ADDR,
'⚠️ @!00000def battery low: 3% (B)',
'⚠️ battery low: @!00000abc (A) 5%, @!00000def (B) 3%',
]);
});
@@ -169,6 +166,34 @@ describe('BatteryAlertService check cooldown', () => {
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)', () => {
@@ -222,7 +247,7 @@ describe('BatteryAlertService check end-to-end (real MeshService)', () => {
? pkt.payloadVariant.value
: undefined;
expect(new TextDecoder().decode(data?.payload ?? new Uint8Array())).toBe(
'⚠️ @!00000abc battery low: 5% (Alpha)',
'⚠️ battery low: @!00000abc (Alpha) 5%',
);
} finally {
delete process.env.MESH_HOST;
+19 -12
View File
@@ -9,11 +9,16 @@ 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 {
/** 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)} battery low: ${node.batteryLevel}%${name ? ` (${name})` : ''}`;
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()
@@ -33,15 +38,16 @@ export class BatteryAlertService {
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 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();
for (const node of await this.mesh.nodes()) {
if (!this.shouldAlert(node, now)) continue;
await this.alert(node);
}
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}`);
}
@@ -55,9 +61,10 @@ export class BatteryAlertService {
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());
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);
}
}