Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b677c05681 | ||
|
|
83e47c9609 | ||
|
|
5eb1a79e70 | ||
|
|
7d7c569d08 | ||
|
|
c0c069ef08 | ||
|
|
1b712f7eee | ||
|
|
6f6b704dcf | ||
|
|
fe8d2dbe0b | ||
|
|
d0b71e6e91 | ||
|
|
8ddd91d4b5 | ||
|
|
6ce0b1ae7b | ||
|
|
15d93ec2a4 | ||
|
|
0909fbc6e2 | ||
|
|
b8ae5d39f7 | ||
|
|
5999159e17 | ||
|
|
36021ec2e8 | ||
|
|
dca0dfa894 | ||
|
|
5e03546f40 |
@@ -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: when an RX text arrives on channel `MESH_NOTIFY_CHANNEL` (default `3`), the in-process `OpencodeService` notifier (`src/opencode`, registered in `MeshModule`) pushes it to opencode — fire-and-forget, no response handling. It resolves the mesh-bot workspace dynamically per request from the opencode API (`OPENCODE_API`, default `http://172.24.0.1:9898`; matched by `OPENCODE_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`), fallback name `mesh-bot`, status `ready` preferred) and posts to its most recently updated session (`time.updated`) — or the pinned `OPENCODE_SESSION` when set, which skips discovery. The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message[ from node <n>]: "<text>" — please react to it.` (the `from node` part omitted when absent), HTTP Basic auth via `OPENCODE_USER`/`OPENCODE_PASS`; empty credentials disable the notifier entirely (one startup log line, no-op). Every call has a 15 s timeout; a read timeout on the final message POST means DELIVERED (opencode accepts immediately but holds the response until the agent turn finishes) and is treated as success without retry, while workspace/session GET failures are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped. 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?, 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
|
||||
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
# Changelog
|
||||
|
||||
## v0.14.0 — 2026-09-25 14:11
|
||||
|
||||
- The standalone Python notify forwarder (`ops/forwarder`, compose service `forwarder`) is gone: its logic now lives in-process as a new `src/opencode` module — an `@Injectable()` `OpencodeService` registered in `MeshModule` and injected into `MeshService`. When an RX text lands on channel `MESH_NOTIFY_CHANNEL` (default 3), `MeshService.notifyRx()` calls `void this.opencode.notify(text, from)` instead of the old webhook POST; the channel gate and self-echo filter stay in `MeshService`, everything else (workspace/session resolution, message POST, retries) moved into the service.
|
||||
- Ported forwarder behavior 1:1 with new env names: `OPENCODE_API` (default `http://172.24.0.1:9898` — opencode binds 0.0.0.0 on the host and is reachable from the bot container over dev_net; the old forwarder used 127.0.0.1 only because it ran with `network_mode: host`), `OPENCODE_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`), `OPENCODE_SESSION` (empty = discover the most recently updated session per request; non-empty = pin that session and skip discovery), `OPENCODE_USER` / `OPENCODE_PASS` (HTTP Basic auth on every call). Missing/empty credentials disable the notifier instead of failing: one warn at construction, `notify()` is a no-op — the app keeps running.
|
||||
- Workspace/session resolution per request (no caching — messages are rare): `GET {api}/api/workspaces` → match by `path == OPENCODE_WORKSPACE_PATH`, fallback name `mesh-bot`, status `ready` preferred when several match; then, unless pinned, `GET {proxyPath}/session` → most recently updated session (`time.updated`; first entry when none carry it). The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message[ from node <n>]: "<text>" — please react to it.` (em-dash, not a hyphen; the `from node` part omitted when there is no sender).
|
||||
- Timeout semantics preserved: every call has a 15 s timeout (`AbortSignal.timeout(15000)`); a read timeout on the final message POST means DELIVERED — opencode accepts immediately but holds the response until the agent turn finishes — so it is treated as success and never retried. Workspace/session GET failures are ordinary retryable failures: backoff [2 s, 10 s, 30 s] (the constant moved here from `mesh.service.ts`), up to 4 attempts total, final failure logs `opencode notify dropped after N attempts`.
|
||||
- `MeshService` loses `notifyUrl`, `postNotify()` and `NOTIFY_RETRY_DELAYS_MS`; the constructor gains an optional second parameter (`db?, opencode?`) so all existing direct constructions in specs keep working — without a notifier wired, `notifyRx()` is a no-op.
|
||||
- `docker-compose.yml`: forwarder service block deleted; bot environment drops `MESH_NOTIFY_URL`, keeps `MESH_NOTIFY_CHANNEL`, and passes the five `OPENCODE_*` vars via `${VAR:-default}` interpolation from `.env`. Local `.env` (gitignored) renamed `FORWARDER_USER/PASS/SESSION` → `OPENCODE_USER/PASS/SESSION` (values unchanged, session pin kept), added `OPENCODE_API=http://172.24.0.1:9898`, dropped the `MESH_NOTIFY_URL=` line.
|
||||
- Tests: new `src/opencode/opencode.service.spec.ts` (12 specs — exact wrapper text with/without `from` incl. em-dash and Basic auth header, workspace path match / name fallback / ready preference, session pin vs discovery-by-max-time.updated vs first-entry fallback, POST timeout = delivered success with no retry, GET failure retried then succeeds, give up after 4 attempts with the dropped log, disabled-when-no-creds no-op + construction warn). `mesh.service.spec.ts` notify block rewritten around a mocked `OpencodeService`: channel gate (default 3), off-channel skip, injected channel-0 case incl. `from` passthrough as undefined, self-echo skip for rx and injected; the ten webhook-specific tests (URL/body/retry/HTTP-error/give-up) are gone — retry semantics now live in the opencode spec.
|
||||
- Docs: README "Notify forwarder" section replaced by "Opencode notifier", env-var list updated (`MESH_NOTIFY_URL` bullet removed, `OPENCODE_*` documented); AGENTS.md webhook paragraph rewritten for the built-in notifier; MESHTASTIC.md notification rule updated. All FORWARDER_* mentions removed from tracked files (CHANGELOG history keeps them as-is).
|
||||
|
||||
## 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).
|
||||
- `network_mode: host` → listens on the host's `0.0.0.0:4097`, so `MESH_NOTIFY_URL=http://172.24.0.1:4097/notify` keeps working unchanged from the bot container, and it reaches opencode at `127.0.0.1:9898`. No `networks:` key (host mode), no Traefik labels; `restart: unless-stopped`.
|
||||
- Config moves out of the script into compose environment from `.env` (gitignored): `FORWARDER_BASE` / `FORWARDER_SESSION` keep their current values as defaults, `FORWARDER_USER` / `FORWARDER_PASS` are required — the container exits on startup if either is missing. The real credentials are no longer hardcoded anywhere in the repo; nothing sensitive committed.
|
||||
- No app code changes — existing 59 tests stay green.
|
||||
|
||||
## v0.12.1 — 2026-09-25 07:57
|
||||
|
||||
- Hotfix for the v0.12.0 bootstrap crash (`UnknownDependenciesException`): `MeshModule` now exports `MeshService`, and `WeatherService`'s `import type { MeshService }` became a value import — with `emitDecoratorMetadata` the type-only import is erased, so the constructor's DI token was `undefined` at runtime even with the export (the unit suite instantiates services directly and never booted the module graph, which is why 58 green tests still shipped a broken app).
|
||||
- `docker-compose.yml`: `WEATHER_CRON` passthrough (`${WEATHER_CRON:-}`) mirroring the existing notify-var pattern — empty/unset falls back to the service default `'0 * * * *'`. No LAT/LON/CITY passthroughs (service-side defaults suffice).
|
||||
- New DI smoke spec `src/weather/di.spec.ts`: compiles `WeatherModule` with Nest's `Test.createTestingModule().compile()` (no `app.init()`, so no TCP connection) and asserts `WeatherService` resolves its `MeshService` from `MeshModule` — fails if the export regresses.
|
||||
|
||||
## v0.12.0 — 2026-09-25 07:42
|
||||
|
||||
- New `src/weather` module: hourly cron-triggered weather report for Lublin, broadcast on the mesh (pinned send channel). `WeatherService.fetchWeather()` GETs Open-Meteo current conditions (`temperature_2m`, `apparent_temperature`, `relative_humidity_2m`, `wind_speed_10m`, `weather_code` — free API, no key) via global fetch; `formatReport()` renders one short line (e.g. `Lublin weather: 18°C (feels 17), partly cloudy, humidity 65%, wind 12 km/h`) with a WMO weather-code → description map (~19 common codes, unknown → `code <n>`). The `@Cron` handler fetches, formats and broadcasts via `MeshService.sendText(BROADCAST_ADDR, report)`; any error logs `[weather] report failed: ...` and is swallowed — the cron never crashes the app.
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
@@ -24,4 +25,4 @@ Rules for how mesh-bot communicates over Meshtastic and how the agent should rea
|
||||
|
||||
## Notifications (mesh → agent)
|
||||
|
||||
- RX text on the notify channel (`MESH_NOTIFY_CHANNEL`, default 3) triggers an outbound webhook POST `{text, from}` to `MESH_NOTIFY_URL` (host forwarder :4097 → opencode session). Messages from our own node are never notified (self-echo filter, v0.11.2).
|
||||
- RX text on the notify channel (`MESH_NOTIFY_CHANNEL`, default 3) is pushed in-process to the live opencode session by `OpencodeService` (workspace/session resolved per request from the opencode API; disabled without `OPENCODE_USER`/`OPENCODE_PASS`). Messages from our own node are never notified (self-echo filter, v0.11.2).
|
||||
|
||||
@@ -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
|
||||
@@ -97,14 +97,24 @@ Hourly cron job (`src/weather`): fetches current conditions for Lublin from [Ope
|
||||
- `WEATHER_LAT` (default `51.2465`) / `WEATHER_LON` (default `22.5684`) — coordinates queried from Open-Meteo
|
||||
- `WEATHER_CITY` (default `"Lublin"`) — city name in the report line
|
||||
|
||||
## Opencode notifier
|
||||
|
||||
In-process service (`src/opencode`, no separate compose service): when an rx text message arrives on `MESH_NOTIFY_CHANNEL`, it pushes it to the mesh-bot folder's live opencode session (Basic auth). The opencode workspace UUID is regenerated on host restarts, so the notifier resolves it dynamically per request via the opencode API: `GET {OPENCODE_API}/api/workspaces` → match by `path == OPENCODE_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. The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message[ from node <n>]: "<text>" — please react to it.` (the `from node` part omitted when there is no sender). opencode accepts the message immediately but holds the HTTP response until the agent turn finishes, so a 15 s read timeout on that POST means **delivered** — treated as success, never retried; workspace/session GET failures are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped.
|
||||
|
||||
Config lives in `.env` (gitignored) and is passed via compose environment — credentials are never committed to the repo:
|
||||
|
||||
- `OPENCODE_API` (default `http://172.24.0.1:9898`) — opencode API base URL as seen from the bot container (dev_net bridge → host; opencode binds 0.0.0.0 on the host)
|
||||
- `OPENCODE_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)
|
||||
- `OPENCODE_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
|
||||
- `OPENCODE_USER` / `OPENCODE_PASS` — Basic auth credentials; missing/empty **disables** the notifier (one startup log line, no-op) instead of failing
|
||||
|
||||
## Env vars
|
||||
|
||||
- `MESH_HOST` (default `192.168.1.4`) — bridge host
|
||||
- `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_CHANNEL` (default `3`) — channel index that triggers the notify webhook
|
||||
- `MESH_NOTIFY_CHANNEL` (default `3`) — channel index whose rx texts trigger the opencode notifier (see "Opencode notifier"; synthetic injected messages, channel 0, trigger it too when set to match; messages from our own node are never notified)
|
||||
- `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
|
||||
- `PORT` (default `3000`) — HTTP listen port
|
||||
|
||||
+6
-2
@@ -6,8 +6,13 @@ services:
|
||||
MESH_PORT: '4403'
|
||||
MESH_DB_PATH: ./data/mesh-bot.db
|
||||
MESH_POLL_MS: '1000'
|
||||
MESH_NOTIFY_URL: ${MESH_NOTIFY_URL:-}
|
||||
MESH_NOTIFY_CHANNEL: '${MESH_NOTIFY_CHANNEL:-3}'
|
||||
OPENCODE_API: ${OPENCODE_API:-http://172.24.0.1:9898}
|
||||
OPENCODE_WORKSPACE_PATH: ${OPENCODE_WORKSPACE_PATH:-/home/krzyw/projects/sandbox/mesh-bot}
|
||||
OPENCODE_SESSION: ${OPENCODE_SESSION:-}
|
||||
OPENCODE_USER: ${OPENCODE_USER:-}
|
||||
OPENCODE_PASS: ${OPENCODE_PASS:-}
|
||||
WEATHER_CRON: ${WEATHER_CRON:-}
|
||||
volumes:
|
||||
- mesh_data:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -18,7 +23,6 @@ services:
|
||||
- 'traefik.http.routers.mesh-bot.rule=Host(`bot.dev.kkhost.pl`)'
|
||||
- 'traefik.http.routers.mesh-bot.entrypoints=web'
|
||||
- 'traefik.http.services.mesh-bot.loadbalancer.server.port=3000'
|
||||
|
||||
volumes:
|
||||
mesh_data:
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mesh-bot",
|
||||
"version": "0.12.0",
|
||||
"version": "0.14.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
+2
-1
@@ -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 {}
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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')!;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { OpencodeService } from '../opencode/opencode.service.js';
|
||||
import { McpController } from './mcp.controller.js';
|
||||
import { MeshController } from './mesh.controller.js';
|
||||
import { initMessagesTable, MeshService } from './mesh.service.js';
|
||||
@@ -18,10 +19,14 @@ function openMessageDb(): DatabaseSync {
|
||||
@Module({
|
||||
controllers: [MeshController, McpController],
|
||||
providers: [
|
||||
OpencodeService,
|
||||
{
|
||||
provide: MeshService,
|
||||
useFactory: () => new MeshService(openMessageDb()),
|
||||
useFactory: (opencode: OpencodeService) =>
|
||||
new MeshService(openMessageDb(), opencode),
|
||||
inject: [OpencodeService],
|
||||
},
|
||||
],
|
||||
exports: [MeshService],
|
||||
})
|
||||
export class MeshModule {}
|
||||
|
||||
+173
-114
@@ -14,6 +14,7 @@ import {
|
||||
telemetryPacketFrame,
|
||||
waitFor,
|
||||
} from './fake-bridge.js';
|
||||
import { OpencodeService } from '../opencode/opencode.service.js';
|
||||
import { initMessagesTable, MeshService } from './mesh.service.js';
|
||||
|
||||
// live position update: POSITION_APP packet with a serialized Position in Data.payload
|
||||
@@ -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';
|
||||
@@ -562,12 +692,14 @@ describe('MeshService message history', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshService notify webhook', () => {
|
||||
const fetchMock = vi.fn(async (..._args: unknown[]) => undefined);
|
||||
describe('MeshService opencode notifier', () => {
|
||||
// our own node number for the self-echo filter specs (distinct from KAMIL_NUM, an external user)
|
||||
const MY_NODE = 987654321;
|
||||
let notifyMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => fetchMock.mockClear());
|
||||
beforeEach(() => {
|
||||
notifyMock = vi.fn(async (_text: string, _from?: number) => {});
|
||||
});
|
||||
|
||||
// myInfo frame as streamed during the initial sync — sets svc.myNodeNum
|
||||
function myInfoFrame(num: number): Uint8Array {
|
||||
@@ -581,13 +713,20 @@ describe('MeshService notify webhook', () => {
|
||||
);
|
||||
}
|
||||
|
||||
it('POSTs {text, from} to MESH_NOTIFY_URL when an rx text lands on the notify channel (default 3)', async () => {
|
||||
// MeshService with a mocked OpencodeService wired in (structural cast — only notify() is used)
|
||||
function makeSvc(): MeshService {
|
||||
return new MeshService(
|
||||
undefined,
|
||||
{ notify: notifyMock } as unknown as OpencodeService,
|
||||
);
|
||||
}
|
||||
|
||||
it('notifies via the opencode service when an rx text lands on the notify channel (default 3)', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const svc = new MeshService();
|
||||
delete process.env.MESH_NOTIFY_CHANNEL; // default 3
|
||||
const svc = makeSvc();
|
||||
try {
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
@@ -596,36 +735,22 @@ describe('MeshService notify webhook', () => {
|
||||
|
||||
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3));
|
||||
|
||||
await waitFor(() => fetchMock.mock.calls.length === 1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('http://notify.example/hook');
|
||||
const opts = init as {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
};
|
||||
expect(opts.method).toBe('POST');
|
||||
expect(opts.headers?.['content-type']).toBe('application/json');
|
||||
expect(JSON.parse(String(opts.body))).toEqual({
|
||||
text: 'ping',
|
||||
from: KAMIL_NUM,
|
||||
});
|
||||
await waitFor(() => notifyMock.mock.calls.length === 1);
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
expect(notifyMock).toHaveBeenCalledWith('ping', KAMIL_NUM);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.MESH_NOTIFY_URL;
|
||||
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not call fetch for rx texts on other channels', async () => {
|
||||
it('does not notify for rx texts on other channels', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const svc = new MeshService();
|
||||
delete process.env.MESH_NOTIFY_CHANNEL; // default 3
|
||||
const svc = makeSvc();
|
||||
try {
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
@@ -634,109 +759,48 @@ describe('MeshService notify webhook', () => {
|
||||
|
||||
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 2));
|
||||
|
||||
// the message is captured in history — but no webhook fires off-channel
|
||||
// the message is captured in history — but no notification fires off-channel
|
||||
await waitFor(() => svc.messages().length === 1);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.MESH_NOTIFY_URL;
|
||||
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('never calls fetch when MESH_NOTIFY_URL is unset or empty', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
delete process.env.MESH_NOTIFY_URL; // unset → disabled
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const svc = new MeshService();
|
||||
try {
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||
);
|
||||
|
||||
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3));
|
||||
await waitFor(() => svc.messages().length === 1);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
// empty string → also disabled (a fresh service re-reads the env)
|
||||
process.env.MESH_NOTIFY_URL = '';
|
||||
const svc2 = new MeshService();
|
||||
try {
|
||||
svc2.onModuleInit();
|
||||
await waitFor(
|
||||
() => bridge.sockets.length === 2 && svc2.status().connected,
|
||||
);
|
||||
|
||||
bridge.sockets[1].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3));
|
||||
await waitFor(() => svc2.messages().length === 1);
|
||||
} finally {
|
||||
delete process.env.MESH_NOTIFY_URL;
|
||||
svc2.onModuleDestroy();
|
||||
}
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('fires the webhook for an injected message when MESH_NOTIFY_CHANNEL matches its channel (0)', async () => {
|
||||
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
|
||||
it('fires the notifier for an injected message when MESH_NOTIFY_CHANNEL matches its channel (0)', async () => {
|
||||
process.env.MESH_NOTIFY_CHANNEL = '0';
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const svc = new MeshService();
|
||||
const svc = makeSvc();
|
||||
try {
|
||||
svc.injectMessage('hi', 42);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('http://notify.example/hook');
|
||||
const opts = init as {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
};
|
||||
expect(opts.method).toBe('POST');
|
||||
expect(opts.headers?.['content-type']).toBe('application/json');
|
||||
expect(JSON.parse(String(opts.body))).toEqual({ text: 'hi', from: 42 });
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
expect(notifyMock).toHaveBeenCalledWith('hi', 42);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.MESH_NOTIFY_URL;
|
||||
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||
}
|
||||
});
|
||||
|
||||
it('omits `from` in the webhook body when an injected message has no sender', async () => {
|
||||
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
|
||||
it('passes `from` through as undefined when an injected message has no sender', async () => {
|
||||
process.env.MESH_NOTIFY_CHANNEL = '0';
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const svc = new MeshService();
|
||||
const svc = makeSvc();
|
||||
try {
|
||||
svc.injectMessage('hi');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
const opts = init as { body?: string };
|
||||
expect(JSON.parse(String(opts.body))).toEqual({ text: 'hi' });
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
expect(notifyMock).toHaveBeenCalledWith('hi', undefined);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.MESH_NOTIFY_URL;
|
||||
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not call fetch for rx texts from our own node on the notify channel', async () => {
|
||||
it('does not notify for rx texts from our own node on the notify channel', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const svc = new MeshService();
|
||||
delete process.env.MESH_NOTIFY_CHANNEL; // default 3
|
||||
const svc = makeSvc();
|
||||
try {
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
@@ -747,28 +811,25 @@ describe('MeshService notify webhook', () => {
|
||||
bridge.sockets[0].write(myInfoFrame(MY_NODE));
|
||||
await waitFor(() => svc.status().myNodeNum === MY_NODE);
|
||||
|
||||
// an rx text from ourselves on the notify channel must not fire the webhook
|
||||
// an rx text from ourselves on the notify channel must not fire the notifier
|
||||
bridge.sockets[0].write(textPacketFrame(MY_NODE, 42, 'ping', 3));
|
||||
|
||||
// history capture proves the packet was processed (notifyRx runs right after it)
|
||||
await waitFor(() => svc.messages().length === 1);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.MESH_NOTIFY_URL;
|
||||
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not call fetch for an injected message from our own node', async () => {
|
||||
it('does not notify for an injected message from our own node', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
|
||||
process.env.MESH_NOTIFY_CHANNEL = '0';
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const svc = new MeshService();
|
||||
const svc = makeSvc();
|
||||
try {
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
@@ -780,10 +841,8 @@ describe('MeshService notify webhook', () => {
|
||||
|
||||
svc.injectMessage('hi', MY_NODE);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.MESH_NOTIFY_URL;
|
||||
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
|
||||
+40
-20
@@ -9,8 +9,10 @@ import * as net from 'node:net';
|
||||
// type-only import: node:sqlite is only required at runtime by whoever opens a database (mesh.module.ts / tests)
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type * as Proto from '@meshtastic/protobufs';
|
||||
import { OpencodeService } from '../opencode/opencode.service.js';
|
||||
import {
|
||||
BROADCAST_ADDR,
|
||||
PORT_NODEINFO_APP,
|
||||
PORT_POSITION_APP,
|
||||
PORT_ROUTING_APP,
|
||||
PORT_TELEMETRY_APP,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
buildTextToRadio,
|
||||
buildWantConfigToRadio,
|
||||
decodeFromRadio,
|
||||
decodeNodeInfo,
|
||||
decodePosition,
|
||||
decodeRouting,
|
||||
decodeTelemetry,
|
||||
@@ -157,8 +160,8 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
process.env.MESH_SEND_CHANNEL ?? '3',
|
||||
10,
|
||||
);
|
||||
// outbound notification webhook: fire-and-forget POST when an rx text lands on the notify channel; empty URL = disabled
|
||||
private readonly notifyUrl = process.env.MESH_NOTIFY_URL ?? '';
|
||||
// outbound notification: fire-and-forget push to the opencode notifier when an rx text lands on the notify channel;
|
||||
// disabled state (missing credentials) lives in OpencodeService itself
|
||||
private readonly notifyChannel = parseInt(
|
||||
process.env.MESH_NOTIFY_CHANNEL ?? '3',
|
||||
10,
|
||||
@@ -168,10 +171,13 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/** SQLite database backing the message history; null = persistence disabled (tests construct without one). */
|
||||
private db: DatabaseSync | null = null;
|
||||
/** In-process opencode notifier for rx texts on the notify channel; null in tests that don't wire one. */
|
||||
private readonly opencode: OpencodeService | null;
|
||||
private pollTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(db?: DatabaseSync) {
|
||||
constructor(db?: DatabaseSync, opencode?: OpencodeService) {
|
||||
this.db = db ?? null;
|
||||
this.opencode = opencode ?? null;
|
||||
}
|
||||
|
||||
private socket: net.Socket | null = null;
|
||||
@@ -426,29 +432,20 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
at: Date.now(),
|
||||
};
|
||||
this.pushHistory(rec);
|
||||
// injected records ride channel 0 — the webhook fires only when MESH_NOTIFY_CHANNEL is set to match
|
||||
// injected records ride channel 0 — the notifier fires only when MESH_NOTIFY_CHANNEL is set to match
|
||||
this.notifyRx(0, text, from);
|
||||
return { id: rec.id };
|
||||
}
|
||||
|
||||
/** Fire-and-forget notify-webhook POST for an rx text on `channel` (no-op unless URL is set and channels match). */
|
||||
/** Fire-and-forget opencode notifier push for an rx text on `channel` (no-op unless channels match and a notifier is wired). */
|
||||
private notifyRx(channel: number, text: string, from?: number): void {
|
||||
if (this.notifyUrl && channel === this.notifyChannel) {
|
||||
if (!this.opencode || channel !== this.notifyChannel) return;
|
||||
// self-echo filter: our own node must never trigger a notification of itself
|
||||
if (from !== undefined && from === this.myNodeNum) {
|
||||
this.logger.debug(`notify skipped: rx from our own node ${from}`);
|
||||
return;
|
||||
}
|
||||
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.opencode.notify(text, from);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -461,17 +458,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 +647,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
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { OpencodeService } from './opencode.service.js';
|
||||
|
||||
const API = 'http://172.24.0.1:9898';
|
||||
const WS_PATH = '/home/krzyw/projects/sandbox/mesh-bot';
|
||||
// the workspace UUID is regenerated on host restarts — only path/name/proxyPath matter
|
||||
const WORKSPACE = {
|
||||
id: 'ws-uuid',
|
||||
path: WS_PATH,
|
||||
name: 'mesh-bot',
|
||||
status: 'ready',
|
||||
proxyPath: '/proxy/abc',
|
||||
};
|
||||
|
||||
function json(body: unknown): Response {
|
||||
return { ok: true, status: 200, json: async () => body } as Response;
|
||||
}
|
||||
|
||||
function timeoutError(): Error {
|
||||
return new DOMException('signal timed out', 'TimeoutError');
|
||||
}
|
||||
|
||||
/** Fresh service with a clean env (creds unset by default — pass them explicitly). */
|
||||
function makeService(env: Record<string, string> = {}): OpencodeService {
|
||||
process.env.OPENCODE_API = API;
|
||||
process.env.OPENCODE_WORKSPACE_PATH = WS_PATH;
|
||||
for (const k of ['OPENCODE_SESSION', 'OPENCODE_USER', 'OPENCODE_PASS'])
|
||||
delete process.env[k];
|
||||
Object.assign(process.env, env);
|
||||
return new OpencodeService();
|
||||
}
|
||||
|
||||
function stubSleep(svc: OpencodeService): ReturnType<typeof vi.fn> {
|
||||
const sleep = vi.fn(async (_ms: number) => {});
|
||||
(svc as unknown as { sleep(ms: number): Promise<void> }).sleep = sleep;
|
||||
return sleep;
|
||||
}
|
||||
|
||||
describe('OpencodeService.notify', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
for (const k of [
|
||||
'OPENCODE_API',
|
||||
'OPENCODE_WORKSPACE_PATH',
|
||||
'OPENCODE_SESSION',
|
||||
'OPENCODE_USER',
|
||||
'OPENCODE_PASS',
|
||||
])
|
||||
delete process.env[k];
|
||||
});
|
||||
|
||||
it('posts the exact wrapper text with `from` (em-dash) to the resolved session', async () => {
|
||||
const svc = makeService({
|
||||
OPENCODE_USER: 'u',
|
||||
OPENCODE_PASS: 'p',
|
||||
OPENCODE_SESSION: 'ses_pinned',
|
||||
});
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||
.mockResolvedValueOnce(json({}));
|
||||
|
||||
await svc.notify('hi', 42);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const [url, init] = fetchMock.mock.calls[1];
|
||||
expect(url).toBe(`${API}/proxy/abc/session/ses_pinned/message`);
|
||||
const opts = init as {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
};
|
||||
expect(opts.method).toBe('POST');
|
||||
expect(opts.headers?.['content-type']).toBe('application/json');
|
||||
expect(opts.headers?.authorization).toBe(
|
||||
'Basic ' + Buffer.from('u:p').toString('base64'),
|
||||
);
|
||||
// body is EXACTLY {"parts":[{"type":"text","text":msg}]} — em-dash, not a hyphen
|
||||
expect(String(opts.body)).toBe(
|
||||
JSON.stringify({
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'New mesh message from node 42: "hi" — please react to it.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the `from node` part when there is no sender', async () => {
|
||||
const svc = makeService({
|
||||
OPENCODE_USER: 'u',
|
||||
OPENCODE_PASS: 'p',
|
||||
OPENCODE_SESSION: 'ses_pinned',
|
||||
});
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||
.mockResolvedValueOnce(json({}));
|
||||
|
||||
await svc.notify('hi');
|
||||
|
||||
const [, init] = fetchMock.mock.calls[1];
|
||||
expect(String((init as { body?: string }).body)).toBe(
|
||||
JSON.stringify({
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'New mesh message: "hi" — please react to it.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('matches the workspace by path, preferring ready entries when several match', async () => {
|
||||
const svc = makeService({
|
||||
OPENCODE_USER: 'u',
|
||||
OPENCODE_PASS: 'p',
|
||||
OPENCODE_SESSION: 'ses_pinned',
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json([
|
||||
{
|
||||
...WORKSPACE,
|
||||
id: 'other',
|
||||
status: 'starting',
|
||||
proxyPath: '/proxy/not-ready',
|
||||
}, // same path but not ready — listed first
|
||||
WORKSPACE,
|
||||
{
|
||||
id: 'x',
|
||||
name: 'mesh-bot',
|
||||
status: 'ready',
|
||||
proxyPath: '/proxy/other',
|
||||
}, // different path — must lose to the path match
|
||||
]),
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(json({}));
|
||||
|
||||
await svc.notify('hi');
|
||||
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
||||
`${API}/proxy/abc/session/ses_pinned/message`,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the workspace name when no path matches', async () => {
|
||||
const svc = makeService({
|
||||
OPENCODE_USER: 'u',
|
||||
OPENCODE_PASS: 'p',
|
||||
OPENCODE_SESSION: 'ses_pinned',
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json([
|
||||
{
|
||||
id: 'a',
|
||||
path: '/elsewhere',
|
||||
name: 'other',
|
||||
status: 'ready',
|
||||
proxyPath: '/proxy/a',
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
path: '/renamed',
|
||||
name: 'mesh-bot',
|
||||
status: 'ready',
|
||||
proxyPath: '/proxy/b',
|
||||
},
|
||||
]),
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(json({}));
|
||||
|
||||
await svc.notify('hi');
|
||||
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
||||
`${API}/proxy/b/session/ses_pinned/message`,
|
||||
);
|
||||
});
|
||||
|
||||
it('discovers the most recently updated session when none is pinned', async () => {
|
||||
const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||
.mockResolvedValueOnce(
|
||||
json([
|
||||
{ id: 'ses_old', time: { updated: 100 } },
|
||||
{ id: 'ses_new', time: { updated: 200 } },
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(json({}));
|
||||
|
||||
await svc.notify('hi');
|
||||
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/session`);
|
||||
expect(fetchMock.mock.calls[2][0]).toBe(
|
||||
`${API}/proxy/abc/session/ses_new/message`,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the first session when none carry time.updated', async () => {
|
||||
const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||
.mockResolvedValueOnce(json([{ id: 'ses_a' }, { id: 'ses_b', time: {} }]))
|
||||
.mockResolvedValueOnce(json({}));
|
||||
|
||||
await svc.notify('hi');
|
||||
|
||||
expect(fetchMock.mock.calls[2][0]).toBe(
|
||||
`${API}/proxy/abc/session/ses_a/message`,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips session discovery entirely when a session is pinned', async () => {
|
||||
const svc = makeService({
|
||||
OPENCODE_USER: 'u',
|
||||
OPENCODE_PASS: 'p',
|
||||
OPENCODE_SESSION: 'ses_pinned',
|
||||
});
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||
.mockResolvedValueOnce(json({}));
|
||||
|
||||
await svc.notify('hi');
|
||||
|
||||
// workspaces GET + message POST only — no /session discovery GET
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(`${API}/api/workspaces`);
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
||||
`${API}/proxy/abc/session/ses_pinned/message`,
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a final-POST read timeout as delivered success and does not retry', async () => {
|
||||
const svc = makeService({
|
||||
OPENCODE_USER: 'u',
|
||||
OPENCODE_PASS: 'p',
|
||||
OPENCODE_SESSION: 'ses_pinned',
|
||||
});
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||
.mockRejectedValueOnce(timeoutError()); // the message POST times out — opencode holds the response until the turn finishes
|
||||
|
||||
const logSpy = vi.spyOn(Logger.prototype, 'log');
|
||||
await expect(svc.notify('hi')).resolves.toBeUndefined();
|
||||
|
||||
// exactly 2 calls: no retry after a timeout-as-delivered
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(logSpy.mock.calls.map((c) => String(c[0]))).toContain(
|
||||
'opencode notify accepted (response still pending after 15 s)',
|
||||
);
|
||||
});
|
||||
|
||||
it('retries a failed workspace GET and succeeds on the next attempt', async () => {
|
||||
const svc = makeService({
|
||||
OPENCODE_USER: 'u',
|
||||
OPENCODE_PASS: 'p',
|
||||
OPENCODE_SESSION: 'ses_pinned',
|
||||
});
|
||||
const sleep = stubSleep(svc);
|
||||
fetchMock
|
||||
.mockRejectedValueOnce(new Error('boom')) // attempt 1: workspaces GET fails
|
||||
.mockResolvedValueOnce(json([WORKSPACE])) // attempt 2: workspaces ok
|
||||
.mockResolvedValueOnce(json({})); // message POST ok
|
||||
|
||||
await expect(svc.notify('hi')).resolves.toBeUndefined();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(sleep).toHaveBeenNthCalledWith(1, 2_000);
|
||||
});
|
||||
|
||||
it('gives up after 4 attempts and logs opencode notify dropped', async () => {
|
||||
const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' });
|
||||
const sleep = stubSleep(svc);
|
||||
fetchMock.mockRejectedValue(new Error('down'));
|
||||
const warnSpy = vi.spyOn(Logger.prototype, 'warn');
|
||||
|
||||
await expect(svc.notify('hi')).resolves.toBeUndefined(); // never throws — fire-and-forget safe
|
||||
|
||||
// one workspaces GET per attempt: exactly 4 attempts total
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
expect(sleep).toHaveBeenNthCalledWith(1, 2_000);
|
||||
expect(sleep).toHaveBeenNthCalledWith(2, 10_000);
|
||||
expect(sleep).toHaveBeenNthCalledWith(3, 30_000);
|
||||
const warns = warnSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(warns.some((w) => w.includes('attempt 1/4'))).toBe(true);
|
||||
// the GET failure is wrapped with the URL for a clear reason — match prefix + cause
|
||||
expect(
|
||||
warns.some(
|
||||
(w) =>
|
||||
w.startsWith('opencode notify dropped after 4 attempts:') &&
|
||||
w.includes(': down'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is a no-op without credentials (empty or unset)', async () => {
|
||||
for (const env of [{}, { OPENCODE_USER: '', OPENCODE_PASS: 'p' }] as Record<
|
||||
string,
|
||||
string
|
||||
>[]) {
|
||||
const svc = makeService(env);
|
||||
await expect(svc.notify('hi', 42)).resolves.toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('logs once at construction when disabled', () => {
|
||||
const warnSpy = vi.spyOn(Logger.prototype, 'warn');
|
||||
makeService({}); // no creds
|
||||
expect(warnSpy.mock.calls.map((c) => String(c[0]))).toContain(
|
||||
'opencode notifier disabled: OPENCODE_USER/OPENCODE_PASS not set',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
// ponytail: hardcoded backoff [2s, 10s, 30s] (~45 s window) — no env knob; tune here if outages run longer
|
||||
const RETRY_DELAYS_MS = [2_000, 10_000, 30_000];
|
||||
// opencode accepts a session message immediately but holds the HTTP response until the agent turn finishes
|
||||
const TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Entry of GET {api}/api/workspaces (only the fields we use). */
|
||||
interface WorkspaceEntry {
|
||||
id?: string;
|
||||
path?: string;
|
||||
name?: string;
|
||||
status?: string;
|
||||
proxyPath?: string;
|
||||
}
|
||||
|
||||
/** Entry of GET {base}/session (only the fields we use). */
|
||||
interface SessionEntry {
|
||||
id: string;
|
||||
time?: { updated?: number };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OpencodeService {
|
||||
private readonly logger = new Logger(OpencodeService.name);
|
||||
// read at construction, like MeshService's env config
|
||||
private readonly api = process.env.OPENCODE_API ?? 'http://172.0.0.1:9898';
|
||||
private readonly workspacePath =
|
||||
process.env.OPENCODE_WORKSPACE_PATH ??
|
||||
'/home/krzyw/projects/sandbox/mesh-bot';
|
||||
// when set, skips session discovery
|
||||
private readonly sessionOverride = process.env.OPENCODE_SESSION ?? '';
|
||||
private readonly user = process.env.OPENCODE_USER ?? '';
|
||||
private readonly pass = process.env.OPENCODE_PASS ?? '';
|
||||
/** Notifier disabled without credentials — the app must keep running (and notify nothing) when they are missing. */
|
||||
private readonly enabled = this.user !== '' && this.pass !== '';
|
||||
|
||||
constructor() {
|
||||
if (!this.enabled)
|
||||
this.logger.warn(
|
||||
'opencode notifier disabled: OPENCODE_USER/OPENCODE_PASS not set',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push an rx mesh message into the opencode session as a text part. Resolves when delivered —
|
||||
* including on a final-POST read timeout, which means DELIVERED (opencode holds the response until
|
||||
* the agent turn finishes) and is never retried. Workspace/session resolution failures are retried
|
||||
* up to RETRY_DELAYS_MS.length + 1 attempts total; the last failure logs `opencode notify dropped
|
||||
* after N attempts`. No-op when disabled (no credentials).
|
||||
*/
|
||||
async notify(text: string, from?: number): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const msg =
|
||||
'New mesh message' +
|
||||
(from != null ? ` from node ${from}` : '') +
|
||||
`: "${text}" — please react to it.`;
|
||||
const maxAttempts = RETRY_DELAYS_MS.length + 1;
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
const { base, sid } = await this.resolveTarget();
|
||||
await this.postMessage(base, sid, msg);
|
||||
return; // success on any attempt — a POST timeout already resolved as delivered inside postMessage
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (attempt === maxAttempts) {
|
||||
this.logger.warn(
|
||||
`opencode notify dropped after ${attempt} attempts: ${message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.logger.warn(
|
||||
`opencode notify failed (attempt ${attempt}/${maxAttempts}): ${message}`,
|
||||
);
|
||||
await this.sleep(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));
|
||||
}
|
||||
|
||||
private authHeaders(): Record<string, string> {
|
||||
return {
|
||||
authorization:
|
||||
'Basic ' + Buffer.from(`${this.user}:${this.pass}`).toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve (base_url, session_id) fresh per request; throws with a clear reason on API/workspace/session failure. */
|
||||
private async resolveTarget(): Promise<{ base: string; sid: string }> {
|
||||
const workspaces = await this.getJson<WorkspaceEntry[]>(
|
||||
`${this.api}/api/workspaces`,
|
||||
);
|
||||
// match by path first, fall back to name — the workspace UUID is regenerated on host restarts
|
||||
const matches =
|
||||
workspaces.filter((w) => w.path === this.workspacePath).length > 0
|
||||
? workspaces.filter((w) => w.path === this.workspacePath)
|
||||
: workspaces.filter((w) => w.name === 'mesh-bot');
|
||||
// prefer ready entries when several match
|
||||
const pool =
|
||||
matches.filter((w) => w.status === 'ready').length > 0
|
||||
? matches.filter((w) => w.status === 'ready')
|
||||
: matches;
|
||||
if (pool.length === 0)
|
||||
throw new Error(
|
||||
`workspace not found: path=${this.workspacePath} name=mesh-bot`,
|
||||
);
|
||||
const ws = pool[0];
|
||||
const base = this.api + (ws.proxyPath ?? '');
|
||||
let sid = this.sessionOverride;
|
||||
if (!sid) {
|
||||
const sessions = await this.getJson<SessionEntry[]>(`${base}/session`);
|
||||
if (sessions.length === 0)
|
||||
throw new Error(
|
||||
`no sessions in workspace ${ws.id} (${this.workspacePath})`,
|
||||
);
|
||||
// most recently updated session; first entry when none carry time.updated
|
||||
const updated = (s: SessionEntry): number => s.time?.updated ?? 0;
|
||||
sid = sessions.some((s) => updated(s))
|
||||
? sessions.reduce((a, b) => (updated(b) > updated(a) ? b : a)).id
|
||||
: sessions[0].id;
|
||||
}
|
||||
return { base, sid };
|
||||
}
|
||||
|
||||
private async getJson<T>(url: string): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
headers: this.authHeaders(),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`opencode API unreachable at ${url}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
if (!res.ok) throw new Error(`opencode API HTTP ${res.status} at ${url}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
private async postMessage(
|
||||
base: string,
|
||||
sid: string,
|
||||
msg: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`${base}/session/${sid}/message`, {
|
||||
method: 'POST',
|
||||
headers: { ...this.authHeaders(), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ parts: [{ type: 'text', text: msg }] }),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`); // HTTP errors retry like network failures
|
||||
} catch (err) {
|
||||
// a read timeout here means DELIVERED, not failed — opencode accepted the message and is holding
|
||||
// the response until the agent turn finishes; never retried
|
||||
if ((err as Error).name === 'TimeoutError') {
|
||||
this.logger.log(
|
||||
`opencode notify accepted (response still pending after ${TIMEOUT_MS / 1000} s)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MeshService } from '../mesh/mesh.service.js';
|
||||
import { WeatherModule } from './weather.module.js';
|
||||
import { WeatherService } from './weather.service.js';
|
||||
|
||||
describe('DI wiring (module graph)', () => {
|
||||
// compile() instantiates providers but does not run lifecycle hooks, so no TCP connection is opened.
|
||||
it('resolves WeatherService 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: [WeatherModule],
|
||||
}).compile();
|
||||
expect(moduleRef.get(WeatherService)).toBeInstanceOf(WeatherService);
|
||||
expect(moduleRef.get(MeshService)).toBeInstanceOf(MeshService);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { BROADCAST_ADDR } from '../mesh/codec.js';
|
||||
import type { MeshService } from '../mesh/mesh.service.js';
|
||||
import { MeshService } from '../mesh/mesh.service.js';
|
||||
|
||||
/** Open-Meteo `current` block fields we use (free API, no key). */
|
||||
export interface CurrentWeather {
|
||||
|
||||
Reference in New Issue
Block a user