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;