CI / build + unit (pull_request) Successful in 1m18s
A 0% batteryLevel is an invalid/no-data value (no battery wired or
misconfigured node), not a genuinely critical battery, so it must not
produce a "⚠️ battery low" alert. Add a non-positive guard to
shouldAlert after the undefined check: 0 (and any non-positive) now
skips, while 1-20 still alert and >20 still doesn't. Threshold/cooldown/
batching unchanged.
Tests: 3 new specs in the check-threshold block (0% excluded from the
batch, lone 0% sends nothing, 1% still alerts). 180 -> 183.
329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
import { fromBinary } from '@bufbuild/protobuf';
|
|
import * as P from '@meshtastic/protobufs';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { BROADCAST_ADDR, extractFrames } from '../mesh/codec.js';
|
|
import { nodeInfoFrame, startBridge, waitFor } from '../mesh/fake-bridge.js';
|
|
import type { MeshService, NodeSummary } from '../mesh/mesh.service.js';
|
|
import { MeshService as RealMeshService } from '../mesh/mesh.service.js';
|
|
import {
|
|
BatteryAlertService,
|
|
formatAlert,
|
|
mention,
|
|
} from './battery.service.js';
|
|
|
|
function node(
|
|
num: number,
|
|
batteryLevel?: number,
|
|
longName?: string,
|
|
shortName?: string,
|
|
): NodeSummary {
|
|
const n: NodeSummary = { num, lastHeard: null };
|
|
if (batteryLevel !== undefined) n.batteryLevel = batteryLevel;
|
|
if (longName) n.longName = longName;
|
|
if (shortName) n.shortName = shortName;
|
|
return n;
|
|
}
|
|
|
|
function makeService(
|
|
nodes: NodeSummary[],
|
|
sendText?: ReturnType<typeof vi.fn>,
|
|
): BatteryAlertService {
|
|
const mesh = { nodes: async () => nodes, sendText } as unknown as MeshService;
|
|
return new BatteryAlertService(mesh);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
// defaults under test: threshold 20, cooldown 6 h, channel 2 ("testowy")
|
|
delete process.env.BATTERY_ALERT_THRESHOLD;
|
|
delete process.env.BATTERY_ALERT_COOLDOWN_MS;
|
|
delete process.env.BATTERY_ALERT_CHANNEL;
|
|
});
|
|
|
|
describe('mention', () => {
|
|
it('zero-pads to 8 chars and lowercases the hex', () => {
|
|
expect(mention(0xabc)).toBe('@!00000abc'); // padded
|
|
expect(mention(0xffffffff)).toBe('@!ffffffff'); // already 8 chars — no padding needed
|
|
expect(mention(0xdeadbeef)).toBe('@!deadbeef'); // lowercase
|
|
});
|
|
});
|
|
|
|
describe('formatAlert', () => {
|
|
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(
|
|
'⚠️ battery low: @!00000abc (KK) 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))]);
|
|
expect(msg.length).toBeLessThan(190);
|
|
});
|
|
});
|
|
|
|
describe('BatteryAlertService check threshold', () => {
|
|
it('alerts at and below the default threshold (20) but not above', async () => {
|
|
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
|
const svc = makeService([node(1, 19), node(2, 20), node(3, 21)], sendText);
|
|
|
|
await svc.check();
|
|
|
|
// <= choice: exactly 20 qualifies too — both in ONE batched message, on the default channel 2
|
|
expect(sendText).toHaveBeenCalledTimes(1);
|
|
expect(sendText).toHaveBeenCalledWith(
|
|
BROADCAST_ADDR,
|
|
'⚠️ battery low: @!00000001 19%, @!00000002 20%',
|
|
undefined,
|
|
2,
|
|
);
|
|
});
|
|
|
|
it('honors a custom BATTERY_ALERT_THRESHOLD', async () => {
|
|
process.env.BATTERY_ALERT_THRESHOLD = '50';
|
|
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
|
try {
|
|
const svc = makeService([node(1, 50), node(2, 51)], sendText);
|
|
|
|
await svc.check();
|
|
|
|
expect(sendText).toHaveBeenCalledTimes(1);
|
|
expect(sendText).toHaveBeenCalledWith(
|
|
BROADCAST_ADDR,
|
|
'⚠️ battery low: @!00000001 50%',
|
|
undefined,
|
|
2,
|
|
);
|
|
} finally {
|
|
delete process.env.BATTERY_ALERT_THRESHOLD;
|
|
}
|
|
});
|
|
|
|
it('skips nodes without a reported battery level', async () => {
|
|
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
|
const svc = makeService([node(1), node(2, 87)], sendText); // no battery / healthy
|
|
|
|
await svc.check();
|
|
|
|
expect(sendText).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ignores a 0% reading (invalid/no battery) even though 0 <= threshold', async () => {
|
|
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
|
// 0% must not alert, but a genuinely-low 5% node in the same batch must
|
|
const svc = makeService([node(1, 0), node(2, 5)], sendText);
|
|
|
|
await svc.check();
|
|
|
|
expect(sendText).toHaveBeenCalledTimes(1);
|
|
// only the 5% node — the 0% node is excluded entirely
|
|
expect(sendText.mock.calls[0]).toEqual([
|
|
BROADCAST_ADDR,
|
|
'⚠️ battery low: @!00000002 5%',
|
|
undefined,
|
|
2,
|
|
]);
|
|
});
|
|
|
|
it('ignores a lone 0% reading (no broadcast at all)', async () => {
|
|
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
|
const svc = makeService([node(1, 0)], sendText); // only an invalid 0% reading
|
|
|
|
await svc.check();
|
|
|
|
expect(sendText).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('still alerts a genuine 1% reading (lowest valid value)', async () => {
|
|
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
|
const svc = makeService([node(1, 1)], sendText);
|
|
|
|
await svc.check();
|
|
|
|
expect(sendText).toHaveBeenCalledTimes(1);
|
|
expect(sendText.mock.calls[0]).toEqual([
|
|
BROADCAST_ADDR,
|
|
'⚠️ battery low: @!00000001 1%',
|
|
undefined,
|
|
2,
|
|
]);
|
|
});
|
|
|
|
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')],
|
|
sendText,
|
|
);
|
|
|
|
await svc.check();
|
|
|
|
expect(sendText).toHaveBeenCalledTimes(1); // never one message per node
|
|
expect(sendText.mock.calls[0]).toEqual([
|
|
BROADCAST_ADDR,
|
|
'⚠️ battery low: @!00000abc (A) 5%, @!00000def (B) 3%',
|
|
undefined,
|
|
2, // default channel "testowy" — not the pinned send channel (3)
|
|
]);
|
|
});
|
|
|
|
it('sends on a custom BATTERY_ALERT_CHANNEL when set', async () => {
|
|
process.env.BATTERY_ALERT_CHANNEL = '5';
|
|
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
|
try {
|
|
const svc = makeService([node(0xabc, 5)], sendText);
|
|
|
|
await svc.check();
|
|
|
|
expect(sendText).toHaveBeenCalledTimes(1);
|
|
expect(sendText.mock.calls[0]).toEqual([
|
|
BROADCAST_ADDR,
|
|
'⚠️ battery low: @!00000abc 5%',
|
|
undefined,
|
|
5,
|
|
]);
|
|
} finally {
|
|
delete process.env.BATTERY_ALERT_CHANNEL;
|
|
}
|
|
});
|
|
|
|
it('never throws when a send fails', async () => {
|
|
const svc = makeService(
|
|
[node(1, 5)],
|
|
vi.fn(async (..._args: unknown[]) => {
|
|
throw new Error('bridge down');
|
|
}),
|
|
);
|
|
|
|
await expect(svc.check()).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('BatteryAlertService check cooldown', () => {
|
|
it('suppresses re-alerts within the window and allows them after expiry (default 6 h)', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
|
const svc = makeService([node(0xabc, 5)], sendText);
|
|
|
|
await svc.check();
|
|
expect(sendText).toHaveBeenCalledTimes(1); // first alert
|
|
|
|
await svc.check();
|
|
expect(sendText).toHaveBeenCalledTimes(1); // immediate re-check — suppressed
|
|
|
|
vi.advanceTimersByTime(6 * 3_600_000 - 1);
|
|
await svc.check();
|
|
expect(sendText).toHaveBeenCalledTimes(1); // still inside the window
|
|
|
|
vi.advanceTimersByTime(1);
|
|
await svc.check();
|
|
expect(sendText).toHaveBeenCalledTimes(2); // cooldown elapsed — re-alerted
|
|
} finally {
|
|
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)', () => {
|
|
it('broadcasts the alert on "testowy" (channel index 2), not the pinned send channel', async () => {
|
|
const bridge = await startBridge();
|
|
process.env.MESH_HOST = '127.0.0.1';
|
|
process.env.MESH_PORT = String(bridge.port);
|
|
delete process.env.MESH_SEND_CHANNEL; // default pinned send channel is 3 — the alert must NOT ride it
|
|
const mesh = new RealMeshService();
|
|
try {
|
|
mesh.onModuleInit();
|
|
await waitFor(
|
|
() => bridge.sockets.length === 1 && mesh.status().connected,
|
|
);
|
|
|
|
bridge.sockets[0].write(
|
|
nodeInfoFrame(0xabc, {
|
|
user: { id: '!a', longName: 'Alpha' },
|
|
deviceMetrics: { batteryLevel: 5 },
|
|
}),
|
|
);
|
|
await waitFor(async () => (await mesh.nodes()).length === 1);
|
|
|
|
const out: Uint8Array[] = [];
|
|
bridge.sockets[0].on('data', (c) => out.push(c));
|
|
const svc = new BatteryAlertService(mesh);
|
|
await svc.check();
|
|
|
|
const readOutbound = (): P.Mesh.MeshPacket[] => {
|
|
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
|
|
let off = 0;
|
|
for (const c of out) {
|
|
merged.set(c, off);
|
|
off += c.length;
|
|
}
|
|
const { packets } = extractFrames(merged);
|
|
// outbound starts with the want_config_id frame — collect only text packets, in order
|
|
return packets.flatMap((p) => {
|
|
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
|
|
if (tr.payloadVariant?.case === 'packet')
|
|
return [tr.payloadVariant.value];
|
|
return [];
|
|
});
|
|
};
|
|
|
|
await waitFor(() => readOutbound().length === 1);
|
|
const pkt = readOutbound()[0];
|
|
expect(pkt.channel).toBe(2); // "testowy" — not the pinned send channel (3) or primary (0)
|
|
const data =
|
|
pkt.payloadVariant?.case === 'decoded'
|
|
? pkt.payloadVariant.value
|
|
: undefined;
|
|
expect(new TextDecoder().decode(data?.payload ?? new Uint8Array())).toBe(
|
|
'⚠️ battery low: @!00000abc (Alpha) 5%',
|
|
);
|
|
} finally {
|
|
delete process.env.MESH_HOST;
|
|
delete process.env.MESH_PORT;
|
|
delete process.env.MESH_SEND_CHANNEL;
|
|
mesh.onModuleDestroy();
|
|
bridge.close();
|
|
}
|
|
});
|
|
});
|