Merge pull request 'Battery alerts → testowy channel (ch2) — v0.13.1' (#34) from fix/battery-alert-channel into main
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.13.1 — 2026-09-25 13:02
|
||||||
|
|
||||||
|
- Battery-low alerts now ride channel "testowy" (index 2) instead of the pinned send channel ("test", index 3): `BatteryAlertService` reads new env `BATTERY_ALERT_CHANNEL` at construction (default `2`, same pattern as its other env config) and passes it through the existing `MeshService.sendText(to, text, replyId?, channel?)` path — which gained an optional trailing `channel` argument defaulting to the pinned `MESH_SEND_CHANNEL`, so every other sender (API, MCP, weather) is untouched. Threshold, cooldown, merged one-message-per-cycle format and @mentions are unchanged.
|
||||||
|
- Tests: battery specs now assert the send carries channel index 2 by default plus a `BATTERY_ALERT_CHANNEL` override case; the end-to-end case asserts the alert frame rides channel 2 while the pinned send channel stays at its default 3. All pre-existing specs stay green.
|
||||||
|
|
||||||
## v0.13.0 — 2026-09-25 12:34
|
## v0.13.0 — 2026-09-25 12:34
|
||||||
|
|
||||||
- New non-AI `src/battery` module: `BatteryAlertModule` + `BatteryAlertService`, wired into `app.module.ts` exactly like the weather module (ScheduleModule.forRoot() + MeshModule). Every `BATTERY_CHECK_INTERVAL_MS` ms (default 60000, read at construction) it scans the same node state that backs `GET /api/nodes` and collects every node whose reported battery is at or below `BATTERY_ALERT_THRESHOLD` percent (default 20); when any qualify it sends AT MOST ONE broadcast per cycle containing all of them — never one message per node, and nothing at all when none qualify. The comparison is `<=` on purpose — a node sitting exactly at the threshold counts as low; tests pin this choice.
|
- New non-AI `src/battery` module: `BatteryAlertModule` + `BatteryAlertService`, wired into `app.module.ts` exactly like the weather module (ScheduleModule.forRoot() + MeshModule). Every `BATTERY_CHECK_INTERVAL_MS` ms (default 60000, read at construction) it scans the same node state that backs `GET /api/nodes` and collects every node whose reported battery is at or below `BATTERY_ALERT_THRESHOLD` percent (default 20); when any qualify it sends AT MOST ONE broadcast per cycle containing all of them — never one message per node, and nothing at all when none qualify. The comparison is `<=` on purpose — a node sitting exactly at the threshold counts as low; tests pin this choice.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.13.0",
|
"version": "0.13.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -33,9 +33,10 @@ function makeService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// defaults under test: threshold 20, cooldown 6 h
|
// defaults under test: threshold 20, cooldown 6 h, channel 2 ("testowy")
|
||||||
delete process.env.BATTERY_ALERT_THRESHOLD;
|
delete process.env.BATTERY_ALERT_THRESHOLD;
|
||||||
delete process.env.BATTERY_ALERT_COOLDOWN_MS;
|
delete process.env.BATTERY_ALERT_COOLDOWN_MS;
|
||||||
|
delete process.env.BATTERY_ALERT_CHANNEL;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mention', () => {
|
describe('mention', () => {
|
||||||
@@ -79,11 +80,13 @@ describe('BatteryAlertService check threshold', () => {
|
|||||||
|
|
||||||
await svc.check();
|
await svc.check();
|
||||||
|
|
||||||
// <= choice: exactly 20 qualifies too — both in ONE batched message
|
// <= choice: exactly 20 qualifies too — both in ONE batched message, on the default channel 2
|
||||||
expect(sendText).toHaveBeenCalledTimes(1);
|
expect(sendText).toHaveBeenCalledTimes(1);
|
||||||
expect(sendText).toHaveBeenCalledWith(
|
expect(sendText).toHaveBeenCalledWith(
|
||||||
BROADCAST_ADDR,
|
BROADCAST_ADDR,
|
||||||
'⚠️ battery low: @!00000001 19%, @!00000002 20%',
|
'⚠️ battery low: @!00000001 19%, @!00000002 20%',
|
||||||
|
undefined,
|
||||||
|
2,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,6 +102,8 @@ describe('BatteryAlertService check threshold', () => {
|
|||||||
expect(sendText).toHaveBeenCalledWith(
|
expect(sendText).toHaveBeenCalledWith(
|
||||||
BROADCAST_ADDR,
|
BROADCAST_ADDR,
|
||||||
'⚠️ battery low: @!00000001 50%',
|
'⚠️ battery low: @!00000001 50%',
|
||||||
|
undefined,
|
||||||
|
2,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
delete process.env.BATTERY_ALERT_THRESHOLD;
|
delete process.env.BATTERY_ALERT_THRESHOLD;
|
||||||
@@ -127,9 +132,31 @@ describe('BatteryAlertService check threshold', () => {
|
|||||||
expect(sendText.mock.calls[0]).toEqual([
|
expect(sendText.mock.calls[0]).toEqual([
|
||||||
BROADCAST_ADDR,
|
BROADCAST_ADDR,
|
||||||
'⚠️ battery low: @!00000abc (A) 5%, @!00000def (B) 3%',
|
'⚠️ 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 () => {
|
it('never throws when a send fails', async () => {
|
||||||
const svc = makeService(
|
const svc = makeService(
|
||||||
[node(1, 5)],
|
[node(1, 5)],
|
||||||
@@ -197,11 +224,11 @@ describe('BatteryAlertService check cooldown', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('BatteryAlertService check end-to-end (real MeshService)', () => {
|
describe('BatteryAlertService check end-to-end (real MeshService)', () => {
|
||||||
it('broadcasts the alert on the pinned send channel (index 3 by default)', async () => {
|
it('broadcasts the alert on "testowy" (channel index 2), not the pinned send channel', async () => {
|
||||||
const bridge = await startBridge();
|
const bridge = await startBridge();
|
||||||
process.env.MESH_HOST = '127.0.0.1';
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
process.env.MESH_PORT = String(bridge.port);
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
delete process.env.MESH_SEND_CHANNEL; // default path: "test" (index 3)
|
delete process.env.MESH_SEND_CHANNEL; // default pinned send channel is 3 — the alert must NOT ride it
|
||||||
const mesh = new RealMeshService();
|
const mesh = new RealMeshService();
|
||||||
try {
|
try {
|
||||||
mesh.onModuleInit();
|
mesh.onModuleInit();
|
||||||
@@ -241,7 +268,7 @@ describe('BatteryAlertService check end-to-end (real MeshService)', () => {
|
|||||||
|
|
||||||
await waitFor(() => readOutbound().length === 1);
|
await waitFor(() => readOutbound().length === 1);
|
||||||
const pkt = readOutbound()[0];
|
const pkt = readOutbound()[0];
|
||||||
expect(pkt.channel).toBe(3); // "test" — never the primary channel (0)
|
expect(pkt.channel).toBe(2); // "testowy" — not the pinned send channel (3) or primary (0)
|
||||||
const data =
|
const data =
|
||||||
pkt.payloadVariant?.case === 'decoded'
|
pkt.payloadVariant?.case === 'decoded'
|
||||||
? pkt.payloadVariant.value
|
? pkt.payloadVariant.value
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export class BatteryAlertService {
|
|||||||
process.env.BATTERY_ALERT_COOLDOWN_MS ?? '21600000', // default 6 h
|
process.env.BATTERY_ALERT_COOLDOWN_MS ?? '21600000', // default 6 h
|
||||||
10,
|
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)
|
// 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>();
|
private lastAlertAt = new Map<number, number>();
|
||||||
|
|
||||||
@@ -62,8 +67,13 @@ export class BatteryAlertService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async alert(nodes: NodeSummary[]): Promise<void> {
|
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)
|
// 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));
|
await this.mesh.sendText(
|
||||||
|
BROADCAST_ADDR,
|
||||||
|
formatAlert(nodes),
|
||||||
|
undefined,
|
||||||
|
this.alertChannel,
|
||||||
|
);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const n of nodes) this.lastAlertAt.set(n.num, now);
|
for (const n of nodes) this.lastAlertAt.set(n.num, now);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -490,22 +490,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return [...this.telemetryHistory].reverse();
|
return [...this.telemetryHistory].reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send `text` to node `to` on the pinned test channel; returns the packet id. Optional `replyId` marks the message as a reply to an earlier packet (the recipient app renders it as a threaded quote). Throws ServiceUnavailable when the bridge is down. */
|
/** Send `text` to node `to`; returns the packet id. Rides the pinned test channel unless an explicit `channel` index is given (e.g. battery alerts on "testowy"). Optional `replyId` marks the message as a reply to an earlier packet (the recipient app renders it as a threaded quote). Throws ServiceUnavailable when the bridge is down. */
|
||||||
async sendText(to: number, text: string, replyId?: number): Promise<number> {
|
async sendText(
|
||||||
|
to: number,
|
||||||
|
text: string,
|
||||||
|
replyId?: number,
|
||||||
|
channel?: number,
|
||||||
|
): Promise<number> {
|
||||||
if (!this.connected || !this.socket)
|
if (!this.connected || !this.socket)
|
||||||
throw new ServiceUnavailableException('bridge not connected');
|
throw new ServiceUnavailableException('bridge not connected');
|
||||||
const { bytes, id } = await buildTextToRadio(
|
const ch = channel ?? this.sendChannel;
|
||||||
to,
|
const { bytes, id } = await buildTextToRadio(to, text, ch, replyId);
|
||||||
text,
|
|
||||||
this.sendChannel,
|
|
||||||
replyId,
|
|
||||||
);
|
|
||||||
const rec: MessageRecord = {
|
const rec: MessageRecord = {
|
||||||
id,
|
id,
|
||||||
dir: 'tx',
|
dir: 'tx',
|
||||||
to,
|
to,
|
||||||
text,
|
text,
|
||||||
channel: this.sendChannel,
|
channel: ch,
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user