Compare commits

..
12 Commits
Author SHA1 Message Date
kamilkrzywda c0c069ef08 Merge pull request 'Battery alerts → testowy channel (ch2) — v0.13.1' (#34) from fix/battery-alert-channel into main
CI / build + unit (push) Successful in 39s
2026-09-25 15:04:11 +02:00
kamilkrzywda 1b712f7eee battery: send alerts on channel "testowy" (index 2), not the pinned send channel
CI / build + unit (pull_request) Successful in 1m0s
Owner request. BatteryAlertService reads new env BATTERY_ALERT_CHANNEL
(default 2) at construction and passes it through MeshService.sendText,
which gains an optional trailing channel argument defaulting to the
pinned MESH_SEND_CHANNEL — every other sender is untouched. Threshold,
cooldown, merged one-message-per-cycle format and @mentions unchanged.
2026-09-25 13:02:56 +00:00
kamilkrzywda 6f6b704dcf Merge pull request 'v0.13.0 — battery-low alert module (merged broadcasts, @mentions, cooldown)' (#33) from feat/battery-alerts into main
CI / build + unit (push) Successful in 43s
2026-09-25 14:53:01 +02:00
kamilkrzywda fe8d2dbe0b 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.
2026-09-25 12:51:25 +00:00
kamilkrzywda d0b71e6e91 battery: low-battery alert module (v0.13.0)
BatteryAlertModule + BatteryAlertService wired into app.module like
the weather module. Every BATTERY_CHECK_INTERVAL_MS (default 60 s) the
service scans the node state backing GET /api/nodes and broadcasts a
single-packet '⚠️ @!<8-hex> battery low: <X>% (<name>)' alert for every
node at or below BATTERY_ALERT_THRESHOLD percent (default 20, <= on
purpose) not alerted within BATTERY_ALERT_COOLDOWN_MS (default 6 h,
in-memory map — resets on restart). Sends ride the pinned send channel
via MeshService.sendText like every other bot send.
2026-09-25 12:39:45 +00:00
kamilkrzywda 8ddd91d4b5 mesh: update node batteryLevel from live NODEINFO_APP packets
battery_level was only populated during the initial sync; NodeInfo
frames streamed as NODEINFO_APP (port 4) now refresh deviceMetrics in
the node database so /api/nodes and downstream consumers see current
values between syncs. decodeNodeInfo + PORT_NODEINFO_APP added to the
codec, one branch in the existing packet-handling path.
2026-09-25 12:39:45 +00:00
kamilkrzywda 6ce0b1ae7b MESHTASTIC.md: questions get their answer on the channel (owner standing rule) (#32)
CI / build + unit (push) Successful in 20s
2026-09-25 14:09:10 +02:00
kamilkrzywda 15d93ec2a4 Optional replyId on send (API + MCP) — protocol-level threaded replies (#31)
CI / build + unit (push) Successful in 33s
2026-09-25 13:53:47 +02:00
kamilkrzywda 0909fbc6e2 MESHTASTIC.md: back to channel-only replies (no DMs) (#30)
CI / build + unit (push) Successful in 33s
2026-09-25 13:00:05 +02:00
kamilkrzywda b8ae5d39f7 MESHTASTIC.md: reply to senders as DMs; always respond before starting work (#29)
CI / build + unit (push) Successful in 33s
2026-09-25 12:52:11 +02:00
kamilkrzywda 5999159e17 Forwarder: resolve opencode workspace/session dynamically per request (#28)
CI / build + unit (push) Successful in 32s
2026-09-25 12:24:39 +02:00
kamilkrzywda 36021ec2e8 Retry notify webhook POST with backoff (2 s / 10 s / 30 s) before dropping (#27)
CI / build + unit (push) Successful in 1m2s
2026-09-25 11:48:39 +02:00
19 changed files with 865 additions and 39 deletions
+2 -2
View File
@@ -17,8 +17,8 @@
## API
1. **Live base URL** — `http://bot.dev.kkhost.pl` (Traefik → docker-compose stack on this host; container port 3000 via external network dev_net, no published ports).
2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast"> }` — numbers pass through as-is (integer 1..0xffffffff), names resolve like `GET /api/nodes/:id`, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. Returns `{ id }`), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `GET /api/device` (device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {<section>: ...}, moduleConfig: {<module>: ...} }` — DeviceMetadata as-is plus per-section Config/ModuleConfig messages; security publicKey/privateKey/adminKey and network wifiPsk redacted to `"REDACTED"`; in-memory, reset on reconnect), `GET /api/messages` (message history newest first: rx text with `from`, tx sends with `to` + `status` pending→delivered/failed via routing ACKs — broadcasts stay pending because firmware strips want_ack; last 100 in memory (seeded from SQLite on startup) — backed by SQLite: re-upserted every MESH_POLL_MS and pruned to the newest 1000 rows, so it survives process restarts and bridge reconnects), `POST /api/messages` (body `{ "text": "...", "from"?: <node number> }`, returns `{ id }`; synthetic inbox injection for external triggers — records an rx entry in the history as if received on the mesh (channel 0) so external systems can push messages to trigger communication later; NOT transmitted over RF, nothing goes to the bridge or air; appears in `GET /api/messages` like any other rx message), `GET /api/telemetry` (telemetry history newest first as `{ from, at /* epoch ms */, kind, data }[]`: live TELEMETRY_APP packets — device metrics and environment sensor readings where nodes have them; kind is the meshtastic.Telemetry oneof case name (deviceMetrics/environmentMetrics/airQualityMetrics/powerMetrics/localStats/healthMetrics/hostMetrics/trafficManagementStats), data that variant's payload with proto uint64 fields as decimal strings; last 100, in-memory, reset on reconnect), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, from the initial sync plus live POSITION_APP updates; reset on reconnect) and `GET /api/nodes/:id` (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. All `POST /api/send` traffic targets channel index 3 (`test`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `3`). Message history is persisted to SQLite: file path from `MESH_DB_PATH` (default `./data/mesh-bot.db`, parent dir auto-created), poller re-upserts every `MESH_POLL_MS` ms (default 1000) and prunes the table to the newest 1000 rows. Outbound notification webhook: when an RX text arrives on channel `MESH_NOTIFY_CHANNEL` (default `3`), a fire-and-forget HTTP POST of `{text, from}` (`from` omitted when absent) goes to `MESH_NOTIFY_URL` — unset/empty disables it entirely; no retries, no response handling. Injected messages (`POST /api/messages`, channel 0) trigger it too when the notify channel is set to match. Messages from our own node are never notified (self-echo filter).
3. **MCP endpoint** — `POST /mcp` (excluded from the `/api` prefix in main.ts, like Scalar at `/`): an in-process MCP server over the official SDK's Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`, fresh `McpServer` + transport per request). Tools map 1:1 to MeshService methods: `mesh_status`, `send_message(text, to?)` (same resolution as `POST /api/send`; errors surface as tool errors with the same messages), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), `get_messages`, `inject_message(text, from?)` (synthetic inbox injection for external triggers — records an rx entry in the history as if received on the mesh; NOT sent over RF; same validation as `POST /api/messages`), `get_telemetry` (same bounded recent window as `GET /api/telemetry`), `device_info` (same redaction as `GET /api/device`). Results are JSON in a text content block; sends ride channel index 3 like everything else. Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (`@modelcontextprotocol/sdk/server/mcp.js` etc.).
2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast">, "replyId"?: <packet id> }` — numbers pass through as-is (integer 1..0xffffffff), names resolve like `GET /api/nodes/:id`, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. Optional `replyId` (integer in 1..0xffffffff, else 400) references an earlier message's packet id from `GET /api/messages` — when set, the recipient app renders the text as a threaded reply/quote of that packet (`Data.reply_id`, field 7). Returns `{ id }`), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `GET /api/device` (device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {<section>: ...}, moduleConfig: {<module>: ...} }` — DeviceMetadata as-is plus per-section Config/ModuleConfig messages; security publicKey/privateKey/adminKey and network wifiPsk redacted to `"REDACTED"`; in-memory, reset on reconnect), `GET /api/messages` (message history newest first: rx text with `from`, tx sends with `to` + `status` pending→delivered/failed via routing ACKs — broadcasts stay pending because firmware strips want_ack; last 100 in memory (seeded from SQLite on startup) — backed by SQLite: re-upserted every MESH_POLL_MS and pruned to the newest 1000 rows, so it survives process restarts and bridge reconnects), `POST /api/messages` (body `{ "text": "...", "from"?: <node number> }`, returns `{ id }`; synthetic inbox injection for external triggers — records an rx entry in the history as if received on the mesh (channel 0) so external systems can push messages to trigger communication later; NOT transmitted over RF, nothing goes to the bridge or air; appears in `GET /api/messages` like any other rx message), `GET /api/telemetry` (telemetry history newest first as `{ from, at /* epoch ms */, kind, data }[]`: live TELEMETRY_APP packets — device metrics and environment sensor readings where nodes have them; kind is the meshtastic.Telemetry oneof case name (deviceMetrics/environmentMetrics/airQualityMetrics/powerMetrics/localStats/healthMetrics/hostMetrics/trafficManagementStats), data that variant's payload with proto uint64 fields as decimal strings; last 100, in-memory, reset on reconnect), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, from the initial sync plus live POSITION_APP updates; reset on reconnect) and `GET /api/nodes/:id` (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. All `POST /api/send` traffic targets channel index 3 (`test`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `3`). Message history is persisted to SQLite: file path from `MESH_DB_PATH` (default `./data/mesh-bot.db`, parent dir auto-created), poller re-upserts every `MESH_POLL_MS` ms (default 1000) and prunes the table to the newest 1000 rows. Outbound notification webhook: when an RX text arrives on channel `MESH_NOTIFY_CHANNEL` (default `3`), a fire-and-forget HTTP POST of `{text, from}` (`from` omitted when absent) goes to `MESH_NOTIFY_URL` — unset/empty disables it entirely; failed POSTs are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped; no response handling. Injected messages (`POST /api/messages`, channel 0) trigger it too when the notify channel is set to match. Messages from our own node are never notified (self-echo filter). The forwarder resolves the mesh-bot workspace/session dynamically from the opencode API per request (`FORWARDER_API`/`FORWARDER_WORKSPACE_PATH`; `FORWARDER_SESSION` pins a session when set).
3. **MCP endpoint** — `POST /mcp` (excluded from the `/api` prefix in main.ts, like Scalar at `/`): an in-process MCP server over the official SDK's Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`, fresh `McpServer` + transport per request). Tools map 1:1 to MeshService methods: `mesh_status`, `send_message(text, to?, replyId?)` (`to` resolves like `POST /api/send`; optional `replyId` integer in 1..0xffffffff makes the message render as a threaded reply/quote of that packet id from `GET /api/messages` or `get_messages`; errors surface as tool errors with the same messages), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), `get_messages`, `inject_message(text, from?)` (synthetic inbox injection for external triggers — records an rx entry in the history as if received on the mesh; NOT sent over RF; same validation as `POST /api/messages`), `get_telemetry` (same bounded recent window as `GET /api/telemetry`), `device_info` (same redaction as `GET /api/device`). Results are JSON in a text content block; sends ride channel index 3 like everything else. Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (`@modelcontextprotocol/sdk/server/mcp.js` etc.).
## Other
+48
View File
@@ -1,5 +1,53 @@
# 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
- 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.
- Alert format: `⚠️ battery low: @!<8-hex-lowercase-node-num> (<longName or shortName>) <X>%` with additional nodes appended as `, @!<hex> (<name>) <Y>%` (the single-node case is the same template with one entry) — Meshtastic mention syntax (`@!` + zero-padded 8-char lowercase hex, e.g. `@!09a2b3c4`), entries kept tight so common cases stay near the ~190-char single-packet limit; the name part falls back longName → shortName and is omitted entirely when a 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>` — only nodes outside their window are included in a batch and the alert timestamp is recorded for every node in it after sending, so 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, batched message construction incl. multi-node append, name fallbacks and the 190-char ceiling, threshold boundary with default + custom env, no-battery skip, all qualifying nodes merged into exactly one broadcast, send-failure never throws, cooldown suppression within window + re-alert after expiry via fake timers, in-cooldown node excluded from a batch while an out-of-window node still triggers the single message) 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
- 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.
## v0.12.7 — 2026-09-25 11:40
- `POST /api/send` and the MCP `send_message` tool now accept an optional `replyId` (integer in 1..0xffffffff) referencing an earlier message's packet id from `GET /api/messages` / `get_messages`. When set, the recipient app renders the sent text as a threaded reply/quote of that packet. The value rides Meshtastic's protocol-level reply mechanism: `Data.reply_id` (field 7) on the TEXT_MESSAGE_APP payload.
- `buildTextToRadio(to, text, channelIndex, replyId?)` in `src/mesh/codec.ts` gained the optional param and sets **camelCase** `replyId` on the Data value (`replyId ?? 0`). The JS protobufs package silently drops snake_case `reply_id`, so camelCase is mandatory; a byte-level spec asserts wire tag `0x3d` (field 7, fixed32) appears iff `replyId` was given.
- `MeshService.sendText(to, text, replyId?)` passes the value through to the codec. REST validation rejects non-integers and out-of-range values with a 400; MCP validates via zod (`z.number().int().min(1).max(0xffffffff)`). Omitting it leaves the field unset (proto3 default 0, not serialized) — existing callers are unaffected.
- New specs: codec byte-level `0x3d` presence/absence check and a service outbound-capture case asserting `Data.replyId === <id>` when given and `0` when omitted. All 62 pre-existing tests stay green (64 total).
- Docs: README endpoint + MCP tool lines, AGENTS.md API section, and the controller's `@ApiOperation` description now document the optional `replyId`.
## v0.12.6 — 2026-09-25 10:55
- Docs (MESHTASTIC.md response protocol): reverted the v0.12.5 reply rule per owner preference ("we will talk on that channel") — replies to messages from a specific node now go back **on the same channel as a broadcast** (no `to`), no direct messages; broadcasts are used for both replies and general announcements such as the hourly weather report. The always-respond-first bullet (**ALWAYS respond ... BEFORE starting any work it triggers**) and every other rule are unchanged. No code changes — `POST /api/send` and the MCP send tool already accept an omitted `to` (broadcast).
## v0.12.5 — 2026-09-25 10:45
- Docs (MESHTASTIC.md response protocol): replies to messages from a specific node now go back **as a direct message to that sender's node number** (`to` = the sender's node num), so they surface in the direct conversation thread ("flagged as reply") instead of as new channel posts. Broadcasts (no `to`) are reserved for general announcements such as the hourly weather report. No code changes — `POST /api/send` and the MCP send tool already accept a `to` target; all other rules (owner authority 2594370888, language, confirm-first-then-work, channel pinning, self-echo note, NO_RESPONSE quirk) unchanged.
- MESHTASTIC.md confirm-first rule made explicit and mandatory: **ALWAYS respond to a received mesh message on the Meshtastic BEFORE starting any work it triggers** (acknowledge first, then work) — the previous "first respond/confirm, then start the actual work" wording was not explicit enough and got violated in practice.
## v0.12.4 — 2026-09-25 10:17
- Notify forwarder (`ops/forwarder`) now resolves the opencode workspace + session dynamically per request instead of hardcoding them as env defaults: `GET {FORWARDER_API}/api/workspaces` → match by `path == FORWARDER_WORKSPACE_PATH` (fallback `name == "mesh-bot"`, status `ready` preferred when several match) → `GET {proxyPath}/session` → most recently updated session (`time.updated`; first entry if absent). No caching — messages are rare. Fixes lost user messages after an opencode host restart regenerated the workspace UUID (the old hardcoded base URL 404'd on every notify POST).
- New env config replaces `FORWARDER_BASE`: `FORWARDER_API` (default `http://127.0.0.1:9898`) and `FORWARDER_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`); `FORWARDER_SESSION` is now an optional pin — when set, session discovery is skipped; empty/unset discovers dynamically. `FORWARDER_USER` / `FORWARDER_PASS` stay required. Each failure mode gets a clear 502 + log line: API unreachable, workspace not found, no sessions in workspace. The message POST itself (endpoint under `{proxyPath}/session/{sid}`, body and auth), timeout=15 semantics, 202-on-timeout and the `_reply` BrokenPipe guard are unchanged.
- `docker-compose.yml`: forwarder environment block now passes `FORWARDER_API` / `FORWARDER_WORKSPACE_PATH` / `FORWARDER_SESSION` (empty default) — old `FORWARDER_BASE` line removed; bot service untouched.
- `MeshService.postNotify()`: after `await fetch(...)`, checks `res.ok` and throws `Error('HTTP <status>')` when not ok, so a forwarder 5xx during an opencode restart is retried with the same backoff as network failures (the `res &&` guard keeps specs that stub fetch to resolve `undefined` green). One new spec: fetch resolves status 502 twice then 200 → exactly 3 attempts, success path. All 61 pre-existing tests stay green (62 total).
- Docs: README "Notify forwarder" section rewritten for dynamic resolution + the new env vars; AGENTS.md webhook note gains a clause that the forwarder resolves the mesh-bot workspace/session from the opencode API per request (`FORWARDER_SESSION` pins a session when set).
## v0.12.3 — 2026-09-25 09:40
- Notify webhook POSTs are now retried instead of dropped on the first failure: `notifyRx`'s fire-and-forget POST runs through a retry loop with up to 4 attempts total (initial + 3 retries) and backoff delays `[2 s, 10 s, 30 s]` (~45 s window). Each failed attempt logs WARN with the attempt number and error; after the final failure it logs WARN `notify dropped after N attempts: ...` and stops. Success on any attempt ends the loop (unchanged success behavior/logging). Fire-and-forget semantics preserved — the RX handler never awaits the loop, which runs as an un-awaited async IIFE exactly like today's single POST. Self-echo filter and channel-match logic unchanged; backoff is hardcoded (no new env vars).
- New private `MeshService.postNotify()` retry helper + injectable `sleep()` so specs can skip the delays without fake timers. Two new specs: fetch fails twice then succeeds → exactly 3 attempts, no dropped log, success path taken; fetch always fails → exactly 4 attempts, final WARN `notify dropped after 4 attempts`, nothing thrown. All 59 pre-existing tests stay green (61 total).
- Docs: README `MESH_NOTIFY_URL` bullet and AGENTS.md webhook note now state failed POSTs are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped.
## v0.12.2 — 2026-09-25 08:44
- New compose service `forwarder` (`ops/forwarder`, `python:3.12-slim`, stdlib only, no pip installs): runs the notify forwarder that was previously a host-side `nohup` python script — receives the bot's outbound notify webhook POSTs (`{text, from?}` on `MESH_NOTIFY_URL`) and forwards each one as a message to an opencode API session (Basic auth).
+3 -2
View File
@@ -14,8 +14,9 @@ Rules for how mesh-bot communicates over Meshtastic and how the agent should rea
## Response protocol
- When doing any action triggered by a chat message: **first respond/confirm on the channel, then start the actual work** — so the user gets the fastest possible response.
- Replies to messages received on a channel go back **on that same channel as a broadcast** (no `to`), not as a DM — unless the sender explicitly asks for a private reply.
- **ALWAYS respond to a received mesh message on the Meshtastic BEFORE starting any work it triggers** — acknowledge first, then work. This is mandatory for every action triggered by a chat message; the confirmation goes out before you start (so the user gets the fastest possible response).
- Replies to messages from a specific node go back **on the same channel as a broadcast** (no `to`) — do not send direct messages; per owner preference, all conversation happens on the channel. Broadcasts are used for both replies and general announcements such as the hourly weather report.
- **Questions 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.
## Channels & sending
+7 -6
View File
@@ -26,7 +26,7 @@ Exposed via Traefik only (no published ports) at `http://bot.dev.kkhost.pl`.
All API endpoints live under the `/api` prefix:
- `GET /api/status` — `{ connected, myNodeNum, host, port }`
- `POST /api/send` — body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast"> }`, returns `{ id }`. Numbers pass through as-is (integer in 1..0xffffffff); names resolve like `GET /api/nodes/:id` (exact long/short name, then unique substring) — unknown or ambiguous targets are a 400
- `POST /api/send` — body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast">, "replyId"?: <packet id> }`, returns `{ id }`. Numbers pass through as-is (integer in 1..0xffffffff); names resolve like `GET /api/nodes/:id` (exact long/short name, then unique substring) — unknown or ambiguous targets are a 400. Optional `replyId` (integer in 1..0xffffffff) references an earlier message's packet id from `GET /api/messages` — when set, the recipient app renders the text as a threaded reply/quote of that packet
- `GET /api/channels` — the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` (psk/keys are redacted; empty until the initial sync completes)
- `GET /api/device` — device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {<section>: ...}, moduleConfig: {<module>: ...} }`. Key material is never exposed (security publicKey/privateKey/adminKey and network wifiPsk are replaced with `"REDACTED"`); in-memory only, reset on every bridge reconnect
- `GET /api/messages` — message history, newest first: inbound text (`dir:'rx'`, with `from`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` (with human-readable `ackError`) when the routing ACK/NACK arrives; broadcasts stay `pending` because firmware strips want_ack before TX. Backed by SQLite: the in-memory buffer (last 100) is seeded from the database on startup and re-upserted every second, so history survives process restarts and bridge reconnects; the database keeps the newest 1000 rows
@@ -67,7 +67,7 @@ Streamable HTTP transport in stateless mode. It maps 1:1 to the REST
endpoints — no new logic, same channel pinning:
- `mesh_status` — bridge connection state (`{ connected, myNodeNum, host, port }`)
- `send_message(text, to?)` — send a text message; `to` resolves like `POST /api/send` (omitted/""/"!b"/"broadcast" → broadcast, number or numeric string → node number, else name lookup); returns `{ id }`; unknown/ambiguous targets are tool errors
- `send_message(text, to?, replyId?)` — send a text message; `to` resolves like `POST /api/send` (omitted/""/"!b"/"broadcast" → broadcast, number or numeric string → node number, else name lookup); optional `replyId` makes the message render as a threaded reply/quote of that packet id from `get_messages`; returns `{ id }`; unknown/ambiguous targets are tool errors
- `list_channels` — device channels (keys redacted)
- `list_nodes` — known nodes as lean summaries
- `get_node(id)` — one node by number or name; tool error when missing/ambiguous
@@ -99,12 +99,13 @@ Hourly cron job (`src/weather`): fetches current conditions for Lublin from [Ope
## Notify forwarder
Second compose service (`ops/forwarder`, `python:3.12-slim`, stdlib only): receives the bot's outbound notify webhook POSTs (`{text, from?}` on `MESH_NOTIFY_URL`) and forwards each one as a message to an opencode API session (Basic auth). Runs with `network_mode: host` — it listens on the host's `0.0.0.0:4097`, which is why `MESH_NOTIFY_URL=http://172.24.0.1:4097/notify` works from the bot container, and reaches opencode at `127.0.0.1:9898`. `restart: unless-stopped`; no Traefik labels (host networking).
Second compose service (`ops/forwarder`, `python:3.12-slim`, stdlib only): receives the bot's outbound notify webhook POSTs (`{text, from?}` on `MESH_NOTIFY_URL`) and forwards each one as a message to the mesh-bot folder's live opencode session (Basic auth). The opencode workspace UUID is regenerated on host restarts, so the forwarder resolves it dynamically per request via the opencode API: `GET /api/workspaces` → match by `path == FORWARDER_WORKSPACE_PATH` (fallback name `mesh-bot`, status `ready` preferred when several match) → `GET {proxyPath}/session` → most recently updated session (`time.updated`; first entry if absent). No caching — messages are rare. Runs with `network_mode: host` — it listens on the host's `0.0.0.0:4097`, which is why `MESH_NOTIFY_URL=http://172.24.0.1:4097/notify` works from the bot container, and reaches opencode at `127.0.0.1:9898`. `restart: unless-stopped`; no Traefik labels (host networking).
Config lives in `.env` (gitignored) and is passed via compose environment — credentials are never committed to the repo:
- `FORWARDER_BASE` (default `http://127.0.0.1:9898/workspaces/679fd6f6-b237-4a46-bf69-64cb0fc865b0/instance`) — opencode instance base URL
- `FORWARDER_SESSION` (default `ses_f31037caeffeioRwixqGV7VS6m`) — target session ID
- `FORWARDER_API` (default `http://127.0.0.1:9898`) — opencode API base URL
- `FORWARDER_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`) — workspace matched by its `path` field (fallback name `mesh-bot`; status `ready` preferred when several match)
- `FORWARDER_SESSION` (optional, default empty) — pins a session ID; when set, session discovery is skipped. Empty/unset → the most recently updated session of the resolved workspace is used per request
- `FORWARDER_USER` / `FORWARDER_PASS` — Basic auth credentials; **required**, the container exits on startup if either is missing
## Env vars
@@ -113,7 +114,7 @@ Config lives in `.env` (gitignored) and is passed via compose environment — cr
- `MESH_PORT` (default `4403`) — bridge port
- `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms
- `MESH_SEND_CHANNEL` (default `3`) — channel index all sends target (`test`; never the primary channel)
- `MESH_NOTIFY_URL` — outbound notification webhook URL; when set (non-empty), an inbound rx text message arriving on channel `MESH_NOTIFY_CHANNEL` fires a fire-and-forget HTTP POST with JSON body `{text, from}` (`from` omitted when absent). Synthetic injected messages (`POST /api/messages`, channel 0) trigger it too when the notify channel is set to match. Messages from our own node are never notified (self-echo filter). Unset or empty = disabled (no fetch at all)
- `MESH_NOTIFY_URL` — outbound notification webhook URL; when set (non-empty), an inbound rx text message arriving on channel `MESH_NOTIFY_CHANNEL` fires a fire-and-forget HTTP POST with JSON body `{text, from}` (`from` omitted when absent). Failed POSTs are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped. Synthetic injected messages (`POST /api/messages`, channel 0) trigger it too when the notify channel is set to match. Messages from our own node are never notified (self-echo filter). Unset or empty = disabled (no fetch at all)
- `MESH_NOTIFY_CHANNEL` (default `3`) — channel index that triggers the notify webhook
- `MESH_DB_PATH` (default `./data/mesh-bot.db`) — SQLite file backing the message history (parent dir created if missing; in Docker, mounted at `/app/data` via a named volume so it survives container recreation)
- `MESH_POLL_MS` (default `1000`) — how often the poller re-upserts the message history into SQLite and prunes rows beyond the newest 1000
+3 -2
View File
@@ -23,8 +23,9 @@ services:
build: ./ops/forwarder
network_mode: host
environment:
FORWARDER_BASE: ${FORWARDER_BASE:-http://127.0.0.1:9898/workspaces/679fd6f6-b237-4a46-bf69-64cb0fc865b0/instance}
FORWARDER_SESSION: ${FORWARDER_SESSION:-ses_f31037caeffeioRwixqGV7VS6m}
FORWARDER_API: ${FORWARDER_API:-http://127.0.0.1:9898}
FORWARDER_WORKSPACE_PATH: ${FORWARDER_WORKSPACE_PATH:-/home/krzyw/projects/sandbox/mesh-bot}
FORWARDER_SESSION: ${FORWARDER_SESSION:-}
FORWARDER_USER: ${FORWARDER_USER:-}
FORWARDER_PASS: ${FORWARDER_PASS:-}
restart: unless-stopped
+47 -7
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env python3
"""mesh-bot notify forwarder: POST /notify {text, from?} -> opencode session message (basic auth).
The opencode workspace UUID is regenerated on host restarts, so the target workspace + session are
resolved dynamically per request via the opencode API (no caching — messages are rare): list
workspaces, pick the mesh-bot one by path (fallback: name), then its most recently updated session.
Note on semantics: the opencode API accepts a session message immediately but holds the HTTP
response until the agent turn completes — so a read timeout here means DELIVERED, not failed.
"""
@@ -8,11 +12,43 @@ import json, base64, urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
import os
BASE = os.environ.get("FORWARDER_BASE", "http://127.0.0.1:9898/workspaces/679fd6f6-b237-4a46-bf69-64cb0fc865b0/instance")
SESSION = os.environ.get("FORWARDER_SESSION", "ses_f31037caeffeioRwixqGV7VS6m")
API = os.environ.get("FORWARDER_API", "http://127.0.0.1:9898")
WORKSPACE_PATH = os.environ.get("FORWARDER_WORKSPACE_PATH", "/home/krzyw/projects/sandbox/mesh-bot")
SESSION_OVERRIDE = os.environ.get("FORWARDER_SESSION", "") # when set, skips session discovery
USER = os.environ["FORWARDER_USER"] # required; fail fast if missing
PASS = os.environ["FORWARDER_PASS"] # required; fail fast if missing
def _auth_headers():
return {"Authorization": "Basic " + base64.b64encode(f"{USER}:{PASS}".encode()).decode()}
def _get_json(url):
req = urllib.request.Request(url, headers=_auth_headers())
try:
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read().decode())
except Exception as e:
raise RuntimeError(f"opencode API unreachable at {url}: {e!r}") from None
def resolve_target():
"""Resolve (base_url, session_id) fresh per request; raises RuntimeError with a clear reason."""
workspaces = _get_json(API + "/api/workspaces")
matches = [w for w in workspaces if w.get("path") == WORKSPACE_PATH] or \
[w for w in workspaces if w.get("name") == "mesh-bot"]
pool = [w for w in matches if w.get("status") == "ready"] or matches
if not pool:
raise RuntimeError(f"workspace not found: path={WORKSPACE_PATH} name=mesh-bot")
base = API + pool[0]["proxyPath"]
sid = SESSION_OVERRIDE
if not sid:
sessions = _get_json(base + "/session")
if not sessions:
raise RuntimeError(f"no sessions in workspace {pool[0]['id']} ({WORKSPACE_PATH})")
def updated(s):
return (s.get("time") or {}).get("updated") or 0
sid = max(sessions, key=updated)["id"] if any(updated(s) for s in sessions) else sessions[0]["id"]
return base, sid
class H(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/notify":
@@ -22,23 +58,27 @@ class H(BaseHTTPRequestHandler):
body = json.loads(self.rfile.read(n) or b"{}")
text, frm = body.get("text"), body.get("from")
msg = 'New mesh message' + (f' from node {frm}' if frm is not None else '') + f': "{text}" — please react to it.'
base, sid = resolve_target() # fresh per request; RuntimeError on API/workspace/session failure
req = urllib.request.Request(
f"{BASE}/session/{SESSION}/message",
f"{base}/session/{sid}/message",
data=json.dumps({"parts": [{"type": "text", "text": msg}]}).encode(),
headers={"Content-Type": "application/json",
"Authorization": "Basic " + base64.b64encode(f"{USER}:{PASS}".encode()).decode()},
headers={"Content-Type": "application/json", **_auth_headers()},
method="POST")
try:
r = urllib.request.urlopen(req, timeout=15)
code = r.status # headers received => opencode accepted the message
try: out = r.read(200).decode(errors='replace')
except Exception as e2: out = f'(body read failed after delivery: {e2!r})'
print(f"forwarded to opencode session {SESSION}: HTTP {code} {out[:150]!r}", flush=True)
print(f"forwarded to opencode session {sid}: HTTP {code} {out[:150]!r}", flush=True)
self._reply(code if 200 <= code < 300 else 502, b'{"ok":true}' if 200 <= code < 300 else str(code).encode())
except TimeoutError:
# request was sent; opencode holds the response until the agent turn finishes => delivered
print(f"forwarded to opencode session {SESSION}: accepted (response still pending after 15 s)", flush=True)
print(f"forwarded to opencode session {sid}: accepted (response still pending after 15 s)", flush=True)
self._reply(202, b'{"ok":true,"note":"accepted; opencode response pending"}')
except RuntimeError as e:
# resolve_target() failure modes: API unreachable / workspace not found / no sessions in workspace
print(f"target resolution failed: {e!r}", flush=True)
self._reply(502, repr(e).encode())
except Exception as e:
print(f"opencode call failed: {e!r}", flush=True)
self._reply(502, repr(e).encode())
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mesh-bot",
"version": "0.12.2",
"version": "0.13.1",
"description": "",
"author": "",
"private": true,
+2 -1
View File
@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { BatteryAlertModule } from './battery/battery.module.js';
import { MeshModule } from './mesh/mesh.module.js';
import { WeatherModule } from './weather/weather.module.js';
@Module({
imports: [MeshModule, WeatherModule],
imports: [MeshModule, WeatherModule, BatteryAlertModule],
})
export class AppModule {}
+11
View File
@@ -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 {}
+287
View File
@@ -0,0 +1,287 @@
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('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();
}
});
});
+80
View File
@@ -0,0 +1,80 @@
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 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)}${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()
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,
);
// 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)
private lastAlertAt = new Map<number, number>();
constructor(private readonly mesh: MeshService) {}
/** 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();
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}`);
}
}
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(nodes: NodeSummary[]): Promise<void> {
// 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),
undefined,
this.alertChannel,
);
const now = Date.now();
for (const n of nodes) this.lastAlertAt.set(n.num, now);
}
}
+19
View File
@@ -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);
});
});
+25 -1
View File
@@ -1,5 +1,7 @@
import { fromBinary, toBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import { describe, expect, it } from 'vitest';
import { extractFrames, frame } from './codec.js';
import { buildTextToRadio, extractFrames, frame } from './codec.js';
const F = (payload: number[]) => [
0x94,
@@ -78,3 +80,25 @@ describe('frame', () => {
expect(rest.length).toBe(0);
});
});
// re-serialize just the nested Data message so its wire bytes are inspectable without the random packet id interfering
function dataBytes(framed: Uint8Array): Uint8Array {
const { packets } = extractFrames(framed); // buildTextToRadio returns framed bytes
expect(packets).toHaveLength(1);
const tr = fromBinary(P.Mesh.ToRadioSchema, packets[0]);
if (tr.payloadVariant?.case !== 'packet') throw new Error('not a packet');
const pv = tr.payloadVariant.value.payloadVariant;
if (pv?.case !== 'decoded') throw new Error('not decoded');
return toBinary(P.Mesh.DataSchema, pv.value);
}
describe('buildTextToRadio', () => {
it('writes wire tag 0x3d (Data.reply_id, field 7) iff replyId is given', async () => {
const withReply = await buildTextToRadio(42, 'hi', 3, 42);
expect(Array.from(dataBytes(withReply.bytes))).toContain(0x3d);
// omitting it must leave the field off the wire (proto3 default 0) — snake_case reply_id would be silently dropped here
const plain = await buildTextToRadio(42, 'hi', 3);
expect(Array.from(dataBytes(plain.bytes))).not.toContain(0x3d);
});
});
+13
View File
@@ -12,6 +12,8 @@ export const BROADCAST_ADDR = 0xffffffff;
export const PORT_TEXT_MESSAGE_APP = 1;
// verified against installed @meshtastic/protobufs Portnums.PortNum.POSITION_APP
export const PORT_POSITION_APP = 3;
// verified against installed @meshtastic/protobufs Portnums.PortNum.NODEINFO_APP
export const PORT_NODEINFO_APP = 4;
// verified against installed @meshtastic/protobufs Portnums.PortNum.ROUTING_APP
export const PORT_ROUTING_APP = 5;
// verified against installed @meshtastic/protobufs Portnums.PortNum.TELEMETRY_APP
@@ -75,6 +77,14 @@ export async function decodePosition(
return fromBinary(P.Mesh.PositionSchema, bytes);
}
/** NODEINFO_APP packets carry a serialized meshtastic.NodeInfo in Data.payload (live node info; deviceMetrics carries battery_level). */
export async function decodeNodeInfo(
bytes: Uint8Array,
): Promise<import('@meshtastic/protobufs').Mesh.NodeInfo> {
const P = await loadProto();
return fromBinary(P.Mesh.NodeInfoSchema, bytes);
}
/** ROUTING_APP packets carry a serialized meshtastic.Routing in Data.payload (ACK/NACK for want_ack sends). */
export async function decodeRouting(
bytes: Uint8Array,
@@ -126,6 +136,7 @@ export async function buildTextToRadio(
to: number,
text: string,
channelIndex: number,
replyId?: number,
): Promise<{ bytes: Uint8Array; id: number }> {
const P = await loadProto();
const id = randomPacketId();
@@ -140,6 +151,8 @@ export async function buildTextToRadio(
portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP,
payload: new TextEncoder().encode(text),
wantResponse: true,
// camelCase on purpose — the JS protobufs package silently drops snake_case reply_id; 0 = not a reply (proto3 default, omitted from wire)
replyId: replyId ?? 0,
},
},
});
+1
View File
@@ -91,6 +91,7 @@ describe('MCP server tools', () => {
expect(Object.keys(send.inputSchema.properties ?? {})).toEqual([
'text',
'to',
'replyId',
]);
const inject = tools.find((t) => t.name === 'inject_message')!;
+10 -4
View File
@@ -33,11 +33,17 @@ export function createMeshMcpServer(mesh: MeshService): McpServer {
server.registerTool(
'send_message',
{
description: `Send a text message over the mesh; returns { id } — the Meshtastic packet id. to: omitted/"" or "!b"/"broadcast" (case-insensitive) → broadcast; number or numeric string → that node number (integer in 1..0xffffffff); anything else is looked up by name (exact long/short name, then unique substring). Unknown or ambiguous targets are an error.${CHANNEL_NOTE}`,
inputSchema: { text: z.string(), to: z.string().optional() },
description: `Send a text message over the mesh; returns { id } — the Meshtastic packet id. to: omitted/"" or "!b"/"broadcast" (case-insensitive) → broadcast; number or numeric string → that node number (integer in 1..0xffffffff); anything else is looked up by name (exact long/short name, then unique substring). Unknown or ambiguous targets are an error. replyId (optional, integer in 1..0xffffffff): the packet id of an earlier message from GET /api/messages or get_messages — when set, the recipient app renders this message as a threaded reply/quote of that packet.${CHANNEL_NOTE}`,
inputSchema: {
text: z.string(),
to: z.string().optional(),
replyId: z.number().int().min(1).max(0xffffffff).optional(),
},
},
async ({ text, to }) =>
jsonResult({ id: await mesh.sendText(await mesh.resolveDest(to), text) }),
async ({ text, to, replyId }) =>
jsonResult({
id: await mesh.sendText(await mesh.resolveDest(to), text, replyId),
}),
);
server.registerTool(
+19 -2
View File
@@ -13,6 +13,7 @@ import {
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { BROADCAST_ADDR } from './codec.js';
import type { DeviceInfo } from './mesh.service.js';
import { MeshService } from './mesh.service.js';
@@ -38,6 +39,13 @@ export class SendTextDto {
example: '!b',
})
to?: string | number;
@ApiProperty({
required: false,
description:
'Packet id of an earlier message (from GET /api/messages) that this one replies to — the recipient app renders it as a threaded reply/quote. Integer in 1..0xffffffff.',
example: 456789,
})
replyId?: number;
}
// tag description is set via DocumentBuilder.addTag in main.ts (v12 ApiTagOptions has no description)
@@ -70,7 +78,7 @@ export class MeshController {
@ApiOperation({
summary: 'Send a text message over the mesh',
description:
'Queues a TEXT_MESSAGE_APP packet to the resolved target (node number, node name, or broadcast). DMs are unicast and wantAck-tracked via GET /api/messages; broadcasts stay pending because firmware strips want_ack. All sends ride the pinned test channel (index 3, "test" — never the primary channel) regardless of DM or broadcast, since modern firmware PKI handles unicast encryption/channel anyway. Returns the Meshtastic packet id.',
'Queues a TEXT_MESSAGE_APP packet to the resolved target (node number, node name, or broadcast). Optional replyId (integer in 1..0xffffffff) references an earlier message\'s packet id from GET /api/messages — when set, the recipient app renders this message as a threaded reply/quote of that packet. DMs are unicast and wantAck-tracked via GET /api/messages; broadcasts stay pending because firmware strips want_ack. All sends ride the pinned test channel (index 3, "test" — never the primary channel) regardless of DM or broadcast, since modern firmware PKI handles unicast encryption/channel anyway. Returns the Meshtastic packet id.',
})
@ApiResponse({
status: 200,
@@ -83,6 +91,15 @@ export class MeshController {
if (typeof body?.text !== 'string' || body.text.length === 0) {
throw new BadRequestException('text must be a non-empty string');
}
const replyId = body.replyId;
if (
replyId !== undefined &&
(!Number.isInteger(replyId) || replyId < 1 || replyId > BROADCAST_ADDR)
) {
throw new BadRequestException(
'replyId must be an integer in 1..0xffffffff',
);
}
let to: number;
try {
to = await this.mesh.resolveDest(body.to);
@@ -91,7 +108,7 @@ export class MeshController {
err instanceof Error ? err.message : String(err),
);
}
return { id: await this.mesh.sendText(to, body.text) };
return { id: await this.mesh.sendText(to, body.text, replyId) };
}
@Get('channels')
+224
View File
@@ -1,4 +1,5 @@
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
import { Logger } from '@nestjs/common';
import * as P from '@meshtastic/protobufs';
import { DatabaseSync } from 'node:sqlite';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -43,6 +44,36 @@ function positionPacketFrame(
);
}
// live node-info update: NODEINFO_APP packet with a serialized NodeInfo in Data.payload
function nodeInfoPacketFrame(
from: number,
fields: {
user?: { id?: string; longName?: string };
deviceMetrics?: { batteryLevel?: number };
},
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.NODEINFO_APP,
payload: toBinary(
P.Mesh.NodeInfoSchema,
create(P.Mesh.NodeInfoSchema, { num: from, ...fields }),
),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from,
id: 1,
payloadVariant: { case: 'decoded', value: data },
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'packet', value: pkt },
}),
),
);
}
// inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload
function textPacketFrame(
from: number,
@@ -295,6 +326,64 @@ describe('MeshService send', () => {
bridge.close();
}
});
it('sets Data.reply_id when replyId is given and leaves it unset otherwise', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
const out: Uint8Array[] = [];
bridge.sockets[0].on('data', (c) => out.push(c));
await svc.sendText(42, 'reply send', 42); // replyId = 42
await svc.sendText(43, 'plain send');
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 === 2);
const [replyPkt, plainPkt] = readOutbound();
const replyData =
replyPkt.payloadVariant?.case === 'decoded'
? replyPkt.payloadVariant.value
: undefined;
expect(
new TextDecoder().decode(replyData?.payload ?? new Uint8Array()),
).toBe('reply send');
expect(replyData?.replyId).toBe(42); // threaded reply to packet 42
const plainData =
plainPkt.payloadVariant?.case === 'decoded'
? plainPkt.payloadVariant.value
: undefined;
expect(
new TextDecoder().decode(plainData?.payload ?? new Uint8Array()),
).toBe('plain send');
expect(plainData?.replyId).toBe(0); // omitted → proto3 default, not a reply
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
});
describe('MeshService node database', () => {
@@ -359,6 +448,47 @@ describe('MeshService node database', () => {
}
});
it('updates batteryLevel from live NODEINFO_APP packets (and stubs unknown nodes)', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
// sync entry with a stale battery, then a live NODEINFO_APP update
bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
await waitFor(async () => (await svc.nodes()).length === 1);
expect((await svc.nodes())[0]?.batteryLevel).toBe(87);
bridge.sockets[0].write(
nodeInfoPacketFrame(KAMIL_NUM, { deviceMetrics: { batteryLevel: 9 } }),
);
await waitFor(async () => (await svc.nodes())[0]?.batteryLevel === 9);
// other fields of the sync entry survive the live update
expect(await svc.node(KAMIL_NUM)).toMatchObject({
longName: 'Kamil SP8MK/P',
shortName: 'KK',
batteryLevel: 9,
});
// unknown node: a NODEINFO_APP with deviceMetrics creates a stub entry
bridge.sockets[0].write(
nodeInfoPacketFrame(555, { deviceMetrics: { batteryLevel: 3 } }),
);
await waitFor(async () => (await svc.nodes()).length === 2);
expect((await svc.node(555))?.batteryLevel).toBe(3);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('looks up nodes by exact num, numeric string, exact name and unique substring', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
@@ -789,6 +919,100 @@ describe('MeshService notify webhook', () => {
bridge.close();
}
});
it('retries a failed webhook POST with backoff and stops on success', async () => {
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
process.env.MESH_NOTIFY_CHANNEL = '0';
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new Error('boom 1'))
.mockRejectedValueOnce(new Error('boom 2'))
.mockResolvedValueOnce(undefined);
vi.stubGlobal('fetch', fetchMock);
const warnSpy = vi.spyOn(Logger.prototype, 'warn');
const sleep = vi.fn(async (_ms: number) => {});
const svc = new MeshService();
(svc as unknown as { sleep(ms: number): Promise<void> }).sleep = sleep;
try {
expect(() => svc.injectMessage('hi', 42)).not.toThrow();
await waitFor(() => fetchMock.mock.calls.length === 3);
// success on the third attempt: exactly 3 POSTs, no dropped log
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(sleep).toHaveBeenNthCalledWith(1, 2_000);
expect(sleep).toHaveBeenNthCalledWith(2, 10_000);
const warns = warnSpy.mock.calls.map((c) => String(c[0]));
expect(warns.some((w) => w.includes('attempt 1/4'))).toBe(true);
expect(warns.some((w) => w.includes('attempt 2/4'))).toBe(true);
expect(warns.some((w) => w.includes('notify dropped'))).toBe(false);
} finally {
warnSpy.mockRestore();
vi.unstubAllGlobals();
delete process.env.MESH_NOTIFY_URL;
delete process.env.MESH_NOTIFY_CHANNEL;
}
});
it('retries a webhook POST that answers with an HTTP error status and stops on success', async () => {
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
process.env.MESH_NOTIFY_CHANNEL = '0';
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 502 })
.mockResolvedValueOnce({ ok: false, status: 502 })
.mockResolvedValueOnce({ ok: true, status: 200 });
vi.stubGlobal('fetch', fetchMock);
const warnSpy = vi.spyOn(Logger.prototype, 'warn');
const sleep = vi.fn(async (_ms: number) => {});
const svc = new MeshService();
(svc as unknown as { sleep(ms: number): Promise<void> }).sleep = sleep;
try {
expect(() => svc.injectMessage('hi', 42)).not.toThrow();
await waitFor(() => fetchMock.mock.calls.length === 3);
// HTTP 502 twice, then 200: exactly 3 POSTs, no dropped log
expect(fetchMock).toHaveBeenCalledTimes(3);
const warns = warnSpy.mock.calls.map((c) => String(c[0]));
expect(warns.some((w) => w.includes('attempt 1/4'))).toBe(true);
expect(warns.some((w) => w.includes('HTTP 502'))).toBe(true);
expect(warns.some((w) => w.includes('notify dropped'))).toBe(false);
} finally {
warnSpy.mockRestore();
vi.unstubAllGlobals();
delete process.env.MESH_NOTIFY_URL;
delete process.env.MESH_NOTIFY_CHANNEL;
}
});
it('gives up after 4 attempts and logs notify dropped', async () => {
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
process.env.MESH_NOTIFY_CHANNEL = '0';
const fetchMock = vi.fn().mockRejectedValue(new Error('down'));
vi.stubGlobal('fetch', fetchMock);
const warnSpy = vi.spyOn(Logger.prototype, 'warn');
const sleep = vi.fn(async (_ms: number) => {});
const svc = new MeshService();
(svc as unknown as { sleep(ms: number): Promise<void> }).sleep = sleep;
try {
expect(() => svc.injectMessage('hi', 42)).not.toThrow();
await waitFor(() =>
warnSpy.mock.calls.some((c) =>
String(c[0]).includes('notify dropped after 4 attempts'),
),
);
// exactly 4 attempts, final WARN with the exact message, nothing thrown
expect(fetchMock).toHaveBeenCalledTimes(4);
const warns = warnSpy.mock.calls.map((c) => String(c[0]));
expect(warns).toContain('notify dropped after 4 attempts: down');
expect(sleep).toHaveBeenNthCalledWith(3, 30_000);
} finally {
warnSpy.mockRestore();
vi.unstubAllGlobals();
delete process.env.MESH_NOTIFY_URL;
delete process.env.MESH_NOTIFY_CHANNEL;
}
});
});
describe('MeshService injectMessage', () => {
+63 -11
View File
@@ -11,6 +11,7 @@ import type { DatabaseSync } from 'node:sqlite';
import type * as Proto from '@meshtastic/protobufs';
import {
BROADCAST_ADDR,
PORT_NODEINFO_APP,
PORT_POSITION_APP,
PORT_ROUTING_APP,
PORT_TELEMETRY_APP,
@@ -18,6 +19,7 @@ import {
buildTextToRadio,
buildWantConfigToRadio,
decodeFromRadio,
decodeNodeInfo,
decodePosition,
decodeRouting,
decodeTelemetry,
@@ -130,6 +132,8 @@ function jsonSafe<T>(value: T): T {
// key material must never leave the process — replaced with a marker so callers can see the field exists
const REDACTED = 'REDACTED';
// ponytail: hardcoded notify-webhook backoff [2s, 10s, 30s] (~45 s window) — no env knob; tune here if outages run longer
const NOTIFY_RETRY_DELAYS_MS = [2_000, 10_000, 30_000];
/** Config sections that carry secrets: security (PKI keys) and network (wifi psk). */
function redactSection(
name: string,
@@ -441,16 +445,41 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
}
const body: Record<string, unknown> = { text };
if (from !== undefined) body.from = from;
void fetch(this.notifyUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
}).catch((err: unknown) =>
this.logger.warn('notify webhook failed: ' + (err as Error).message),
);
void this.postNotify(body);
}
}
/** POST the webhook with retries: up to NOTIFY_RETRY_DELAYS_MS.length + 1 attempts total. */
private async postNotify(body: Record<string, unknown>): Promise<void> {
const maxAttempts = NOTIFY_RETRY_DELAYS_MS.length + 1;
for (let attempt = 1; ; attempt++) {
try {
const res = await fetch(this.notifyUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (res && !res.ok) throw new Error('HTTP ' + res.status); // HTTP errors retry like network failures
return; // success on any attempt — same as today's single POST
} catch (err: unknown) {
const msg = (err as Error).message;
this.logger.warn(
`notify webhook failed (attempt ${attempt}/${maxAttempts}): ${msg}`,
);
if (attempt === maxAttempts) {
this.logger.warn(`notify dropped after ${attempt} attempts: ${msg}`);
return;
}
await this.sleep(NOTIFY_RETRY_DELAYS_MS[attempt - 1]);
}
}
}
/** Injectable so specs can skip the backoff delays. */
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Telemetry records, newest first: TELEMETRY_APP packets from any node — device metrics
* (battery/voltage/uptime/channel+air utilization) and environment sensor readings where nodes have them.
@@ -461,17 +490,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
return [...this.telemetryHistory].reverse();
}
/** Send `text` to node `to` on the pinned test channel; returns the packet id. Throws ServiceUnavailable when the bridge is down. */
async sendText(to: number, text: string): Promise<number> {
/** 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,
channel?: number,
): Promise<number> {
if (!this.connected || !this.socket)
throw new ServiceUnavailableException('bridge not connected');
const { bytes, id } = await buildTextToRadio(to, text, this.sendChannel);
const ch = channel ?? this.sendChannel;
const { bytes, id } = await buildTextToRadio(to, text, ch, replyId);
const rec: MessageRecord = {
id,
dir: 'tx',
to,
text,
channel: this.sendChannel,
channel: ch,
at: Date.now(),
status: 'pending',
};
@@ -644,6 +679,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
position: pos,
});
}
} else if (decoded && decoded.portnum === PORT_NODEINFO_APP) {
// live node-info update: NodeInfo serialized in Data.payload; deviceMetrics carries
// battery_level so /api/nodes and the battery alerts see fresh values between syncs
let ni: Proto.Mesh.NodeInfo | undefined;
try {
ni = await decodeNodeInfo(decoded.payload);
} catch {
ni = undefined; // malformed payload — skip, keep whatever we had
}
if (ni?.deviceMetrics !== undefined) {
const num = pkt.from;
this.nodeMap.set(num, {
...this.nodeMap.get(num),
num,
deviceMetrics: ni.deviceMetrics,
});
}
} else if (decoded && decoded.portnum === PORT_ROUTING_APP) {
// ACK/NACK for our want_ack sends: Routing serialized in Data.payload,
// Data.requestId = the original packet id we sent