v0.13.0 — battery-low alert module (merged broadcasts, @mentions, cooldown) #33
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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 broadcasts a single-packet alert for every node whose reported battery is at or below `BATTERY_ALERT_THRESHOLD` percent (default 20). The comparison is `<=` on purpose — a node sitting exactly at the threshold counts as low; tests pin this choice.
|
||||||
|
- Alert format: `⚠️ @!<8-hex-lowercase-node-num> battery low: <X>% (<longName or shortName>)` — Meshtastic mention syntax (`@!` + zero-padded 8-char lowercase hex, e.g. `@!09a2b3c4`), always well under the ~190-char single-packet limit; the name part falls back longName → shortName and is omitted entirely when the node reported no name. Broadcasts ride the pinned send channel via the existing `MeshService.sendText(BROADCAST_ADDR, text)` path (MESH_SEND_CHANNEL, default index 3) — no new transport.
|
||||||
|
- Anti-spam: per-node cooldown of `BATTERY_ALERT_COOLDOWN_MS` ms (default 21600000 = 6 h) in an in-memory `Map<nodeNum, lastAlertEpochMs>` — a node is alerted at most once per window; the map resets on process restart by design (a node may get one duplicate alert after a deploy).
|
||||||
|
- Live battery updates: `batteryLevel` was previously only populated from NodeInfo frames during the initial sync. Live NODEINFO_APP packets (port 4, serialized meshtastic.NodeInfo in Data.payload) now update `deviceMetrics` in the node database too — new `decodeNodeInfo()` + `PORT_NODEINFO_APP` in codec.ts, one branch in the existing packet-handling path — so alerts and `/api/nodes` reflect current values between syncs. Unknown nodes get a stub entry when they report metrics live; malformed payloads are skipped like every other port.
|
||||||
|
- Tests: new `src/battery/battery.service.spec.ts` (mention hex padding/lowercase, full message construction incl. name fallbacks and the 190-char ceiling, threshold boundary with default + custom env, no-battery skip, once-per-node broadcast target, send-failure never throws, cooldown suppression within window + re-alert after expiry via fake timers) plus a DI spec mirroring weather's; an end-to-end case drives a real MeshService over the fake bridge and asserts the alert frame rides channel index 3 with the exact text. `mesh.service.spec.ts` gains a NODEINFO_APP live-update case (stale sync battery refreshed, other fields preserved, unknown-node stub). All pre-existing specs stay green.
|
||||||
|
|
||||||
## v0.12.8 — 2026-09-25 12:04
|
## v0.12.8 — 2026-09-25 12:04
|
||||||
|
|
||||||
- Docs (MESHTASTIC.md response protocol): new standing rule per owner request ("remember to always replay on reply on channel when question is send here") — questions received via mesh are **answered on the channel**: every question sent via mesh gets its actual answer broadcast back on the same channel when work completes; an acknowledgement alone is not sufficient, and the answer must not stay only in the private agent session. All existing bullets (always-respond-first, channel-only/no DMs, owner authority 2594370888, language, NO_RESPONSE quirk, self-echo note) unchanged. No code changes — all 64 tests untouched and green.
|
- Docs (MESHTASTIC.md response protocol): new standing rule per owner request ("remember to always replay on reply on channel when question is send here") — questions received via mesh are **answered on the channel**: every question sent via mesh gets its actual answer broadcast back on the same channel when work completes; an acknowledgement alone is not sufficient, and the answer must not stay only in the private agent session. All existing bullets (always-respond-first, channel-only/no DMs, owner authority 2594370888, language, NO_RESPONSE quirk, self-echo note) unchanged. No code changes — all 64 tests untouched and green.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.12.8",
|
"version": "0.13.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
+2
-1
@@ -1,8 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { BatteryAlertModule } from './battery/battery.module.js';
|
||||||
import { MeshModule } from './mesh/mesh.module.js';
|
import { MeshModule } from './mesh/mesh.module.js';
|
||||||
import { WeatherModule } from './weather/weather.module.js';
|
import { WeatherModule } from './weather/weather.module.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MeshModule, WeatherModule],
|
imports: [MeshModule, WeatherModule, BatteryAlertModule],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { MeshModule } from '../mesh/mesh.module.js';
|
||||||
|
import { BatteryAlertService } from './battery.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ScheduleModule.forRoot(), MeshModule],
|
||||||
|
providers: [BatteryAlertService],
|
||||||
|
exports: [BatteryAlertService],
|
||||||
|
})
|
||||||
|
export class BatteryAlertModule {}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
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
|
||||||
|
delete process.env.BATTERY_ALERT_THRESHOLD;
|
||||||
|
delete process.env.BATTERY_ALERT_COOLDOWN_MS;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 full single-packet message with longName', () => {
|
||||||
|
expect(formatAlert(node(0x1234abcd, 15, 'Kamil SP8MK/P'))).toBe(
|
||||||
|
'⚠️ @!1234abcd battery low: 15% (Kamil SP8MK/P)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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))).toBe('⚠️ @!00000abc battery low: 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();
|
||||||
|
|
||||||
|
expect(sendText).toHaveBeenCalledTimes(2); // <= choice: exactly 20 alerts too
|
||||||
|
expect(sendText).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
BROADCAST_ADDR,
|
||||||
|
'⚠️ @!00000001 battery low: 19%',
|
||||||
|
);
|
||||||
|
expect(sendText).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
BROADCAST_ADDR,
|
||||||
|
'⚠️ @!00000002 battery low: 20%',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
'⚠️ @!00000001 battery low: 50%',
|
||||||
|
);
|
||||||
|
} 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('alerts once per qualifying node 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(2);
|
||||||
|
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)',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BatteryAlertService check end-to-end (real MeshService)', () => {
|
||||||
|
it('broadcasts the alert on the pinned send channel (index 3 by default)', 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 path: "test" (index 3)
|
||||||
|
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(3); // "test" — never the primary channel (0)
|
||||||
|
const data =
|
||||||
|
pkt.payloadVariant?.case === 'decoded'
|
||||||
|
? pkt.payloadVariant.value
|
||||||
|
: undefined;
|
||||||
|
expect(new TextDecoder().decode(data?.payload ?? new Uint8Array())).toBe(
|
||||||
|
'⚠️ @!00000abc battery low: 5% (Alpha)',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
delete process.env.MESH_HOST;
|
||||||
|
delete process.env.MESH_PORT;
|
||||||
|
delete process.env.MESH_SEND_CHANNEL;
|
||||||
|
mesh.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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 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 {
|
||||||
|
// 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})` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,
|
||||||
|
);
|
||||||
|
// 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>();
|
||||||
|
|
||||||
|
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(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);
|
||||||
|
}
|
||||||
|
} 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(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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { MeshService } from '../mesh/mesh.service.js';
|
||||||
|
import { BatteryAlertModule } from './battery.module.js';
|
||||||
|
import { BatteryAlertService } from './battery.service.js';
|
||||||
|
|
||||||
|
describe('DI wiring (module graph)', () => {
|
||||||
|
// compile() instantiates providers but does not run lifecycle hooks, so no TCP connection is opened.
|
||||||
|
it('resolves BatteryAlertService with MeshService provided by MeshModule', async () => {
|
||||||
|
process.env.MESH_DB_PATH = ':memory:'; // keep openMessageDb from touching the repo's ./data
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [BatteryAlertModule],
|
||||||
|
}).compile();
|
||||||
|
expect(moduleRef.get(BatteryAlertService)).toBeInstanceOf(
|
||||||
|
BatteryAlertService,
|
||||||
|
);
|
||||||
|
expect(moduleRef.get(MeshService)).toBeInstanceOf(MeshService);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user