Compare commits

..
Author SHA1 Message Date
kamilkrzywdaandOpenCode 25f99f04ad Remove insecure NODE_TLS_REJECT_UNAUTHORIZED=0 TLS workaround
CI / build + unit (pull_request) Successful in 46s
The MeshMonitor gateway uses a valid Let's Encrypt certificate, so
standard TLS certificate verification already works — no workaround
needed. The flag was a security downgrade: it disabled cert verification
for ALL outbound connections in the container (including the LLM and
opencode services), not just the gateway.

- docker-compose.yml: drop NODE_TLS_REJECT_UNAUTHORIZED env line + comment
- src/main.ts: drop process.env.NODE_TLS_REJECT_UNAUTHORIZED + comments
- CHANGELOG.md: correct the v0.22.0 Docker bullet wording

Part of the already-open MeshMonitor connection work (PR #56): no version
bump, no new changelog section.

Co-Authored-By: OpenCode <noreply@opencode.ai>
2026-10-13 01:07:36 +00:00
kamilkrzywda f6b884dfdf feat(mesh): drive MeshService through the MeshMonitor HTTP gateway (Phase 2)
CI / build + unit (pull_request) Successful in 1m17s
Rewire MeshService off the raw TCP socket + codec onto the Phase-1
MeshMonitorClient contract (HttpMeshMonitorClient in production,
FakeMeshMonitorClient in tests): status/messages/nodes/channels/telemetry
are polled (15s/1s/30s/5min/60s) instead of streamed, outbound broadcasts
and DMs go through client.send(), respond/inject/notifier/command/battery/
weather behavior and all /api/* + MCP endpoint shapes are preserved.
The fake TCP bridge is deleted and every consumer spec (mesh service, MCP
controller, command auto-responder, battery alerts) now drives the real
service through the widened fake client. Docker compose drops
MESH_HOST/MESH_PORT and adds the MESHMONITOR_* env (token from .env,
self-signed TLS via NODE_TLS_REJECT_UNAUTHORIZED=0).

Live smoke against https://meshmonitor.kkhost.pl: getStatus ->
{connected:true, myNodeNum:3994646728, longName:'SP8MK Bot',
nodeResponsive:true}; getNodes -> 104 nodes.

Build green, suite 17 files / 245 tests (baseline 234).
2026-10-13 00:42:02 +00:00
kamilkrzywda f80340ae6e feat(meshmonitor): add stateless MeshMonitor HTTP-gateway client + fake + module
Phase 1 of the MeshMonitor HTTP-gateway migration: a stateless REST client
that a later phase wires into MeshService to replace the raw
Meshtastic protobuf-over-TCP bridge.

- mesh-monitor-client.ts: MeshMonitorClient interface, HttpMeshMonitorClient
  (native fetch, Bearer auth, injectable base/source/poll params), mappers for
  the gateway's actual JSON shapes (source-scoped paths, flat node position,
  second-based node lastHeard vs ms message/telemetry timestamps, long-format
  telemetry, packet id = 3rd segment of the id string), MeshMonitorError,
  nodeNumToId/nodeIdToNum, shared assertSendTarget XOR, and
  createMeshMonitorClientFromEnv() (null when MESHMONITOR_API_TOKEN unset).
- fake-mesh-monitor-client.ts: in-memory double mirroring the client contract
  (set*/pushInbound config, sends[] recorder, outbound mirrored into
  getMessages, newest-first, limit/channel/toNodeId/fromNodeId filters).
- meshmonitor.module.ts: thin Nest provider exposing the env-built client
  (or null) under MESH_MONITOR_CLIENT for Phase 2's MeshModule.
- specs: HttpMeshMonitorClient against an in-process node:http gateway
  (raw->typed mapping per endpoint, Authorization: Bearer header, send body
  shape with channel/toNodeId XOR + replyId passthrough, 401/413/429/503
  -> MeshMonitorError); FakeMeshMonitorClient behavior.

No MeshService changes, no version bump, no changelog, no PR.
2026-10-12 20:29:50 +00:00
kamilkrzywda b54f909fbc Merge pull request 'Ignore 0% battery readings in low-battery alerting' (#55) from fix/battery-zero-ignore into main
CI / build + unit (push) Successful in 1m10s
2026-10-06 12:53:41 +02:00
kamilkrzywda 7ec97a06d2 Ignore 0% battery readings in low-battery alerting
CI / build + unit (pull_request) Successful in 1m18s
A 0% batteryLevel is an invalid/no-data value (no battery wired or
misconfigured node), not a genuinely critical battery, so it must not
produce a "⚠️ battery low" alert. Add a non-positive guard to
shouldAlert after the undefined check: 0 (and any non-positive) now
skips, while 1-20 still alert and >20 still doesn't. Threshold/cooldown/
batching unchanged.

Tests: 3 new specs in the check-threshold block (0% excluded from the
batch, lone 0% sends nothing, 1% still alerts). 180 -> 183.
2026-10-06 08:02:37 +00:00
kamilkrzywda 84915fe314 Merge pull request 'fix(notifier): migrate opencode notifier to v2 routes (v0.21.11)' (#54) from fix/opencode-v2-notifier into main
CI / build + unit (push) Successful in 22s
2026-10-05 15:25:38 +02:00
kamilkrzywda 5506fb9330 fix(notifier): migrate opencode notifier to v2 routes (v0.21.11)
CI / build + unit (pull_request) Successful in 46s
CodeNomad's per-workspace instance proxy was upgraded and now only
serves opencode v2 /api/* routes; the v1 /session/... routes 403, which
broke all notifications.

- Discovery: GET {api}{proxyPath}/api/session (v2 {data,cursor} envelope)
  instead of GET {base}/session; sessions filtered by location.directory
  and the max time.updated entry is picked.
- Send: POST {api}{proxyPath}/api/session/{sid}/prompt with a {text}
  body instead of POST {base}/session/{id}/message with {parts:[...]}.
- v2 returns 2xx immediately, so the 15 s read-timeout-as-delivered
  branch is kept only as a harmless safety net.
- Updated the spec to the v2 routes/body/envelope (incl. a
  directory-filter assertion) and kept AGENTS.md in sync.
2026-10-05 12:48:47 +00:00
kamilkrzywda beebcd5851 Merge pull request 'Container TZ: Europe/Warsaw so cron hours are local (v0.21.10)' (#53) from fix/tz-warsaw into main
CI / build + unit (push) Successful in 1m16s
2026-09-30 07:49:50 +02:00
kamilkrzywda 5a1493fb4a Run container in Europe/Warsaw so cron hours and logs use local Polish time (v0.21.10)
CI / build + unit (pull_request) Successful in 50s
2026-09-30 05:47:10 +00:00
kamilkrzywda 8fb16217f7 Merge pull request 'Add #loser command — worst packet delivery record (v0.21.9)' (#52) from fix/loser-command into main
CI / build + unit (push) Successful in 1m36s
2026-09-29 23:01:29 +02:00
kamilkrzywda 3b1f5de101 Add #loser command — worst packet delivery record over directed sends (v0.21.9)
CI / build + unit (pull_request) Successful in 48s
2026-09-29 20:58:43 +00:00
kamilkrzywda 46d32b3553 Merge pull request 'Fix unhandled ECONNRESET in fake-bridge teardown — npm test exits 0 again (v0.21.8)' (#51) from fix/spec-teardown-econnreset into main
CI / build + unit (push) Successful in 1m11s
2026-09-27 09:18:09 +02:00
kamilkrzywda e20e11e2d5 Fix npm test exit 1 — uncaught ECONNRESET from fake-bridge sockets without error handlers (v0.21.8)
CI / build + unit (pull_request) Successful in 46s
2026-09-27 07:13:56 +00:00
kamilkrzywda 0b20df242c Merge pull request 'Fix double ScheduleModule.forRoot() — all cron jobs fired twice (v0.21.7)' (#50) from fix/schedule-double-registration into main
CI / build + unit (push) Failing after 34s
2026-09-27 08:52:35 +02:00
kamilkrzywda a16b6c19d4 Fix double ScheduleModule.forRoot() registration doubling every cron/interval job (v0.21.7)
CI / build + unit (pull_request) Failing after 22s
2026-09-27 06:51:50 +00:00
kamilkrzywda 9e558fd95e Merge pull request 'Add #help command with short list of all commands (v0.21.6)' (#49) from fix/help-command into main
CI / build + unit (push) Failing after 34s
2026-09-26 12:54:06 +02:00
kamilkrzywda 70be8bdaa7 Add #help command — short single-message list of all commands (v0.21.6)
CI / build + unit (pull_request) Failing after 21s
Owner request: 'now add #help with short list of all command'. Registered in
the CommandService constructor next to #ping; returns a SINGLE multi-line
string (one packet, ~203 chars) — not an array, which would drip-feed one
spaced packet per line through the v0.21.5 MESH_MULTI_SEND_DELAY_MS loop.

Also hardened the v0.21.5 multi-send-spacing spec: its wall-clock assertion
('first send < 8 ms after t0') flaked under full-suite load — replaced with
fake timers, which assert deterministically that line 1 is out before any time
elapses and lines 2-3 land exactly one delay apart.
2026-09-26 10:52:18 +00:00
kamilkrzywda 847c3d47d0 Merge pull request 'Space multi-line command replies 5 s apart (v0.21.5)' (#48) from fix/multi-send-spacing into main
CI / build + unit (push) Failing after 46s
2026-09-26 12:31:22 +02:00
kamilkrzywda 1b768b14a7 Space multi-line command replies MESH_MULTI_SEND_DELAY_MS apart (v0.21.5)
CI / build + unit (pull_request) Failing after 21s
Production incident: '#rss get lublin' returned an array of lines and
CommandService sent each one back-to-back within the same second — on the real
mesh 3 of 4 items were dropped in RF (the owner saw only the last one).

CommandService.onRx now waits MESH_MULTI_SEND_DELAY_MS (new env, default 5000 =
5 s, read at construction like the other timing knobs) between consecutive
lines of an array reply — no delay before the first line. The delay sits in the
shared per-line loop, so both the respond() path and the legacy sendText
fallback are covered by one await.
2026-09-26 10:29:21 +00:00
kamilkrzywda 3f6c02d71e Merge pull request 'LLM timeout default 60 s → 180 s (v0.21.4)' (#47) from fix/llm-timeout into main
CI / build + unit (push) Failing after 38s
2026-09-26 11:55:54 +02:00
kamilkrzywda 98b9ec5185 LLM_TIMEOUT_MS default 60 s -> 3 min (v0.21.4)
CI / build + unit (pull_request) Failing after 21s
Production incident: '#rss get lublin' at 9:41 AM failed with 'The operation
was aborted due to timeout' — the LlmService call hit its 60 s cap while the
local OpenAI-compatible server (qwen3.8-27b, uncontrollable reasoning tokens)
was slow/busy. The same endpoint answers a trivial prompt in ~1 s when idle,
so this is latency variance on the server side, not a dead endpoint.

Bump the LLM_TIMEOUT_MS fallback from 60000 to 180000 (3 min headroom for
slow reasoning periods); still env-overridable, only the default changed.
2026-09-26 09:52:18 +00:00
kamilkrzywda 0722c82359 Merge pull request 'Reconnect tuning: 1 min idle timeout + 5 min periodic autoreconnect (v0.21.3)' (#46) from fix/reconnect-tuning into main
CI / build + unit (push) Failing after 42s
2026-09-26 11:37:41 +02:00
kamilkrzywda 1ae8d7a926 Idle timeout default 60 s; lazy periodic autoreconnect every 5 min (v0.21.3)
CI / build + unit (pull_request) Failing after 22s
The v0.21.2 frame-based watchdog works in production (it fired today: 'no
decoded bridge frames for 628474 ms — forcing reconnect', and the reconnect
delivered queued packets), but the bridge/radio side at 192.168.1.4 keeps
killing inbound RX within ~20 min of a fresh sync, repeatedly. Two tuning
changes requested by the owner:

- MESH_IDLE_TIMEOUT_MS default lowered from 600000 (10 min) to 60000 (1 min).
- New lazy periodic autoreconnect: MESH_RECONNECT_INTERVAL_MS (new env,
  default 300000 = 5 min, read at construction like the others). An interval
  started in onModuleInit unconditionally logs and destroys the socket so the
  existing close -> scheduleReconnect loop re-establishes with a fresh sync on
  schedule; cleared in onModuleDestroy. When not connected at tick time,
  destroy is a no-op and the retry loop owns recovery — no special-casing.

Tests: 171 -> 172 — periodic reconnect observed end-to-end with an overridden
short interval (fake bridge disconnect + re-establish); existing watchdog tests
pass unchanged, comments updated to the new 60 s default.
2026-09-26 09:34:30 +00:00
24 changed files with 4457 additions and 2484 deletions
+4 -4
View File
File diff suppressed because one or more lines are too long
+55
View File
@@ -1,5 +1,60 @@
# Changelog # Changelog
## v0.22.0 — 2026-10-13 00:42
- **Phase 2: the service now reaches the Meshtastic device through the MeshMonitor HTTP gateway** (`MeshMonitorClient`) instead of a raw TCP socket + codec. `MeshService` takes an injectable client (`HttpMeshMonitorClient` in production via `MESHMONITOR_API_TOKEN`, `FakeMeshMonitorClient` in tests); a missing token means "no gateway" — the service stays disconnected and sends throw 503. The `net.Socket` connection, frame buffer, codec framing, idle watchdog and periodic reconnect are all gone; all `/api/*` REST and MCP endpoint shapes, the notifier, the command auto-responder, the battery alerts and the weather job behave as before.
- Poll loops replace the socket: status every 15 s (5 s retries while down) gates everything; on first success the service syncs nodes → channels → telemetry → messages (the message poll takes a seen-set baseline so pre-existing traffic is not re-processed) and starts the loops: messages every `MESH_POLL_MS` (default 1 s) in `{limit: 50}` newest-first windows, nodes every 30 s, channels every 5 min, telemetry every 60 s (long-format rows are grouped into per-packet records and deduped).
- Outbound: broadcasts ride `send({ text, channel })` on the pinned send channel (default 3, `MESH_SEND_CHANNEL`); DMs ride `send({ text, toNodeId: '!hex' })` on the primary channel in mesh terms. Gateway-rejected sends are recorded as failed tx history entries; HTTP 413 → 400 BadRequest, anything else → 503 ServiceUnavailable. `respond()` semantics are unchanged (rx → DM back to the sender unless the original was a broadcast, tx → its destination, injected → broadcast; always threaded via `replyId`).
- Documented degradations vs the raw socket: `GET /api/device` is degraded (no device metadata / local+module config — only the firmware version when present in the node record); there is no idle-watchdog/periodic-reconnect (steady polling replaces it); world state (nodes/channels/telemetry) is not cleared when the status drops — each refreshes on its own interval.
- **Test migration:** the `MeshService`, MCP controller, command auto-responder and battery-alert specs now drive the real service through `FakeMeshMonitorClient` — the fake TCP bridge is gone (`src/mesh/fake-bridge.ts` deleted, zero imports remain). The fake client's test API was widened to match: the constructor takes the source identity (`{ connected, nodeNum, longName, shortName }`), auto-seeds the bot's own node (the gateway always reports it), `pushNode` upserts by num, plus the existing `pushInbound`/`setDeliveryState`/`clearTraffic`/`sends[]` mirror. Suite: 17 files / 245 tests green (baseline 234).
- **Docker:** compose drops `MESH_HOST`/`MESH_PORT` and adds `MESHMONITOR_API_TOKEN` (from `.env`, never committed), `MESHMONITOR_API_BASE` (default `https://meshmonitor.kkhost.pl/api/v1`), `MESHMONITOR_SOURCE` (default `default`) and `MESHMONITOR_TIMEOUT_MS` (default 15000). The gateway uses a valid Let's Encrypt certificate, so normal TLS verification applies — no `NODE_TLS_REJECT_UNAUTHORIZED` workaround.
## v0.21.12 — 2026-10-06 08:01
- Battery monitoring now ignores a 0% reading: a 0 (or any non-positive) `batteryLevel` is treated as an invalid/no-data value (node has no battery wired or is misconfigured) and no longer produces a "⚠️ battery low" alert, while genuinely-low readings (1–20%) still alert exactly as before. One-line guard added to `BatteryAlertService.shouldAlert` right after the `undefined` check. Tests: 3 new specs in the `check threshold` block — a 0% node is excluded from the batch (and a lone 0% sends nothing) while a 1% node still alerts.
## v0.21.11 — 2026-10-05 14:47
- Migrated the opencode notifier to the v2 API routes: discovery is now `GET {api}{proxyPath}/api/session` (a `{ data, cursor }` envelope; sessions filtered by `location.directory`, most-recent `time.updated` wins) and send is now `POST {api}{proxyPath}/api/session/{sid}/prompt` with a `{text}` body, instead of the v1 `GET /session` + `POST /session/{id}/message` (`{parts:[{type:"text",text}]}`) routes. CodeNomad's per-workspace instance proxy was upgraded and now only serves opencode v2 `/api/*` — every v1 `/session/…` call 403s, which was breaking all notifications. v2 returns 2xx immediately (no longer holds the response until the turn finishes), so the 15 s read-timeout-as-delivered branch is kept only as a harmless safety net.
## v0.21.10 — 2026-09-30 05:46
- Container now runs with `TZ=Europe/Warsaw` (new line in the bot service's `environment:` block, hardcoded like MESH_HOST/MESH_PORT): scheduled jobs and log timestamps use local Polish time instead of UTC — the weather cron (`WEATHER_CRON=0 6,12,18 * * *`) previously fired at 06/12/18 UTC = 08/14/20 CEST. DST-aware via the IANA zone (CEST +2 / CET +1). Deployment-config only — no application code changed; takes effect on the next `docker compose up -d --build`.
## v0.21.9 — 2026-09-29 20:56
- New `#loser` command (owner request): replies with which user/node has the worst packet delivery record, over directed sends only. Meshtastic shares no mesh-wide loss stats, so it groups our own tx history by destination and counts delivered vs failed per node; broadcasts are excluded (firmware strips want_ack pre-TX, they stay pending — and the broadcast addr is never a real destination even if a NACK lands on it). Loser = most failures, tie-break worse failure ratio. Node resolved to `longName`/`shortName`, falling back to the `@!xxxxxxxx` mention when unknown. Three reply shapes: worst delivery line (`worst delivery: <name> (@!hex) - N failed / M delivered`), a "no failed sends yet" line, and a "not enough data yet — only directed sends count (broadcasts have no acks)" line. Registered in the CommandService constructor next to `#ping`; listed in `#help`.
- Tests: 175 → 180 — new `#loser` specs cover all three reply cases, broadcast/pending exclusion, the unknown-node `@!hex` fallback, and the failure-ratio tie-break; the `#help` spec now also asserts `#loser` is listed.
## v0.21.8 — 2026-09-27 07:13
- Fixed `npm test` exiting 1 with "Errors 2" even though all tests pass (every CI run red since v0.21.2): the two watchdog specs that force a reconnect destroy the service's socket while data is still pending in its receive buffer (the overflow spec floods 300 KB and destroys on the first ~64 KB chunk; the garbage spec destroys right after its last write), so the kernel sends RST instead of FIN and the fake bridge's accepted server-side sockets emit `read ECONNRESET`. Those test-owned sockets had no `'error'` handler, so Node threw it as an uncaught exception that vitest counts as a run-level failure. `startBridge()` now attaches a no-op `'error'` handler to every accepted socket (single registration point, covers all four spec files using the fake bridge). Test-only change — `fake-bridge.ts` is imported by specs only; no runtime diff, no deploy needed.
## v0.21.7 — 2026-09-27 06:50
- Fixed double registration of scheduled jobs: `ScheduleModule.forRoot()` was called from both `WeatherModule` and `BatteryAlertModule`, so Nest created two ScheduleExplorers, each scanning all providers — every @Cron/@Interval job (hourly weather broadcast, per-minute battery check) fired twice. Removed the call from `battery.module.ts`; the single registration in `weather.module.ts` remains.
- Tests: 174 → 175 — new regression spec (`src/schedule-singleton.spec.ts`) asserts `ScheduleModule.forRoot(` appears exactly once across all `src/**/*.module.ts`.
## v0.21.6 — 2026-09-26 10:37
- New `#help` command (owner request): replies with a short list of all available commands as ONE multi-line message (single packet, not one spaced packet per line) — `#ping`, the `#rss help/add/remove/get/sources/tags` family. Registered in the CommandService constructor next to `#ping`.
- Tests: 173 → 174 — `#help` dispatches through respond() exactly once with text containing both `#ping` and `#rss`. (Also hardened the v0.21.5 multi-send-spacing spec, which flaked under full-suite load: it now uses fake timers to assert deterministically that line 1 goes out before any time elapses and lines 2-3 land exactly one delay apart.)
## v0.21.5 — 2026-09-26 10:26
- Multi-line command replies are now spaced apart: `CommandService.onRx` waits `MESH_MULTI_SEND_DELAY_MS` (new env, default **5000** = 5 s, read at construction) between consecutive lines of an array reply — no delay before the first line. Production incident: `#rss get lublin` sent its items back-to-back within the same second and 3 of 4 were dropped in RF (the owner saw only the last one); each packet now gets time to land.
- Tests: 172 → 173 — a 3-line reply with an overridden short delay asserts all lines are sent, the first goes out immediately (no pre-delay), and each subsequent line lands ≥ the configured gap after the previous one; existing specs pin `MESH_MULTI_SEND_DELAY_MS=0` in `beforeEach` so they stay fast.
## v0.21.4 — 2026-09-26 09:51
- `LLM_TIMEOUT_MS` default raised from 60000 (60 s) to **180000** (3 min): production incident — `#rss get lublin` at 9:41 AM failed with "The operation was aborted due to timeout" because the local OpenAI-compatible server (qwen3.8-27b, uncontrollable reasoning tokens) was slow/busy on that call; the same endpoint answers a trivial prompt in ~1 s when idle, so this is latency variance, not a dead endpoint. Still env-overridable (`LLM_TIMEOUT_MS`), only the fallback changed.
## v0.21.3 — 2026-09-26 09:31
- Idle timeout default lowered from 10 min to **1 min** (`MESH_IDLE_TIMEOUT_MS` fallback `600000` → `60000`) — the v0.21.2 frame-based watchdog works in production (it fired today and the reconnect delivered queued packets), but the bridge/radio side at 192.168.1.4 keeps killing inbound RX within ~20 min of a fresh sync, so a shorter detection window is wanted.
- New **lazy periodic autoreconnect**: `MESH_RECONNECT_INTERVAL_MS` (new env, default **300000** = 5 min, read at construction) — an interval that unconditionally logs ("periodic reconnect — MESH_RECONNECT_INTERVAL_MS elapsed") and destroys the socket so the existing close→scheduleReconnect loop re-establishes with a fresh sync on schedule. When not connected at tick time, destroy is a no-op and the retry loop owns recovery (no special-casing). Started in `onModuleInit`, cleared in `onModuleDestroy`.
- Tests: 171 → 172 — periodic reconnect observed end-to-end with an overridden short interval (fake bridge disconnect + re-establish); existing watchdog tests pass unchanged, comments updated to the new 60 s default.
## v0.21.2 — 2026-09-26 08:47 ## v0.21.2 — 2026-09-26 08:47
- Idle watchdog now tracks DECODED FRAMES, not raw bytes (fixes a second production zombie that slipped past v0.21.1): text+telemetry RX was dead for >1 h while TX worked and the watchdog never fired — /proc/net/snmp showed InSegs delta = 2 segments per 30 s, i.e. the bridge peer keeps dribbling small NON-frame bytes (~every 15 s) that kept resetting `lastPacketAt` on every socket chunk even though zero valid Meshtastic frames decoded. The `'data'` handler no longer updates `lastPacketAt` per chunk; it is updated only when `extractFrames` yields ≥1 valid packet (the reset in the `'connect'` handler is unchanged), so a stream that decodes nothing trips the watchdog after `MESH_IDLE_TIMEOUT_MS` even while raw bytes keep arriving. - Idle watchdog now tracks DECODED FRAMES, not raw bytes (fixes a second production zombie that slipped past v0.21.1): text+telemetry RX was dead for >1 h while TX worked and the watchdog never fired — /proc/net/snmp showed InSegs delta = 2 segments per 30 s, i.e. the bridge peer keeps dribbling small NON-frame bytes (~every 15 s) that kept resetting `lastPacketAt` on every socket chunk even though zero valid Meshtastic frames decoded. The `'data'` handler no longer updates `lastPacketAt` per chunk; it is updated only when `extractFrames` yields ≥1 valid packet (the reset in the `'connect'` handler is unchanged), so a stream that decodes nothing trips the watchdog after `MESH_IDLE_TIMEOUT_MS` even while raw bytes keep arriving.
+5 -2
View File
@@ -2,8 +2,11 @@ services:
bot: bot:
build: . build: .
environment: environment:
MESH_HOST: 192.168.1.4 TZ: Europe/Warsaw
MESH_PORT: '4403' MESHMONITOR_API_TOKEN: ${MESHMONITOR_API_TOKEN:-}
MESHMONITOR_API_BASE: ${MESHMONITOR_API_BASE:-https://meshmonitor.kkhost.pl/api/v1}
MESHMONITOR_SOURCE: ${MESHMONITOR_SOURCE:-default}
MESHMONITOR_TIMEOUT_MS: ${MESHMONITOR_TIMEOUT_MS:-15000}
MESH_DB_PATH: ./data/mesh-bot.db MESH_DB_PATH: ./data/mesh-bot.db
MESH_POLL_MS: '1000' MESH_POLL_MS: '1000'
MESH_NOTIFY_CHANNEL: '${MESH_NOTIFY_CHANNEL:-3}' MESH_NOTIFY_CHANNEL: '${MESH_NOTIFY_CHANNEL:-3}'
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mesh-bot", "name": "mesh-bot",
"version": "0.21.2", "version": "0.22.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
+1 -2
View File
@@ -1,10 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { MeshModule } from '../mesh/mesh.module.js'; import { MeshModule } from '../mesh/mesh.module.js';
import { BatteryAlertService } from './battery.service.js'; import { BatteryAlertService } from './battery.service.js';
@Module({ @Module({
imports: [ScheduleModule.forRoot(), MeshModule], imports: [MeshModule],
providers: [BatteryAlertService], providers: [BatteryAlertService],
exports: [BatteryAlertService], exports: [BatteryAlertService],
}) })
+64 -50
View File
@@ -1,10 +1,8 @@
import { fromBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { BROADCAST_ADDR, extractFrames } from '../mesh/codec.js'; import { BROADCAST_ADDR } from '../mesh/codec.js';
import { nodeInfoFrame, startBridge, waitFor } from '../mesh/fake-bridge.js';
import type { MeshService, NodeSummary } from '../mesh/mesh.service.js'; import type { MeshService, NodeSummary } from '../mesh/mesh.service.js';
import { MeshService as RealMeshService } from '../mesh/mesh.service.js'; import { MeshService as RealMeshService } from '../mesh/mesh.service.js';
import { FakeMeshMonitorClient } from '../mesh/meshmonitor/fake-mesh-monitor-client.js';
import { import {
BatteryAlertService, BatteryAlertService,
formatAlert, formatAlert,
@@ -119,6 +117,47 @@ describe('BatteryAlertService check threshold', () => {
expect(sendText).not.toHaveBeenCalled(); expect(sendText).not.toHaveBeenCalled();
}); });
it('ignores a 0% reading (invalid/no battery) even though 0 <= threshold', async () => {
const sendText = vi.fn(async (..._args: unknown[]) => 1);
// 0% must not alert, but a genuinely-low 5% node in the same batch must
const svc = makeService([node(1, 0), node(2, 5)], sendText);
await svc.check();
expect(sendText).toHaveBeenCalledTimes(1);
// only the 5% node — the 0% node is excluded entirely
expect(sendText.mock.calls[0]).toEqual([
BROADCAST_ADDR,
'⚠️ battery low: @!00000002 5%',
undefined,
2,
]);
});
it('ignores a lone 0% reading (no broadcast at all)', async () => {
const sendText = vi.fn(async (..._args: unknown[]) => 1);
const svc = makeService([node(1, 0)], sendText); // only an invalid 0% reading
await svc.check();
expect(sendText).not.toHaveBeenCalled();
});
it('still alerts a genuine 1% reading (lowest valid value)', async () => {
const sendText = vi.fn(async (..._args: unknown[]) => 1);
const svc = makeService([node(1, 1)], sendText);
await svc.check();
expect(sendText).toHaveBeenCalledTimes(1);
expect(sendText.mock.calls[0]).toEqual([
BROADCAST_ADDR,
'⚠️ battery low: @!00000001 1%',
undefined,
2,
]);
});
it('merges all qualifying nodes into exactly ONE broadcast with the broadcast target', async () => { it('merges all qualifying nodes into exactly ONE broadcast with the broadcast target', async () => {
const sendText = vi.fn(async (..._args: unknown[]) => 1); const sendText = vi.fn(async (..._args: unknown[]) => 1);
const svc = makeService( const svc = makeService(
@@ -225,63 +264,38 @@ describe('BatteryAlertService check cooldown', () => {
describe('BatteryAlertService check end-to-end (real MeshService)', () => { describe('BatteryAlertService check end-to-end (real MeshService)', () => {
it('broadcasts the alert on "testowy" (channel index 2), not the pinned send channel', async () => { it('broadcasts the alert on "testowy" (channel index 2), not the pinned send channel', async () => {
const bridge = await startBridge(); const client = new FakeMeshMonitorClient({
process.env.MESH_HOST = '127.0.0.1'; connected: true,
process.env.MESH_PORT = String(bridge.port); nodeNum: 3994646728,
});
client.pushNode({
num: 0xabc,
longName: 'Alpha',
batteryLevel: 5,
lastHeard: 1_760_000_000,
});
process.env.MESHMONITOR_API_BASE = 'https://meshmonitor.kkhost.pl/api/v1';
delete process.env.MESH_SEND_CHANNEL; // default pinned send channel is 3 — the alert must NOT ride it delete process.env.MESH_SEND_CHANNEL; // default pinned send channel is 3 — the alert must NOT ride it
const mesh = new RealMeshService(); const mesh = new RealMeshService(undefined, undefined, client);
try { try {
mesh.onModuleInit(); mesh.onModuleInit();
await waitFor( await mesh.pollStatus();
() => 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); const svc = new BatteryAlertService(mesh);
await svc.check(); await svc.check();
const readOutbound = (): P.Mesh.MeshPacket[] => { // one broadcast on the alert channel ("testowy", index 2) — not the pinned send channel (3)
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0)); expect(client.sends).toHaveLength(1);
let off = 0; expect(client.sends[0]).toMatchObject({
for (const c of out) { text: '⚠️ battery low: @!00000abc (Alpha) 5%',
merged.set(c, off); toNodeId: undefined, // broadcast — not a DM
off += c.length; channel: 2,
} replyId: undefined,
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 { } finally {
delete process.env.MESH_HOST; delete process.env.MESHMONITOR_API_BASE;
delete process.env.MESH_PORT;
delete process.env.MESH_SEND_CHANNEL; delete process.env.MESH_SEND_CHANNEL;
mesh.onModuleDestroy(); mesh.onModuleDestroy();
bridge.close();
} }
}); });
}); });
+1
View File
@@ -60,6 +60,7 @@ export class BatteryAlertService {
private shouldAlert(node: NodeSummary, now: number): boolean { private shouldAlert(node: NodeSummary, now: number): boolean {
if (node.batteryLevel === undefined) return false; // no battery reported — skip if (node.batteryLevel === undefined) return false; // no battery reported — skip
if (node.batteryLevel <= 0) return false; // 0/invalid reading — no battery wired or misconfigured node, not a real low-battery alert
// <= on purpose: a node at exactly the threshold is already "low" (spec allowed either < or <=; tests pin this choice) // <= 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; if (!(node.batteryLevel <= this.threshold)) return false;
const last = this.lastAlertAt.get(node.num); const last = this.lastAlertAt.get(node.num);
+263 -80
View File
@@ -3,20 +3,22 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite'; import { DatabaseSync } from 'node:sqlite';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BROADCAST_ADDR } from '../mesh/codec.js';
import type {
MessageRecord,
MeshService,
NodeSummary,
} from '../mesh/mesh.service.js';
import { MeshService as RealMeshService } from '../mesh/mesh.service.js';
import { FakeMeshMonitorClient } from '../mesh/meshmonitor/fake-mesh-monitor-client.js';
import type { LlmService } from '../llm/llm.service.js'; import type { LlmService } from '../llm/llm.service.js';
import { RssService } from '../rss/rss.service.js'; import { RssService } from '../rss/rss.service.js';
import {
startBridge,
textPacketFrame,
waitFor,
outboundPackets,
KAMIL_NUM,
} from '../mesh/fake-bridge.js';
import { BROADCAST_ADDR } from '../mesh/codec.js';
import type { MeshService } from '../mesh/mesh.service.js';
import { MeshService as RealMeshService } from '../mesh/mesh.service.js';
import { CommandService } from './command.service.js'; import { CommandService } from './command.service.js';
// local copies — the fake-bridge constants these specs used are gone with Phase 2
const KAMIL_NUM = 123456789;
const KAMIL_ID = '!075bcd15';
function makeService( function makeService(
sendText?: ReturnType<typeof vi.fn>, sendText?: ReturnType<typeof vi.fn>,
respond?: ReturnType<typeof vi.fn>, respond?: ReturnType<typeof vi.fn>,
@@ -30,6 +32,7 @@ function makeService(
beforeEach(() => { beforeEach(() => {
delete process.env.COMMAND_CHANNEL; // default channel 2 under test delete process.env.COMMAND_CHANNEL; // default channel 2 under test
process.env.MESH_MULTI_SEND_DELAY_MS = '0'; // no spacing under test unless a test opts in — the real default (5000) would slow the suite down
}); });
describe('CommandService.onRx', () => { describe('CommandService.onRx', () => {
@@ -40,6 +43,16 @@ describe('CommandService.onRx', () => {
expect(mesh.sendText).not.toHaveBeenCalled(); expect(mesh.sendText).not.toHaveBeenCalled();
}); });
it('replies #help with a short single-message list of all commands', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, '#help', 42, 7);
expect(mesh.respond).toHaveBeenCalledTimes(1); // one packet — not one per line
const text = mesh.respond.mock.calls[0][1];
expect(text).toContain('#ping');
expect(text).toContain('#loser');
expect(text).toContain('#rss');
});
it('falls back to a directed sendText on COMMAND_CHANNEL when the trigger has no packet id (DM)', async () => { it('falls back to a directed sendText on COMMAND_CHANNEL when the trigger has no packet id (DM)', async () => {
const [svc, mesh] = makeService(); const [svc, mesh] = makeService();
await svc.onRx(2, '#ping', 42); // from present but no id — defensive path await svc.onRx(2, '#ping', 42); // from present but no id — defensive path
@@ -124,6 +137,37 @@ describe('CommandService.onRx', () => {
expect(mesh.sendText).not.toHaveBeenCalled(); expect(mesh.sendText).not.toHaveBeenCalled();
}); });
it('spaces consecutive multi-line sends by MESH_MULTI_SEND_DELAY_MS — no delay before the first line', async () => {
process.env.MESH_MULTI_SEND_DELAY_MS = '50'; // short so the test stays fast
let sends = 0;
const respond = vi.fn(async () => {
sends++;
return { id: 1 };
});
vi.useFakeTimers();
try {
const [svc, mesh] = makeService(undefined, respond);
svc.register('multi3', () => ['a: one', 'b: two', 'c: three']);
const p = svc.onRx(2, '#multi3', 42, 9);
// flush microtasks without advancing time — the first line must already be out (no delay before it)
await vi.advanceTimersByTimeAsync(0);
expect(sends).toBe(1);
// each subsequent line waits exactly MESH_MULTI_SEND_DELAY_MS after the previous one landed
await vi.advanceTimersByTimeAsync(50);
expect(sends).toBe(2);
await vi.advanceTimersByTimeAsync(50);
expect(sends).toBe(3);
await p;
expect(mesh.respond).toHaveBeenCalledTimes(3);
} finally {
vi.useRealTimers();
delete process.env.MESH_MULTI_SEND_DELAY_MS;
}
});
it('sends each line of an array reply via the legacy sendText fallback when there is no packet id', async () => { it('sends each line of an array reply via the legacy sendText fallback when there is no packet id', async () => {
const [svc, mesh] = makeService(); const [svc, mesh] = makeService();
svc.register('multi', () => ['one: first item', 'two: second item']); svc.register('multi', () => ['one: first item', 'two: second item']);
@@ -164,6 +208,125 @@ describe('CommandService.onRx', () => {
}); });
}); });
describe('#loser', () => {
const tx = (
id: number,
to: number,
status: MessageRecord['status'],
): MessageRecord => ({
id,
dir: 'tx',
to,
text: 'hi',
channel: 3,
at: Date.now(),
status,
});
const makeLoserMesh = (
messages: MessageRecord[],
nodes: NodeSummary[],
): [CommandService, MeshService] => {
const mesh = {
sendText: vi.fn(),
respond: vi.fn().mockResolvedValue({ id: 1 }),
messages: () => messages,
nodes: async () => nodes,
} as unknown as MeshService;
return [new CommandService(mesh), mesh];
};
it('reports the node with the most failures — named via longName, broadcasts and pending sends excluded', async () => {
const messages = [
tx(10, BROADCAST_ADDR, 'failed'), // firmware quirk: a broadcast NACK is never a real destination
tx(9, 222, 'failed'), // Alice: 3 failed / 0 delivered → loser
tx(8, 111, 'delivered'), // Bob: 1 failed / 2 delivered
tx(7, 222, 'failed'),
tx(6, 111, 'failed'),
tx(5, 333, 'pending'), // no final status yet — ignored
tx(4, 222, 'failed'),
{
id: 3,
dir: 'rx',
from: 999,
text: 'hello',
channel: 0,
at: Date.now(),
},
];
const nodes = [
{ num: 111, shortName: 'Bob', lastHeard: null },
{ num: 222, longName: 'Alice', lastHeard: null },
];
const [svc, mesh] = makeLoserMesh(messages, nodes);
await svc.onRx(2, '#loser', 42, 7);
expect(mesh.respond).toHaveBeenCalledWith(
7,
'worst delivery: Alice (@!000000de) - 3 failed / 0 delivered',
);
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('says no failed sends when the directed history is all delivered', async () => {
const messages = [tx(2, 5, 'delivered'), tx(1, 6, 'delivered')];
const [svc, mesh] = makeLoserMesh(messages, []);
await svc.onRx(2, '#loser', 42, 7);
expect(mesh.respond).toHaveBeenCalledWith(
7,
'no failed sends yet - everyone delivers :)',
);
});
it('explains the directed-only rule when there is no final-status data at all', async () => {
const messages = [tx(2, BROADCAST_ADDR, 'pending'), tx(1, 5, 'pending')];
const [svc, mesh] = makeLoserMesh(messages, []);
await svc.onRx(2, '#loser', 42, 7);
expect(mesh.respond).toHaveBeenCalledWith(
7,
'not enough data yet - only directed sends count (broadcasts have no acks)',
);
});
it('falls back to the @!hex mention when the worst node has no known name', async () => {
const messages = [tx(1, 600, 'failed')];
const nodes = [{ num: 5, longName: 'Someone Else', lastHeard: null }];
const [svc, mesh] = makeLoserMesh(messages, nodes);
await svc.onRx(2, '#loser', 42, 7);
expect(mesh.respond).toHaveBeenCalledWith(
7,
'worst delivery: @!00000258 - 1 failed / 0 delivered',
);
});
it('tie-breaks equal failure counts by the worse failure ratio', async () => {
const messages = [
// node 7: 1 failed / 9 delivered (ratio 0.1) — not the loser
tx(16, 8, 'failed'), // node 8: 1 failed / 1 delivered (ratio 0.5) → loser
tx(15, 8, 'delivered'),
tx(14, 7, 'failed'),
...Array.from({ length: 9 }, (_, i) =>
tx(i + 1, 7, 'delivered' as const),
),
];
const [svc, mesh] = makeLoserMesh(messages, []);
await svc.onRx(2, '#loser', 42, 7);
expect(mesh.respond).toHaveBeenCalledWith(
7,
'worst delivery: @!00000008 - 1 failed / 1 delivered',
);
});
});
describe('#rss prefix dispatch', () => { describe('#rss prefix dispatch', () => {
let dir: string | undefined; let dir: string | undefined;
@@ -230,116 +393,136 @@ describe('#rss prefix dispatch', () => {
}); });
describe('MeshService wiring (event-driven trigger)', () => { describe('MeshService wiring (event-driven trigger)', () => {
it('answers a #ping DM on ch2 with a directed #pong! back to the sender, same channel, threaded quote', async () => { const MY_NUM = 3994646728;
const bridge = await startBridge(); const T0 = 1_760_000_000_000;
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port); // wire the responder exactly like command.module.ts does in production,
const svc = new RealMeshService(); // against the fake gateway client; `settle` lets the fire-and-forget
try { // onRx → respond → sendText microtask chain finish
// wire the responder exactly like command.module.ts does in production const makeWiredService = async (
client: FakeMeshMonitorClient,
): Promise<RealMeshService> => {
const svc = new RealMeshService(undefined, undefined, client);
svc.attachCommands(new CommandService(svc)); svc.attachCommands(new CommandService(svc));
svc.onModuleInit(); svc.onModuleInit();
await waitFor( await svc.pollStatus();
() => bridge.sockets.length === 1 && svc.status().connected, return svc;
); };
const out: Uint8Array[] = []; const settle = () => new Promise((r) => setTimeout(r, 10));
bridge.sockets[0].on('data', (c) => out.push(c));
// rx DM from KAMIL to node 42 on channel 2 (packet id 1) it('answers a #ping DM on ch2 with a directed #pong! back to the sender, same channel, threaded quote', async () => {
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 2)); const client = new FakeMeshMonitorClient({
connected: true,
nodeNum: MY_NUM,
});
const svc = await makeWiredService(client);
try {
// rx DM from KAMIL to us on channel 2 (packet id 7)
client.pushInbound({
packetId: 7,
fromNum: KAMIL_NUM,
fromId: KAMIL_ID,
toNum: MY_NUM,
text: '#ping',
channel: 2,
portnum: 1,
at: T0,
});
await svc.pollMessages();
await settle();
// rx record + the directed #pong! reply on channel 2 (newest first — the reply is [0]) // history: the reply is recorded as a DM (mesh channel 0 — the gateway reports DMs as -1)
await waitFor(() => svc.messages().length === 2);
expect(svc.messages()[0]).toMatchObject({ expect(svc.messages()[0]).toMatchObject({
dir: 'tx', dir: 'tx',
to: KAMIL_NUM, to: KAMIL_NUM,
text: '#pong!', text: '#pong!',
channel: 2, channel: 0,
}); });
// wire-level: DM back to the sender on ch2 with replyId = the ping packet id (threaded quote) // client-level: a DM back to the sender (toNodeId), quoting the ping packet (threaded reply)
await waitFor(() => outboundPackets(out).length >= 1); expect(client.sends).toHaveLength(1);
const pkt = outboundPackets(out)[0]!; expect(client.sends[0]).toMatchObject({
expect(pkt.to).toBe(KAMIL_NUM); text: '#pong!',
expect(pkt.channel).toBe(2); toNodeId: KAMIL_ID,
const data = channel: undefined,
pkt.payloadVariant?.case === 'decoded' replyId: 7,
? pkt.payloadVariant.value });
: undefined;
expect(data?.replyId).toBe(1);
} finally { } finally {
svc.onModuleDestroy(); svc.onModuleDestroy();
bridge.close();
} }
}); });
it('answers a #ping broadcast on ch2 with a #pong! broadcast, same channel, threaded quote', async () => { it('answers a #ping broadcast on ch2 with a #pong! broadcast, same channel, threaded quote', async () => {
const bridge = await startBridge(); const client = new FakeMeshMonitorClient({
process.env.MESH_HOST = '127.0.0.1'; connected: true,
process.env.MESH_PORT = String(bridge.port); nodeNum: MY_NUM,
const svc = new RealMeshService(); });
const svc = await makeWiredService(client);
try { try {
svc.attachCommands(new CommandService(svc)); // rx broadcast from KAMIL on channel 2 (packet id 7)
svc.onModuleInit(); client.pushInbound({
await waitFor( packetId: 7,
() => bridge.sockets.length === 1 && svc.status().connected, fromNum: KAMIL_NUM,
); fromId: KAMIL_ID,
toId: 'broadcast',
text: '#ping',
channel: 2,
portnum: 1,
at: T0,
});
await svc.pollMessages();
await settle();
const out: Uint8Array[] = []; // broadcast original → broadcast reply, on the same channel
bridge.sockets[0].on('data', (c) => out.push(c));
// rx broadcast from KAMIL on channel 2 (packet id 1)
bridge.sockets[0].write(
textPacketFrame(KAMIL_NUM, BROADCAST_ADDR, '#ping', 2),
);
await waitFor(() => svc.messages().length === 2);
expect(svc.messages()[0]).toMatchObject({ expect(svc.messages()[0]).toMatchObject({
dir: 'tx', dir: 'tx',
to: BROADCAST_ADDR, // broadcast original → broadcast reply to: BROADCAST_ADDR,
text: '#pong!', text: '#pong!',
channel: 2, channel: 2,
}); });
await waitFor(() => outboundPackets(out).length >= 1); expect(client.sends).toHaveLength(1);
const pkt = outboundPackets(out)[0]!; expect(client.sends[0]).toMatchObject({
expect(pkt.to).toBe(BROADCAST_ADDR); text: '#pong!',
expect(pkt.channel).toBe(2); toNodeId: undefined,
const data = channel: 2,
pkt.payloadVariant?.case === 'decoded' replyId: 7, // threaded quote of the ping packet (id 7)
? pkt.payloadVariant.value });
: undefined;
expect(data?.replyId).toBe(1); // threaded quote of the ping packet (id 1)
} finally { } finally {
svc.onModuleDestroy(); svc.onModuleDestroy();
bridge.close();
} }
}); });
it('does not answer #ping arriving on a non-command channel', async () => { it('does not answer #ping arriving on a non-command channel', async () => {
const bridge = await startBridge(); const client = new FakeMeshMonitorClient({
process.env.MESH_HOST = '127.0.0.1'; connected: true,
process.env.MESH_PORT = String(bridge.port); nodeNum: MY_NUM,
const svc = new RealMeshService(); });
const svc = await makeWiredService(client);
try { try {
svc.attachCommands(new CommandService(svc)); client.pushInbound({
svc.onModuleInit(); packetId: 7,
await waitFor( fromNum: KAMIL_NUM,
() => bridge.sockets.length === 1 && svc.status().connected, fromId: KAMIL_ID,
); toNum: MY_NUM,
text: '#ping',
channel: 3, // not the command channel (2)
portnum: 1,
at: T0,
});
await svc.pollMessages();
await settle();
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 3)); // only the rx record — no reply anywhere
await waitFor(() => svc.messages().length === 1); // only the rx record — no reply
expect(svc.messages()[0]).toMatchObject({ expect(svc.messages()[0]).toMatchObject({
dir: 'rx', dir: 'rx',
text: '#ping', text: '#ping',
channel: 3, channel: 3,
}); });
expect(client.sends).toEqual([]);
} finally { } finally {
svc.onModuleDestroy(); svc.onModuleDestroy();
bridge.close();
} }
}); });
}); });
+75 -2
View File
@@ -1,4 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
// pure formatting helper (no cycle: battery → mesh only)
import { mention } from '../battery/battery.service.js';
import { BROADCAST_ADDR } from '../mesh/codec.js'; import { BROADCAST_ADDR } from '../mesh/codec.js';
// type-only: the instances come from DI (command.module.ts factory) or direct construction in tests — no runtime import cycles with src/mesh / src/rss // type-only: the instances come from DI (command.module.ts factory) or direct construction in tests — no runtime import cycles with src/mesh / src/rss
import type { MeshService } from '../mesh/mesh.service.js'; import type { MeshService } from '../mesh/mesh.service.js';
@@ -27,6 +29,11 @@ export class CommandService {
process.env.COMMAND_CHANNEL ?? '2', process.env.COMMAND_CHANNEL ?? '2',
10, 10,
); );
// spacing between consecutive multi-line reply messages — back-to-back packets get dropped in RF on a real mesh; each packet needs time to land. Read at construction like the other env config.
private readonly multiSendDelayMs = parseInt(
process.env.MESH_MULTI_SEND_DELAY_MS ?? '5000',
10,
);
// single registration point: the constructor registers every built-in command — add the next one there (or via register()) // single registration point: the constructor registers every built-in command — add the next one there (or via register())
private readonly commands = new Map<string, CommandHandler>(); private readonly commands = new Map<string, CommandHandler>();
// prefix handlers own a whole family (`#rss`, `#rss add ...`) instead of one exact name // prefix handlers own a whole family (`#rss`, `#rss add ...`) instead of one exact name
@@ -38,9 +45,68 @@ export class CommandService {
rss?: RssService, rss?: RssService,
) { ) {
this.register('ping', () => '#pong!'); this.register('ping', () => '#pong!');
this.register('loser', () => this.loserReply());
// single multi-line string = ONE packet — not an array, which would drip-feed one spaced packet per line
this.register('help', () =>
[
'#ping - alive check',
'#loser - worst delivery record',
'#rss help - rss subcommands',
'#rss add <url> <name> - add feed with AI tags',
'#rss remove <name> - drop feed',
'#rss get <name|#tag> - news summary',
'#rss sources / #rss tags - list feeds/tags',
].join('\n'),
);
if (rss) this.registerPrefix('rss', (ctx) => rss.handle(ctx)); if (rss) this.registerPrefix('rss', (ctx) => rss.handle(ctx));
} }
/**
* #loser — worst packet delivery record, over directed sends only. Meshtastic shares no mesh-wide
* loss stats, so the only real data is our own tx history: group by destination, count delivered vs
* failed per node; broadcasts are excluded (firmware strips want_ack pre-TX, they stay pending — and
* the broadcast addr is never a real destination even if a NACK ever lands on it). Loser = most
* failures, tie-break worse failure ratio. One reply line, ≤ ~180 chars.
*/
private async loserReply(): Promise<string> {
const mesh = this.mesh;
if (!mesh)
return 'not enough data yet - only directed sends count (broadcasts have no acks)';
const stats = new Map<number, { failed: number; delivered: number }>();
for (const m of mesh.messages()) {
if (m.dir !== 'tx' || m.to === undefined || m.to === BROADCAST_ADDR)
continue; // directed sends only
if (m.status !== 'delivered' && m.status !== 'failed') continue; // pending = no final status yet
const s = stats.get(m.to) ?? { failed: 0, delivered: 0 };
if (m.status === 'failed') s.failed += 1;
else s.delivered += 1;
stats.set(m.to, s);
}
if (stats.size === 0)
return 'not enough data yet - only directed sends count (broadcasts have no acks)';
let worst: { num: number; failed: number; total: number } | undefined;
for (const [num, s] of stats) {
if (s.failed === 0) continue; // zero-failure nodes can't be the loser
const total = s.failed + s.delivered;
// most failures first, then worse failure ratio on a tie
if (
!worst ||
s.failed > worst.failed ||
(s.failed === worst.failed &&
s.failed / total > worst.failed / worst.total)
) {
worst = { num, failed: s.failed, total };
}
}
if (!worst) return 'no failed sends yet - everyone delivers :)';
const names = new Map(
(await mesh.nodes()).map((n) => [n.num, n.longName ?? n.shortName]),
);
const name = names.get(worst.num);
const who = name ? `${name} (${mention(worst.num)})` : mention(worst.num);
return `worst delivery: ${who} - ${worst.failed} failed / ${worst.total - worst.failed} delivered`;
}
/** Register a command handler (names are case-sensitive; the leading `#` is stripped before lookup). */ /** Register a command handler (names are case-sensitive; the leading `#` is stripped before lookup). */
register(name: string, fn: CommandHandler): void { register(name: string, fn: CommandHandler): void {
this.commands.set(name, fn); this.commands.set(name, fn);
@@ -83,8 +149,15 @@ export class CommandService {
try { try {
const reply = await handler({ text: trimmed, from, id }); const reply = await handler({ text: trimmed, from, id });
if (reply === undefined || reply === null) return; // void = no reply if (reply === undefined || reply === null) return; // void = no reply
// an array reply sends one message per line, all threaded to the same trigger packet id // an array reply sends one message per line, all threaded to the same trigger packet id — spaced so each packet has time to land in RF (no delay before the first line)
for (const line of Array.isArray(reply) ? reply : [reply]) { const lines = Array.isArray(reply) ? reply : [reply];
for (let i = 0; i < lines.length; i++) {
if (i > 0 && this.multiSendDelayMs > 0) {
await new Promise((resolve) =>
setTimeout(resolve, this.multiSendDelayMs),
);
}
const line = lines[i];
if (id !== undefined && this.mesh) { if (id !== undefined && this.mesh) {
// respond() auto-routes each line: rides the original record's channel with replyId = id (threaded quote) // respond() auto-routes each line: rides the original record's channel with replyId = id (threaded quote)
await this.mesh.respond(id, line); await this.mesh.respond(id, line);
+2 -2
View File
@@ -12,9 +12,9 @@ export class LlmService {
private readonly apiBase = private readonly apiBase =
process.env.LLM_API_BASE ?? 'http://192.168.1.70:1111/v1'; process.env.LLM_API_BASE ?? 'http://192.168.1.70:1111/v1';
private readonly model = process.env.LLM_MODEL ?? 'qwen3.8-27b'; private readonly model = process.env.LLM_MODEL ?? 'qwen3.8-27b';
// local 27B model can be slow — generous default // local 27B model can be slow (uncontrollable reasoning tokens) — generous default: 3 min headroom for slow reasoning periods
private readonly timeoutMs = parseInt( private readonly timeoutMs = parseInt(
process.env.LLM_TIMEOUT_MS ?? '60000', process.env.LLM_TIMEOUT_MS ?? '180000',
10, 10,
); );
-208
View File
@@ -1,208 +0,0 @@
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import * as net from 'node:net';
import type { AddressInfo } from 'node:net';
import { extractFrames, frame } from './codec.js';
/** In-process TCP stand-in for the meshtastic-serial-bridge; hands out accepted sockets. */
export function startBridge(
onSocket?: (s: net.Socket) => void,
): Promise<{ port: number; sockets: net.Socket[]; close: () => void }> {
const sockets: net.Socket[] = [];
const server = net.createServer((socket) => {
onSocket?.(socket); // attached at accept time, before any data can arrive
sockets.push(socket);
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve({
port: (server.address() as AddressInfo).port,
sockets,
close: () => server.close(),
});
});
});
}
export async function waitFor(
cond: () => boolean | Promise<boolean>,
timeoutMs = 5000,
): Promise<void> {
const t0 = Date.now();
while (!(await cond())) {
if (Date.now() - t0 > timeoutMs) throw new Error('timed out waiting');
await new Promise((r) => setTimeout(r, 10));
}
}
/** Plain-object NodeInfo fields (init shape, not the runtime message type) accepted by nodeInfoFrame. */
export interface NodeInfoFields {
user?: {
id?: string;
longName?: string;
shortName?: string;
hwModel?: number;
};
position?: { latitudeI?: number; longitudeI?: number; altitude?: number };
lastHeard?: number;
deviceMetrics?: { batteryLevel?: number };
snr?: number;
viaMqtt?: boolean;
hopsAway?: number;
}
// init shape for create(): all fields optional (protobuf-es' MessageInit is not exported)
type Init<T> = {
[P in keyof T as P extends '$unknown' | '$typeName' ? never : P]?: T[P];
};
/** DeviceMetadata frame as streamed once during the initial sync. */
export function metadataFrame(md: Init<P.Mesh.DeviceMetadata>): Uint8Array {
const fr = create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'metadata', value: md },
});
return frame(toBinary(P.Mesh.FromRadioSchema, fr));
}
// Config/ModuleConfig section frames as streamed one-per-section during the initial sync.
// (meshtastic.Config / meshtastic.ModuleConfig live in their own root namespaces, not under Mesh)
export function configSectionFrame(
sectionCase: P.Config.Config['payloadVariant']['case'],
value: unknown,
): Uint8Array {
const cfg = create(P.Config.ConfigSchema, {
payloadVariant: {
case: sectionCase,
value,
} as P.Config.Config['payloadVariant'],
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'config', value: cfg },
}),
),
);
}
export function moduleConfigSectionFrame(
sectionCase: P.ModuleConfig.ModuleConfig['payloadVariant']['case'],
value: unknown,
): Uint8Array {
const mc = create(P.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: sectionCase,
value,
} as P.ModuleConfig.ModuleConfig['payloadVariant'],
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'moduleConfig', value: mc },
}),
),
);
}
export function nodeInfoFrame(
num: number,
fields?: NodeInfoFields,
): Uint8Array {
const ni = create(P.Mesh.NodeInfoSchema, { num, ...fields });
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'nodeInfo', value: ni },
}),
),
);
}
/** TELEMETRY_APP packet frame with a serialized Telemetry in Data.payload (live telemetry from any node). */
export function telemetryPacketFrame(
from: number,
variant: P.Telemetry.Telemetry['variant'],
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.TELEMETRY_APP,
payload: toBinary(
P.Telemetry.TelemetrySchema,
create(P.Telemetry.TelemetrySchema, { variant }),
),
});
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 (fixed packet id 1). */
export function textPacketFrame(
from: number,
to: number,
text: string,
channel = 0,
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP,
payload: new TextEncoder().encode(text),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from,
to,
id: 1,
channel,
payloadVariant: { case: 'decoded', value: data },
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'packet', value: pkt },
}),
),
);
}
/** Outbound text packets written to the bridge, in order (want_config frames skipped). */
export function outboundPackets(out: Uint8Array[]): 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;
}
return extractFrames(merged).packets.flatMap((p) => {
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
if (tr.payloadVariant?.case === 'packet') return [tr.payloadVariant.value];
return [];
});
}
export const KAMIL_NUM = 123456789;
export function kamilNodeInfo(): NodeInfoFields {
return {
user: {
id: '!kamil',
longName: 'Kamil SP8MK/P',
shortName: 'KK',
hwModel: P.Mesh.HardwareModel.TBEAM,
},
position: { latitudeI: 521049637, longitudeI: 209833152, altitude: 123 },
lastHeard: 1758000000,
deviceMetrics: { batteryLevel: 87 },
};
}
+102 -64
View File
@@ -1,19 +1,12 @@
import { fromBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import type * as net from 'node:net';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { BROADCAST_ADDR, extractFrames } from './codec.js'; import { BROADCAST_ADDR } from './codec.js';
import { FakeMeshMonitorClient } from './meshmonitor/fake-mesh-monitor-client.js';
import { import {
KAMIL_NUM, nodeNumToId,
kamilNodeInfo, type MMMessage,
nodeInfoFrame, } from './meshmonitor/mesh-monitor-client.js';
startBridge,
telemetryPacketFrame,
textPacketFrame,
waitFor,
} from './fake-bridge.js';
import { createMeshMcpServer } from './mcp.controller.js'; import { createMeshMcpServer } from './mcp.controller.js';
import { MeshService } from './mesh.service.js'; import { MeshService } from './mesh.service.js';
@@ -26,23 +19,63 @@ function toolText(r: Awaited<ReturnType<Client['callTool']>>): string {
return c.text ?? ''; return c.text ?? '';
} }
/** The bot's own mesh node number (the gateway's view of "us"). */
const MY_NUM = 3994646728; // 0xee1978c8
/** A regular peer node. */
const KAMIL_NUM = 123456789;
const KAMIL_ID = nodeNumToId(KAMIL_NUM);
/** A fixed "past" timestamp (2025-10-09) for deterministic ordering. */
const T0 = 1_760_000_000_000;
function kamilNode() {
return { num: KAMIL_NUM, longName: 'Kamil SP8MK/P', shortName: 'KK' };
}
/** An inbound (mesh -> bot) text message as the gateway reports it (channel 3, addressed to us). */
function inbound(over: Partial<MMMessage> & { text: string }): MMMessage {
return {
packetId: 1,
fromNum: KAMIL_NUM,
toNum: MY_NUM,
channel: 3,
portnum: 1,
at: T0,
...over,
};
}
function makeClient(): FakeMeshMonitorClient {
const client = new FakeMeshMonitorClient({ localNum: MY_NUM });
client.setStatus({
connected: true,
myNodeNum: MY_NUM,
longName: 'SP8MK Bot',
nodeResponsive: true,
});
return client;
}
/**
* A fully synced service on a fake gateway client (deterministic: the status
* poll drives the one-shot initial sync; tests drive the other polls explicitly).
*/
async function connectedService(): Promise<{ async function connectedService(): Promise<{
svc: MeshService; svc: MeshService;
sockets: net.Socket[]; client: FakeMeshMonitorClient;
close: () => void; close: () => void;
}> { }> {
const bridge = await startBridge(); // pin the gateway base so the status() host/port assertion is deterministic
process.env.MESH_HOST = '127.0.0.1'; process.env.MESHMONITOR_API_BASE = 'https://meshmonitor.kkhost.pl/api/v1';
process.env.MESH_PORT = String(bridge.port); const client = makeClient();
const svc = new MeshService(); const svc = new MeshService(undefined, undefined, client);
svc.onModuleInit(); svc.onModuleInit();
await waitFor(() => bridge.sockets.length === 1 && svc.status().connected); await svc.pollStatus();
return { return {
svc, svc,
sockets: bridge.sockets, client,
close: () => { close: () => {
svc.onModuleDestroy(); svc.onModuleDestroy();
bridge.close(); delete process.env.MESHMONITOR_API_BASE;
}, },
}; };
} }
@@ -117,7 +150,7 @@ describe('MCP server tools', () => {
} }
}); });
it('mesh_status returns the bridge connection state', async () => { it('mesh_status returns the gateway connection state', async () => {
const env = await connectedService(); const env = await connectedService();
try { try {
const mcp = await connectMcp(env.svc); const mcp = await connectMcp(env.svc);
@@ -129,9 +162,9 @@ describe('MCP server tools', () => {
expect(r.isError).not.toBe(true); expect(r.isError).not.toBe(true);
expect(JSON.parse(toolText(r))).toEqual({ expect(JSON.parse(toolText(r))).toEqual({
connected: true, connected: true,
myNodeNum: null, myNodeNum: MY_NUM,
host: '127.0.0.1', host: 'meshmonitor.kkhost.pl',
port: Number(process.env.MESH_PORT), port: 443,
}); });
} finally { } finally {
await mcp.close(); await mcp.close();
@@ -144,8 +177,8 @@ describe('MCP server tools', () => {
it('send_message broadcasts by default and resolves names to node numbers', async () => { it('send_message broadcasts by default and resolves names to node numbers', async () => {
const env = await connectedService(); const env = await connectedService();
try { try {
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); env.client.setNodes([kamilNode()]);
await waitFor(async () => (await env.svc.nodes()).length === 1); await env.svc.pollNodes();
const mcp = await connectMcp(env.svc); const mcp = await connectMcp(env.svc);
try { try {
@@ -160,6 +193,7 @@ describe('MCP server tools', () => {
dir: 'tx', dir: 'tx',
to: BROADCAST_ADDR, to: BROADCAST_ADDR,
}); });
expect(env.client.sends[0]?.channel).toBe(3); // pinned test channel
// name lookup via the node DB (unique substring) // name lookup via the node DB (unique substring)
const r2 = await mcp.client.callTool({ const r2 = await mcp.client.callTool({
@@ -171,6 +205,7 @@ describe('MCP server tools', () => {
dir: 'tx', dir: 'tx',
to: KAMIL_NUM, to: KAMIL_NUM,
}); });
expect(env.client.sends[1]?.toNodeId).toBe(KAMIL_ID); // a gateway DM
} finally { } finally {
await mcp.close(); await mcp.close();
} }
@@ -209,8 +244,10 @@ describe('MCP server tools', () => {
const env = await connectedService(); const env = await connectedService();
try { try {
// rx DM from KAMIL on channel 3 (packet id 1) // rx DM from KAMIL on channel 3 (packet id 1)
env.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3)); env.client.pushInbound(
await waitFor(() => env.svc.messages().length === 1); inbound({ text: 'ping', packetId: 1, channel: 3 }),
);
await env.svc.pollMessages();
const mcp = await connectMcp(env.svc); const mcp = await connectMcp(env.svc);
try { try {
@@ -221,12 +258,17 @@ describe('MCP server tools', () => {
expect(r1.isError).not.toBe(true); expect(r1.isError).not.toBe(true);
const reply = JSON.parse(toolText(r1)) as { id: number }; const reply = JSON.parse(toolText(r1)) as { id: number };
expect(reply.id).toBeGreaterThan(0); expect(reply.id).toBeGreaterThan(0);
// DM back to the original sender on the original channel // DM back to the original sender — in mesh terms DMs ride the primary
// channel (recorded as 0) and address the node by id
expect(env.svc.messages()[0]).toMatchObject({ expect(env.svc.messages()[0]).toMatchObject({
dir: 'tx', dir: 'tx',
to: KAMIL_NUM, to: KAMIL_NUM,
text: 'pong', text: 'pong',
channel: 3, channel: 0,
});
expect(env.client.sends[0]).toMatchObject({
toNodeId: KAMIL_ID,
replyId: 1,
}); });
const r2 = await mcp.client.callTool({ const r2 = await mcp.client.callTool({
@@ -256,16 +298,11 @@ describe('MCP server tools', () => {
} }
}); });
it('inject_message records an rx entry without sending over RF', async () => { it('inject_message records an rx entry without sending over the air', async () => {
const env = await connectedService(); const env = await connectedService();
try { try {
const mcp = await connectMcp(env.svc); const mcp = await connectMcp(env.svc);
try { try {
// capture outbound bytes: the only frame ever written on connect is want_config,
// so any 'packet' variant after this point would be an RF transmission
const out: Uint8Array[] = [];
env.sockets[0].on('data', (c) => out.push(c));
const r1 = await mcp.client.callTool({ const r1 = await mcp.client.callTool({
name: 'inject_message', name: 'inject_message',
arguments: { text: 'trigger' }, arguments: { text: 'trigger' },
@@ -297,19 +334,8 @@ describe('MCP server tools', () => {
expect(r3.isError).toBe(true); expect(r3.isError).toBe(true);
expect(toolText(r3)).toContain('invalid node number'); expect(toolText(r3)).toContain('invalid node number');
// nothing went over the wire — synthetic inbox injection only. // nothing was transmitted — synthetic inbox injection only
// The want_config frame (written on connect) may or may not have been captured; expect(env.client.sends).toHaveLength(0);
// what matters is that no ToRadio 'packet' variant ever made it to the bridge.
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;
}
for (const p of extractFrames(merged).packets) {
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
expect(tr.payloadVariant?.case).not.toBe('packet');
}
} finally { } finally {
await mcp.close(); await mcp.close();
} }
@@ -321,8 +347,8 @@ describe('MCP server tools', () => {
it('get_node resolves by name and errors when missing', async () => { it('get_node resolves by name and errors when missing', async () => {
const env = await connectedService(); const env = await connectedService();
try { try {
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); env.client.setNodes([kamilNode()]);
await waitFor(async () => (await env.svc.nodes()).length === 1); await env.svc.pollNodes();
const mcp = await connectMcp(env.svc); const mcp = await connectMcp(env.svc);
try { try {
@@ -354,8 +380,19 @@ describe('MCP server tools', () => {
it('list_channels, list_nodes, get_messages and device_info return the service data', async () => { it('list_channels, list_nodes, get_messages and device_info return the service data', async () => {
const env = await connectedService(); const env = await connectedService();
try { try {
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); env.client.setChannels([{ index: 3, name: 'test' }]);
await waitFor(async () => (await env.svc.nodes()).length === 1); env.client.setNodes([kamilNode()]);
env.client.setTelemetry([
{
fromNum: KAMIL_NUM,
at: T0,
kind: 'batteryLevel',
data: { value: 87, packetId: 77 },
},
]);
await env.svc.pollChannels();
await env.svc.pollNodes();
await env.svc.pollTelemetry();
const mcp = await connectMcp(env.svc); const mcp = await connectMcp(env.svc);
try { try {
@@ -368,16 +405,24 @@ describe('MCP server tools', () => {
}), }),
), ),
), ),
).toEqual([]); // no channel frames fed yet ).toEqual([
{
index: 3,
name: 'test',
uplinkEnabled: false,
downlinkEnabled: false,
},
]);
// no metadata/config frames fed yet → empty sections, null node num // degraded device info: identity from the gateway, empty config sections
// (the gateway has no device-metadata/config endpoints)
expect( expect(
JSON.parse( JSON.parse(
toolText( toolText(
await mcp.client.callTool({ name: 'device_info', arguments: {} }), await mcp.client.callTool({ name: 'device_info', arguments: {} }),
), ),
), ),
).toEqual({ myNodeNum: null, localConfig: {}, moduleConfig: {} }); ).toEqual({ myNodeNum: MY_NUM, localConfig: {}, moduleConfig: {} });
const nodes = JSON.parse( const nodes = JSON.parse(
toolText( toolText(
@@ -399,13 +444,6 @@ describe('MCP server tools', () => {
) as { dir: string; to?: number }[]; ) as { dir: string; to?: number }[];
expect(msgs[0]).toMatchObject({ dir: 'tx', to: BROADCAST_ADDR }); // newest first expect(msgs[0]).toMatchObject({ dir: 'tx', to: BROADCAST_ADDR }); // newest first
env.sockets[0].write(
telemetryPacketFrame(KAMIL_NUM, {
case: 'deviceMetrics',
value: { batteryLevel: 87 },
}),
);
await waitFor(() => env.svc.telemetry().length === 1);
const tel = JSON.parse( const tel = JSON.parse(
toolText( toolText(
await mcp.client.callTool({ name: 'get_telemetry', arguments: {} }), await mcp.client.callTool({ name: 'get_telemetry', arguments: {} }),
@@ -416,7 +454,7 @@ describe('MCP server tools', () => {
from: KAMIL_NUM, from: KAMIL_NUM,
kind: 'deviceMetrics', kind: 'deviceMetrics',
}); });
expect(tel[0].data).toEqual({ batteryLevel: 87 }); expect(tel[0].data).toEqual({ batteryLevel: { value: 87 } });
} finally { } finally {
await mcp.close(); await mcp.close();
} }
+7 -32
View File
@@ -69,7 +69,7 @@ export class MeshController {
@ApiOperation({ @ApiOperation({
summary: 'Bridge connection status', summary: 'Bridge connection status',
description: description:
'Whether the bot is connected to the meshtastic-serial-bridge and which node number this device has.', 'Whether the device behind the MeshMonitor gateway is connected and which node number this device has; host/port identify the gateway.',
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -77,8 +77,8 @@ export class MeshController {
example: { example: {
connected: true, connected: true,
myNodeNum: 123456789, myNodeNum: 123456789,
host: '192.168.1.4', host: 'meshmonitor.kkhost.pl',
port: 4403, port: 443,
}, },
}) })
status() { status() {
@@ -165,9 +165,9 @@ export class MeshController {
@Get('device') @Get('device')
@ApiOperation({ @ApiOperation({
summary: 'Device info (metadata + config, keys redacted)', summary: 'Device info (firmware; config sections degraded)',
description: description:
'This device\'s node number, firmware version and metadata, plus per-section config and module-config messages captured during the initial sync; empty until it completes. Key material is never exposed: security publicKey/privateKey/adminKey and network wifiPsk are replaced with "REDACTED". In-memory only, reset on every bridge reconnect.', "This device's node number and the firmware version the gateway reports for it. Degraded versus the old TCP bridge: the gateway has no device-metadata or config endpoints, so localConfig/moduleConfig are always empty (key material is never exposed). In-memory only.",
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -175,33 +175,8 @@ export class MeshController {
example: { example: {
myNodeNum: 123456789, myNodeNum: 123456789,
firmwareVersion: '2.7.0.1', firmwareVersion: '2.7.0.1',
metadata: { localConfig: {},
firmwareVersion: '2.7.0.1', moduleConfig: {},
deviceStateVersion: 1,
canShutdown: true,
hasWifi: false,
hasBluetooth: true,
hasEthernet: false,
role: 3,
positionFlags: 65,
hwModel: 42,
hasRemoteHardware: false,
hasPKC: true,
excludedModules: 0,
},
localConfig: {
device: { role: 3, buttonGpio: 13 },
security: {
publicKey: 'REDACTED',
privateKey: 'REDACTED',
adminKey: 'REDACTED',
isManaged: true,
},
network: { wifiEnabled: false, wifiSsid: '', wifiPsk: 'REDACTED' },
},
moduleConfig: {
telemetry: { environmentUpdateInterval: 900 },
},
}, },
}) })
device(): DeviceInfo { device(): DeviceInfo {
+12 -3
View File
@@ -5,6 +5,11 @@ import { DatabaseSync } from 'node:sqlite';
import { OpencodeService } from '../opencode/opencode.service.js'; import { OpencodeService } from '../opencode/opencode.service.js';
import { McpController } from './mcp.controller.js'; import { McpController } from './mcp.controller.js';
import { MeshController } from './mesh.controller.js'; import { MeshController } from './mesh.controller.js';
import {
MESH_MONITOR_CLIENT,
MeshMonitorModule,
} from './meshmonitor/meshmonitor.module.js';
import type { MeshMonitorClient } from './meshmonitor/mesh-monitor-client.js';
import { initMessagesTable, MeshService } from './mesh.service.js'; import { initMessagesTable, MeshService } from './mesh.service.js';
// SQLite-backed message history: file path from MESH_DB_PATH (default ./data/mesh-bot.db), parent dir created if missing. // SQLite-backed message history: file path from MESH_DB_PATH (default ./data/mesh-bot.db), parent dir created if missing.
@@ -17,14 +22,18 @@ function openMessageDb(): DatabaseSync {
} }
@Module({ @Module({
imports: [MeshMonitorModule],
controllers: [MeshController, McpController], controllers: [MeshController, McpController],
providers: [ providers: [
OpencodeService, OpencodeService,
{ {
// the MeshMonitor client is null when MESHMONITOR_API_TOKEN is unset — the service then stays disconnected
provide: MeshService, provide: MeshService,
useFactory: (opencode: OpencodeService) => useFactory: (
new MeshService(openMessageDb(), opencode), opencode: OpencodeService,
inject: [OpencodeService], client: MeshMonitorClient | null,
) => new MeshService(openMessageDb(), opencode, client),
inject: [OpencodeService, MESH_MONITOR_CLIENT],
}, },
], ],
exports: [MeshService], exports: [MeshService],
+1391 -1582
View File
File diff suppressed because it is too large Load Diff
+444 -384
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,298 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { FakeMeshMonitorClient } from './fake-mesh-monitor-client.js';
import { BROADCAST_ADDR } from './mesh-monitor-client.js';
const BOT_NUM = 3994646728; // !ee1978c8
const KAMIL_NUM = 2927200947; // !ae798ab3
describe('FakeMeshMonitorClient', () => {
let fake: FakeMeshMonitorClient;
beforeEach(() => {
fake = new FakeMeshMonitorClient({ nodeNum: BOT_NUM });
});
// ------------------------------------------------------------------
// status
// ------------------------------------------------------------------
describe('getStatus', () => {
it('returns a default status until configured', async () => {
expect(await new FakeMeshMonitorClient().getStatus()).toEqual({
connected: true,
myNodeNum: 0,
});
});
it('returns the configured status (defensively copied)', async () => {
fake.setStatus({ connected: false, myNodeNum: BOT_NUM, longName: 'Bot' });
const s = await fake.getStatus();
s.connected = true; // mutate the returned copy
expect((await fake.getStatus()).connected).toBe(false);
});
});
// ------------------------------------------------------------------
// nodes
// ------------------------------------------------------------------
describe('getNodes / getNode', () => {
beforeEach(() => {
fake.setNodes([
{
num: BOT_NUM,
longName: 'SP8MK Bot',
shortName: '8MKb',
position: { lat: 51, lon: 22 },
},
{ num: KAMIL_NUM, longName: 'Kamil', shortName: 'KK' },
]);
});
it('returns all configured nodes', async () => {
expect(await fake.getNodes()).toHaveLength(2);
});
it('finds a node by number', async () => {
expect((await fake.getNode(KAMIL_NUM))?.longName).toBe('Kamil');
});
it('finds a node by !hex id', async () => {
expect((await fake.getNode('!ae798ab3'))?.longName).toBe('Kamil');
expect((await fake.getNode('!ee1978c8'))?.longName).toBe('SP8MK Bot');
});
it('finds a node by numeric string', async () => {
expect((await fake.getNode(String(KAMIL_NUM)))?.longName).toBe('Kamil');
});
it('finds a node by name (case-insensitive)', async () => {
expect((await fake.getNode('kamil'))?.num).toBe(KAMIL_NUM);
expect((await fake.getNode('8mkB'))?.num).toBe(BOT_NUM);
});
it('returns null for an unknown node', async () => {
expect(await fake.getNode(999)).toBeNull();
expect(await fake.getNode('!deadbeef')).toBeNull();
expect(await fake.getNode('nobody')).toBeNull();
});
});
describe('constructor + pushNode', () => {
it('seeds the local node and reports the source identity', async () => {
const f = new FakeMeshMonitorClient({
nodeNum: BOT_NUM,
longName: 'SP8MK Bot',
shortName: '8MKb',
connected: false,
});
expect(await f.getStatus()).toEqual({
connected: false,
myNodeNum: BOT_NUM,
longName: 'SP8MK Bot',
});
const nodes = await f.getNodes();
expect(nodes).toHaveLength(1);
expect(nodes[0]).toMatchObject({
num: BOT_NUM,
longName: 'SP8MK Bot',
shortName: '8MKb',
lastHeard: 0,
});
});
it('pushNode upserts by num — replace in place when present, append when new', async () => {
const f = new FakeMeshMonitorClient({ nodeNum: BOT_NUM });
f.pushNode({ num: BOT_NUM, longName: 'renamed', lastHeard: 5 });
f.pushNode({ num: KAMIL_NUM, longName: 'Kamil' });
const nodes = await f.getNodes();
// order preserved: the seeded bot node first (replaced in place), Kamil appended
expect(nodes).toHaveLength(2);
expect(nodes[0]).toMatchObject({
num: BOT_NUM,
longName: 'renamed',
lastHeard: 5,
});
expect(nodes[1]).toMatchObject({ num: KAMIL_NUM, longName: 'Kamil' });
});
it('a zero/omitted nodeNum seeds no node and reports myNodeNum 0', async () => {
expect(await new FakeMeshMonitorClient().getNodes()).toEqual([]);
});
});
// ------------------------------------------------------------------
// channels + telemetry (passthrough of configured data)
// ------------------------------------------------------------------
describe('getChannels / getTelemetry', () => {
it('returns configured channels and telemetry', async () => {
fake.setChannels([
{ index: 0, name: '', psk: 'REDACTED' },
{ index: 3, name: 'test' },
]);
fake.setTelemetry([
{ fromNum: BOT_NUM, at: 1, kind: 'airUtilTx', data: { value: 0.1 } },
]);
expect(await fake.getChannels()).toHaveLength(2);
expect((await fake.getTelemetry())[0].kind).toBe('airUtilTx');
});
it('returns empty arrays by default', async () => {
expect(await fake.getChannels()).toEqual([]);
expect(await fake.getTelemetry()).toEqual([]);
});
});
// ------------------------------------------------------------------
// pushInbound + getMessages
// ------------------------------------------------------------------
describe('getMessages', () => {
it('returns inbound messages newest first', async () => {
fake.pushInbound({ packetId: 1, text: 'first', channel: 0, at: 100 });
fake.pushInbound({ packetId: 2, text: 'second', channel: 0, at: 200 });
const msgs = await fake.getMessages();
expect(msgs.map((m) => m.text)).toEqual(['second', 'first']);
});
it('filters by channel', async () => {
fake.pushInbound({ packetId: 1, text: 'a', channel: 0, at: 1 });
fake.pushInbound({ packetId: 2, text: 'b', channel: 3, at: 2 });
const msgs = await fake.getMessages({ channel: 3 });
expect(msgs.map((m) => m.text)).toEqual(['b']);
});
it('filters by toNodeId and fromNodeId', async () => {
fake.pushInbound({
packetId: 1,
fromId: '!ee1978c8',
toId: '!ffffffff',
text: 'bcast',
channel: 3,
at: 1,
});
fake.pushInbound({
packetId: 2,
fromId: '!ae798ab3',
toId: '!ee1978c8',
text: 'dm',
channel: -1,
at: 2,
});
expect(
(await fake.getMessages({ toNodeId: '!ffffffff' })).map((m) => m.text),
).toEqual(['bcast']);
expect(
(await fake.getMessages({ fromNodeId: '!ae798ab3' })).map(
(m) => m.text,
),
).toEqual(['dm']);
});
it('applies limit (newest N) after filtering', async () => {
fake.pushInbound({ packetId: 1, text: 'a', channel: 0, at: 1 });
fake.pushInbound({ packetId: 2, text: 'b', channel: 0, at: 2 });
fake.pushInbound({ packetId: 3, text: 'c', channel: 3, at: 3 });
// newest-first: c, b, a — limit 2 → c, b
expect((await fake.getMessages({ limit: 2 })).map((m) => m.text)).toEqual(
['c', 'b'],
);
// limit + channel: only c is on ch3
expect(
(await fake.getMessages({ limit: 2, channel: 3 })).map((m) => m.text),
).toEqual(['c']);
});
it('returns a defensive copy (mutating the result does not leak)', async () => {
fake.pushInbound({ packetId: 1, text: 'x', channel: 0, at: 1 });
const m = (await fake.getMessages())[0];
m.text = 'mutated';
expect((await fake.getMessages())[0].text).toBe('x');
});
});
// ------------------------------------------------------------------
// send
// ------------------------------------------------------------------
describe('send', () => {
it('records a directed send and mirrors it as an outbound message', async () => {
const out = await fake.send({ text: 'hi kamil', toNodeId: '!ae798ab3' });
expect(out.deliveryState).toBe('pending');
expect(out.packetId).toBeGreaterThan(0);
expect(fake.sends).toHaveLength(1);
expect(fake.sends[0]).toMatchObject({
text: 'hi kamil',
toNodeId: '!ae798ab3',
packetId: out.packetId,
});
const msgs = await fake.getMessages();
expect(msgs).toHaveLength(1);
expect(msgs[0]).toMatchObject({
packetId: out.packetId,
fromNum: BOT_NUM,
fromId: '!ee1978c8',
toNum: KAMIL_NUM,
toId: '!ae798ab3',
text: 'hi kamil',
channel: -1, // DM rides channel -1
requestId: out.packetId,
deliveryState: 'pending',
});
});
it('records a broadcast send with the broadcast destination', async () => {
const out = await fake.send({ text: 'hello mesh', channel: 3 });
const msgs = await fake.getMessages();
expect(msgs[0]).toMatchObject({
toNum: BROADCAST_ADDR,
toId: '!ffffffff',
channel: 3,
text: 'hello mesh',
});
expect(out.packetId).toBe(msgs[0].packetId);
});
it('assigns a distinct, non-zero packet id per send', async () => {
const a = await fake.send({ text: 'one', toNodeId: '!ae798ab3' });
const b = await fake.send({ text: 'two', channel: 0 });
expect(a.packetId).not.toBe(b.packetId);
expect(a.packetId).toBeGreaterThan(0);
expect(b.packetId).toBeGreaterThan(0);
});
it('carries replyId onto the recorded message', async () => {
await fake.send({ text: 'replying', toNodeId: '!ae798ab3', replyId: 55 });
expect((await fake.getMessages())[0].replyId).toBe(55);
expect(fake.sends[0].replyId).toBe(55);
});
it('rejects XOR violations exactly like the HTTP client', async () => {
await expect(
fake.send({ text: 'x', channel: 3, toNodeId: '!1' }),
).rejects.toThrow(/mutually exclusive/);
await expect(fake.send({ text: 'x' })).rejects.toThrow(
/required \(XOR\)/,
);
await expect(fake.send({ text: '', channel: 3 })).rejects.toThrow(
/non-empty/,
);
// none of the rejected sends were recorded
expect(fake.sends).toHaveLength(0);
expect(await fake.getMessages()).toHaveLength(0);
});
it('clearTraffic() resets sends + messages + the packet-id counter', async () => {
await fake.send({ text: 'a', channel: 0 });
fake.pushInbound({ packetId: 1, text: 'in', channel: 0, at: 1 });
fake.clearTraffic();
expect(fake.sends).toHaveLength(0);
expect(await fake.getMessages()).toHaveLength(0);
// counter reset → first id is 1 again
expect((await fake.send({ text: 'b', channel: 0 })).packetId).toBe(1);
});
});
});
@@ -0,0 +1,250 @@
/**
* In-memory stand-in for the {@link MeshMonitorClient} — the Phase-2 test
* driver that lets specs exercise `MeshService` against the HTTP-client
* contract without touching the network:
* - construct it with the source identity the gateway would report
* (`{ connected, nodeNum, longName, shortName }`); the bot's own node is
* seeded in the node list automatically (the gateway always reports the
* local node)
* - set the world with the `set*`/`pushNode`/`pushInbound` helpers below
* - every `send()` is recorded in {@link FakeMeshMonitorClient.sends} AND
* appears as an outbound entry in `getMessages()` (newest first), exactly
* like the real gateway mirrors outbound packets
*
* It implements the exact {@link MeshMonitorClient} interface, so it is a
* drop-in replacement for the real client in the service's constructor.
*/
import {
BROADCAST_ADDR,
assertSendTarget,
nodeIdToNum,
nodeNumToId,
type MMChannel,
type MMMessage,
type MMNode,
type MMStatus,
type MMTelemetry,
type MeshMonitorClient,
} from './mesh-monitor-client.js';
/** One recorded {@link FakeMeshMonitorClient.send} call (input + assigned ids). */
export interface FakeSendRecord {
text: string;
toNodeId?: string;
channel?: number;
replyId?: number;
/** Non-zero uint32 assigned by the fake (monotonic per instance). */
packetId: number;
deliveryState: string;
messageId?: string;
}
/**
* A send as it would be stored by the gateway: the fake mirrors what the real
* `POST /sources/{id}/messages` returns and what a later `GET .../messages`
* would surface for an outbound packet.
*/
export interface FakeMessageInput {
text: string;
toNodeId?: string;
channel?: number;
replyId?: number;
}
export class FakeMeshMonitorClient implements MeshMonitorClient {
private status: MMStatus;
private nodes: MMNode[] = [];
private channels: MMChannel[] = [];
private telemetry: MMTelemetry[] = [];
/** Inbound + outbound messages, newest first (matches the gateway order). */
private messages: MMMessage[] = [];
/** The bot's own node number — used as the sender of outbound messages. */
private readonly localNum: number;
private packetSeq = 0;
/** Every `send()` in call order. */
readonly sends: FakeSendRecord[] = [];
constructor(opts?: {
connected?: boolean;
nodeNum?: number;
longName?: string;
shortName?: string;
}) {
this.localNum = opts?.nodeNum ?? 0;
this.status = {
connected: opts?.connected ?? true,
myNodeNum: this.localNum,
};
if (opts?.longName) this.status.longName = opts.longName;
// the gateway always reports the local node in the node list
if (this.localNum !== 0)
this.pushNode({
num: this.localNum,
longName: opts?.longName,
shortName: opts?.shortName,
lastHeard: 0,
});
}
// ------------------------------------------------------------------
// Test-side configuration
// ------------------------------------------------------------------
setStatus(s: MMStatus): void {
this.status = { ...s };
}
setNodes(n: MMNode[]): void {
this.nodes = n.map((x) => ({ ...x }));
}
setChannels(c: MMChannel[]): void {
this.channels = c.map((x) => ({ ...x }));
}
setTelemetry(t: MMTelemetry[]): void {
this.telemetry = t.map((x) => ({ ...x }));
}
/** Upsert a node by `num` — replace in place when present, append when new. */
pushNode(n: MMNode): void {
const i = this.nodes.findIndex((x) => x.num === n.num);
if (i === -1) this.nodes.push({ ...n });
else this.nodes[i] = { ...n };
}
/**
* Record a message as if it arrived on the mesh (inbound). Prepend, so the
* newest stays first, exactly like the gateway's newest-first ordering.
*/
pushInbound(m: MMMessage): void {
this.messages.unshift({ ...m });
}
/** Reset sends + messages + the packet-id counter (keeps the configured world). */
clearTraffic(): void {
this.sends.length = 0;
this.messages.length = 0;
this.packetSeq = 0;
}
/**
* Mutate the delivery state of a stored message by packet id — models the
* gateway updating an outbound entry from `pending` to a final state in
* place (the same `id` reappears in later `GET .../messages` windows).
* No-op when no message has the id.
*/
setDeliveryState(packetId: number, state: MMMessage['deliveryState']): void {
for (const m of this.messages)
if (m.packetId === packetId) m.deliveryState = state;
}
// ------------------------------------------------------------------
// MeshMonitorClient
// ------------------------------------------------------------------
async getStatus(): Promise<MMStatus> {
return { ...this.status };
}
async getNodes(): Promise<MMNode[]> {
return this.nodes.map((n) => ({ ...n }));
}
async getNode(nodeIdOrNum: string | number): Promise<MMNode | null> {
const hit = this.nodes.find((n) => this.nodeMatches(n, nodeIdOrNum));
return hit ? { ...hit } : null;
}
/** Match a node by `!hex` id, node number (or numeric string), or exact name. */
private nodeMatches(n: MMNode, idOrNum: string | number): boolean {
if (typeof idOrNum === 'number') return n.num === idOrNum >>> 0;
const s = String(idOrNum).trim();
// `!hex` id
const asNum = nodeIdToNum(s);
if (asNum != null) return n.num === asNum;
// plain numeric string
if (/^[0-9]+$/.test(s)) return n.num === Number(s);
// name (case-insensitive, exact long/short — like the gateway lookup)
const lower = s.toLowerCase();
return (
n.longName?.toLowerCase() === lower ||
n.shortName?.toLowerCase() === lower
);
}
async getChannels(): Promise<MMChannel[]> {
return this.channels.map((c) => ({ ...c }));
}
async getTelemetry(): Promise<MMTelemetry[]> {
return this.telemetry.map((t) => ({ ...t }));
}
async getMessages(opts?: {
limit?: number;
channel?: number;
toNodeId?: string;
fromNodeId?: string;
}): Promise<MMMessage[]> {
let list = this.messages;
if (opts?.channel != null)
list = list.filter((m) => m.channel === opts.channel);
if (opts?.toNodeId != null)
list = list.filter((m) => m.toId === opts.toNodeId);
if (opts?.fromNodeId != null)
list = list.filter((m) => m.fromId === opts.fromNodeId);
if (opts?.limit != null) list = list.slice(0, opts.limit);
return list.map((m) => ({ ...m }));
}
async send(msg: FakeMessageInput): Promise<{
packetId: number;
deliveryState: string;
messageId?: string;
}> {
// identical validation to the HTTP client (shared assertSendTarget)
assertSendTarget(msg);
this.packetSeq = (this.packetSeq + 1) >>> 0;
if (this.packetSeq === 0) this.packetSeq = 1; // keep it a non-zero uint32
const packetId = this.packetSeq;
const deliveryState = 'pending';
const messageId = `fake-${packetId}`;
this.sends.push({
text: msg.text,
toNodeId: msg.toNodeId,
channel: msg.channel,
replyId: msg.replyId,
packetId,
deliveryState,
messageId,
});
// Mirror the gateway: an outbound message is readable back via getMessages().
const toNum =
msg.toNodeId != null
? (nodeIdToNum(msg.toNodeId) ?? undefined)
: BROADCAST_ADDR;
const toId = msg.toNodeId ?? nodeNumToId(BROADCAST_ADDR);
this.messages.unshift({
packetId,
fromNum: this.localNum,
fromId: nodeNumToId(this.localNum),
toNum,
toId,
text: msg.text,
// a channel send is a broadcast (channel >= 0); a DM rides channel -1
channel: msg.channel ?? -1,
portnum: 1,
requestId: packetId,
replyId: msg.replyId,
at: Date.now(),
deliveryState,
});
return { packetId, deliveryState, messageId };
}
}
@@ -0,0 +1,777 @@
import * as http from 'node:http';
import type { AddressInfo } from 'node:net';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
BROADCAST_ADDR,
HttpMeshMonitorClient,
MeshMonitorError,
createMeshMonitorClientFromEnv,
nodeIdToNum,
nodeNumToId,
} from './mesh-monitor-client.js';
/** Test-only bearer token — never the real one (which lives only in .env). */
const TOKEN = 'mm_v1_test_token_not_real';
const SOURCE = 'srctest';
/** The gateway serves under `/api/v1`; the client's baseUrl carries it. */
const BASE = '/api/v1';
interface RecordedRequest {
method: string;
url: string;
pathname: string;
query?: string;
headers: http.IncomingHttpHeaders;
body: string;
}
interface CannedResponse {
status?: number;
body: unknown;
}
type Routes = Record<string, CannedResponse>;
interface Gateway {
baseUrl: string;
requests: RecordedRequest[];
close: () => Promise<void>;
}
function splitQuery(url: string): [string, string | undefined] {
const i = url.indexOf('?');
return i === -1 ? [url, undefined] : [url.slice(0, i), url.slice(i + 1)];
}
/**
* In-process `node:http` stand-in for the MeshMonitor gateway. Routes are keyed
* by `METHOD pathname` (query stripped). Every request is recorded so tests can
* assert the exact path, headers and body the client emitted.
*/
function startGateway(routes: Routes): Promise<Gateway> {
const requests: RecordedRequest[] = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
const url = req.url ?? '/';
const [pathname, query] = splitQuery(url);
requests.push({
method: req.method ?? 'GET',
url,
pathname,
query,
headers: req.headers,
body,
});
const route = routes[`${req.method} ${pathname}`];
const status = route ? (route.status ?? 200) : 404;
res.writeHead(status, { 'content-type': 'application/json' });
res.end(
JSON.stringify(
route
? route.body
: { success: false, error: `no route ${req.method} ${pathname}` },
),
);
});
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const port = (server.address() as AddressInfo).port;
resolve({
baseUrl: `http://127.0.0.1:${port}${BASE}`,
requests,
close: () =>
new Promise<void>((r) => {
server.closeAllConnections?.();
server.close(() => r());
}),
});
});
});
}
/** Route key for a source-scoped path, e.g. route('GET', '/status'). */
const route = (method: string, path: string): string =>
`${method} ${BASE}/sources/${SOURCE}${path}`;
let gw: Gateway | null = null;
beforeEach(() => {
gw = null;
});
afterEach(async () => {
await gw?.close();
gw = null;
for (const k of [
'MESHMONITOR_API_TOKEN',
'MESHMONITOR_API_BASE',
'MESHMONITOR_SOURCE',
'MESHMONITOR_TIMEOUT_MS',
])
delete process.env[k];
});
function makeClient(baseUrl: string): HttpMeshMonitorClient {
return new HttpMeshMonitorClient({
baseUrl,
sourceId: SOURCE,
token: TOKEN,
timeoutMs: 5000,
});
}
// ---------------------------------------------------------------------------
// Helpers: node id <-> number, broadcast address
// ---------------------------------------------------------------------------
describe('nodeNumToId / nodeIdToNum', () => {
it('maps a uint32 node number to an 8-char !hex id', () => {
expect(nodeNumToId(3994646728)).toBe('!ee1978c8');
expect(nodeNumToId(2927200947)).toBe('!ae798ab3');
expect(nodeNumToId(0)).toBe('!00000000');
expect(nodeNumToId(4294967295)).toBe('!ffffffff');
});
it('parses an !hex id back to a number (round-trip)', () => {
expect(nodeIdToNum('!ee1978c8')).toBe(3994646728);
expect(nodeIdToNum('!AE798AB3')).toBe(2927200947); // case-insensitive
expect(nodeIdToNum('!ffffffff')).toBe(4294967295);
for (const n of [1, 255, 0xdeadbeef, 4294967295])
expect(nodeIdToNum(nodeNumToId(n))).toBe(n);
});
it('rejects malformed ids with null', () => {
expect(nodeIdToNum('ee1978c8')).toBeNull();
expect(nodeIdToNum('!zzzz')).toBeNull();
expect(nodeIdToNum('!1234567890')).toBeNull(); // > 8 hex digits
expect(nodeIdToNum('')).toBeNull();
});
it('exposes the broadcast address as 0xffffffff / !ffffffff', () => {
expect(BROADCAST_ADDR).toBe(4294967295);
expect(nodeNumToId(BROADCAST_ADDR)).toBe('!ffffffff');
});
});
// ---------------------------------------------------------------------------
// getStatus
// ---------------------------------------------------------------------------
describe('HttpMeshMonitorClient.getStatus', () => {
it('GETs the source status, sends the Bearer token, maps raw → MMStatus', async () => {
gw = await startGateway({
[route('GET', '/status')]: {
body: {
success: true,
data: {
localNodeNum: 3994646728,
localNodeId: '!ee1978c8',
longName: 'SP8MK Bot',
shortName: '8MKb',
connected: true,
nodeResponsive: true,
},
},
},
});
const s = await makeClient(gw.baseUrl).getStatus();
expect(s).toEqual({
connected: true,
myNodeNum: 3994646728,
longName: 'SP8MK Bot',
nodeResponsive: true,
});
const req = gw.requests[0];
expect(req.pathname).toBe(`${BASE}/sources/${SOURCE}/status`);
expect(req.headers.authorization).toBe(`Bearer ${TOKEN}`);
});
it('defaults connected=false / myNodeNum=0 when fields are absent', async () => {
gw = await startGateway({
[route('GET', '/status')]: { body: { success: true, data: {} } },
});
const s = await makeClient(gw.baseUrl).getStatus();
expect(s.connected).toBe(false);
expect(s.myNodeNum).toBe(0);
expect(s.longName).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// getNodes
// ---------------------------------------------------------------------------
describe('HttpMeshMonitorClient.getNodes', () => {
it('GETs with active/sinceDays, maps flat position + second-based lastHeard', async () => {
gw = await startGateway({
[route('GET', '/nodes')]: {
body: {
success: true,
count: 2,
data: [
{
nodeNum: 3994646728,
nodeId: '!ee1978c8',
longName: 'SP8MK Bot',
shortName: '8MKb',
hwModel: 63,
batteryLevel: 37,
voltage: 3.607,
firmwareVersion: '2.7.15.567b8ea',
rssi: null,
snr: 0,
lastHeard: 1791834334,
latitude: 51.253837,
longitude: 22.528193,
altitude: null,
hopsAway: null,
viaMqtt: false,
},
{
nodeNum: 2927200947,
nodeId: '!ae798ab3',
longName: 'Lubmesh Node',
shortName: 'LM',
hwModel: 14,
batteryLevel: 88,
lastHeard: 0,
latitude: null,
longitude: null,
snr: -8.25,
rssi: -118,
viaMqtt: false,
},
],
},
},
});
const nodes = await makeClient(gw.baseUrl).getNodes();
expect(nodes).toEqual([
{
num: 3994646728,
longName: 'SP8MK Bot',
shortName: '8MKb',
hwModel: 63,
batteryLevel: 37,
voltage: 3.607,
firmwareVersion: '2.7.15.567b8ea',
snr: 0,
lastHeard: 1791834334,
position: { lat: 51.253837, lon: 22.528193 },
},
{
num: 2927200947,
longName: 'Lubmesh Node',
shortName: 'LM',
hwModel: 14,
batteryLevel: 88,
snr: -8.25,
rssi: -118,
},
]);
const req = gw.requests[0];
expect(req.pathname).toBe(`${BASE}/sources/${SOURCE}/nodes`);
expect(req.query).toBe('active=true&sinceDays=7');
expect(req.headers.authorization).toBe(`Bearer ${TOKEN}`);
});
});
// ---------------------------------------------------------------------------
// getNode
// ---------------------------------------------------------------------------
describe('HttpMeshMonitorClient.getNode', () => {
it('resolves a numeric argument to !hex and maps the node detail', async () => {
gw = await startGateway({
[route('GET', '/nodes/!ee1978c8')]: {
body: {
success: true,
data: {
nodeNum: 3994646728,
nodeId: '!ee1978c8',
longName: 'SP8MK Bot',
batteryLevel: 37,
lastHeard: 1791834334,
latitude: 51.253837,
longitude: 22.528193,
altitude: null,
viaMqtt: false,
},
},
},
});
const n = await makeClient(gw.baseUrl).getNode(3994646728);
expect(n?.num).toBe(3994646728);
expect(n?.longName).toBe('SP8MK Bot');
expect(n?.position).toEqual({ lat: 51.253837, lon: 22.528193 });
expect(gw.requests[0].pathname).toBe(
`${BASE}/sources/${SOURCE}/nodes/!ee1978c8`,
);
});
it('passes an explicit !hex id through unchanged', async () => {
gw = await startGateway({
[route('GET', '/nodes/!ae798ab3')]: {
body: {
success: true,
data: { nodeNum: 2927200947, longName: 'X' },
},
},
});
const n = await makeClient(gw.baseUrl).getNode('!ae798ab3');
expect(n?.num).toBe(2927200947);
expect(gw.requests[0].pathname).toBe(
`${BASE}/sources/${SOURCE}/nodes/!ae798ab3`,
);
});
it('returns null on a 404 (unknown node) instead of throwing', async () => {
gw = await startGateway({
[route('GET', '/nodes/!00000001')]: {
status: 404,
body: { success: false, error: 'node not found' },
},
});
await expect(makeClient(gw.baseUrl).getNode(1)).resolves.toBeNull();
});
it('re-throws non-404 errors (e.g. 500)', async () => {
gw = await startGateway({
[route('GET', '/nodes/!ee1978c8')]: {
status: 500,
body: { success: false, error: 'boom' },
},
});
await expect(
makeClient(gw.baseUrl).getNode(3994646728),
).rejects.toMatchObject({ name: 'MeshMonitorError', status: 500 });
});
});
// ---------------------------------------------------------------------------
// getChannels
// ---------------------------------------------------------------------------
describe('HttpMeshMonitorClient.getChannels', () => {
it('sorts by index and redacts the psk (marker when set, omitted when null)', async () => {
gw = await startGateway({
[route('GET', '/channels')]: {
body: {
success: true,
count: 3,
data: [
{
id: 0,
name: '',
displayName: 'Primary',
uplinkEnabled: true,
downlinkEnabled: false,
pskSet: true,
psk: 'AQ==',
},
{
id: 2,
name: 'testowy',
displayName: 'testowy',
uplinkEnabled: false,
downlinkEnabled: false,
pskSet: false,
psk: null,
},
{
id: 1,
name: 'LubMesh',
displayName: 'LubMesh',
uplinkEnabled: true,
downlinkEnabled: false,
pskSet: true,
psk: 'LQ==',
},
],
},
},
});
const ch = await makeClient(gw.baseUrl).getChannels();
expect(ch.map((c) => c.index)).toEqual([0, 1, 2]);
expect(ch).toEqual([
{
index: 0,
name: '',
psk: 'REDACTED',
uplinkEnabled: true,
downlinkEnabled: false,
},
{
index: 1,
name: 'LubMesh',
psk: 'REDACTED',
uplinkEnabled: true,
downlinkEnabled: false,
},
{
index: 2,
name: 'testowy',
uplinkEnabled: false,
downlinkEnabled: false,
},
]);
// the raw base64 key must never leak into the mapped output
expect(JSON.stringify(ch)).not.toContain('AQ==');
expect(JSON.stringify(ch)).not.toContain('LQ==');
});
});
// ---------------------------------------------------------------------------
// getTelemetry
// ---------------------------------------------------------------------------
describe('HttpMeshMonitorClient.getTelemetry', () => {
it('maps long-format rows to { fromNum, at(ms), kind, data }', async () => {
gw = await startGateway({
[route('GET', '/telemetry')]: {
body: {
success: true,
count: 3,
data: [
{
nodeNum: 3994646728,
telemetryType: 'airUtilTx',
timestamp: 1791834214540,
value: 0.1517,
unit: '%',
packetId: 3971503092,
channel: null,
gpsAccuracy: null,
rxSnr: null,
},
{
nodeNum: 4059237616,
telemetryType: 'altitude',
timestamp: 1791834193282,
value: 185,
unit: 'm',
packetId: 3637205748,
channel: 0,
gpsAccuracy: null,
rxSnr: null,
},
{
nodeNum: 1475656844,
telemetryType: 'linkQuality',
timestamp: 1791834161250,
value: 9,
unit: 'quality',
packetId: null,
channel: null,
gpsAccuracy: null,
rxSnr: null,
},
],
},
},
});
const t = await makeClient(gw.baseUrl).getTelemetry();
expect(t).toEqual([
{
fromNum: 3994646728,
at: 1791834214540,
kind: 'airUtilTx',
data: { value: 0.1517, unit: '%', packetId: 3971503092 },
},
{
fromNum: 4059237616,
at: 1791834193282,
kind: 'altitude',
data: { value: 185, unit: 'm', packetId: 3637205748, channel: 0 },
},
{
fromNum: 1475656844,
at: 1791834161250,
kind: 'linkQuality',
data: { value: 9, unit: 'quality' },
},
]);
});
});
// ---------------------------------------------------------------------------
// getMessages
// ---------------------------------------------------------------------------
describe('HttpMeshMonitorClient.getMessages', () => {
it('maps messages (packetId from the id string, ms timestamps), newest first', async () => {
gw = await startGateway({
[route('GET', '/messages')]: {
body: {
success: true,
count: 2,
data: [
{
id: 'src_3994646728_268035341',
fromNodeNum: 3994646728,
fromNodeId: '!ee1978c8',
toNodeNum: 4294967295,
toNodeId: 'broadcast',
text: 'hi ch3',
channel: 3,
portnum: 1,
requestId: 268035341,
timestamp: 1791832779000,
replyId: 1807244554,
rxSnr: null,
viaMqtt: null,
deliveryState: 'delivered',
},
{
id: 'src_2927200947_3264248582',
fromNodeNum: 2927200947,
fromNodeId: '!ae798ab3',
toNodeNum: 4294967295,
toNodeId: '!ffffffff',
text: 'weather',
channel: 1,
portnum: 1,
requestId: null,
timestamp: 1791828092137,
replyId: null,
rxSnr: -7.5,
viaMqtt: false,
deliveryState: null,
},
],
},
},
});
const msgs = await makeClient(gw.baseUrl).getMessages();
expect(msgs).toEqual([
{
packetId: 268035341,
fromNum: 3994646728,
fromId: '!ee1978c8',
toNum: 4294967295,
toId: 'broadcast',
text: 'hi ch3',
channel: 3,
portnum: 1,
requestId: 268035341,
replyId: 1807244554,
at: 1791832779000,
deliveryState: 'delivered',
},
{
packetId: 3264248582,
fromNum: 2927200947,
fromId: '!ae798ab3',
toNum: 4294967295,
toId: '!ffffffff',
text: 'weather',
channel: 1,
portnum: 1,
at: 1791828092137,
snr: -7.5,
},
]);
});
it('encodes the limit/channel/toNodeId/fromNodeId filters into the query', async () => {
gw = await startGateway({
[route('GET', '/messages')]: { body: { success: true, data: [] } },
});
await makeClient(gw.baseUrl).getMessages({
limit: 10,
channel: 3,
toNodeId: '!ffffffff',
fromNodeId: '!ae798ab3',
});
// URLSearchParams percent-encodes `!` as %21 — assert on the decoded values
const q = new URLSearchParams(gw.requests[0].query);
expect(q.get('limit')).toBe('10');
expect(q.get('channel')).toBe('3');
expect(q.get('toNodeId')).toBe('!ffffffff');
expect(q.get('fromNodeId')).toBe('!ae798ab3');
});
});
// ---------------------------------------------------------------------------
// send
// ---------------------------------------------------------------------------
describe('HttpMeshMonitorClient.send', () => {
it('sends a DM: POSTs { text, toNodeId, replyId } and returns the packet id', async () => {
gw = await startGateway({
[route('POST', '/messages')]: {
body: {
success: true,
data: { messageId: 'mid-1', requestId: 42, deliveryState: 'pending' },
},
},
});
const out = await makeClient(gw.baseUrl).send({
text: 'hello',
toNodeId: '!ae798ab3',
replyId: 7,
});
expect(out).toEqual({
packetId: 42,
deliveryState: 'pending',
messageId: 'mid-1',
});
const req = gw.requests[0];
expect(req.pathname).toBe(`${BASE}/sources/${SOURCE}/messages`);
expect(req.headers['content-type']).toBe('application/json');
expect(req.headers.authorization).toBe(`Bearer ${TOKEN}`);
expect(JSON.parse(req.body)).toEqual({
text: 'hello',
toNodeId: '!ae798ab3',
replyId: 7,
});
});
it('sends a broadcast: POSTs { text, channel } (no toNodeId)', async () => {
gw = await startGateway({
[route('POST', '/messages')]: {
body: {
success: true,
data: { messageId: 'mid-2', requestId: 99, deliveryState: 'pending' },
},
},
});
const out = await makeClient(gw.baseUrl).send({
text: 'hi all',
channel: 3,
});
expect(out.packetId).toBe(99);
const body = JSON.parse(gw.requests[0].body);
expect(body).toEqual({ text: 'hi all', channel: 3 });
expect(body).not.toHaveProperty('toNodeId');
expect(body).not.toHaveProperty('replyId');
});
it('omits replyId when not provided', async () => {
gw = await startGateway({
[route('POST', '/messages')]: {
body: {
success: true,
data: { requestId: 5, deliveryState: 'pending' },
},
},
});
await makeClient(gw.baseUrl).send({ text: 'x', toNodeId: '!ee1978c8' });
const body = JSON.parse(gw.requests[0].body);
expect(body).toEqual({ text: 'x', toNodeId: '!ee1978c8' });
expect(body).not.toHaveProperty('replyId');
});
it('defaults deliveryState to pending when the response omits it', async () => {
gw = await startGateway({
[route('POST', '/messages')]: {
body: { success: true, data: { requestId: 3 } },
},
});
const out = await makeClient(gw.baseUrl).send({ text: 'y', channel: 0 });
expect(out.deliveryState).toBe('pending');
expect(out.packetId).toBe(3);
expect(out.messageId).toBeUndefined();
});
it('rejects a send that sets BOTH channel and toNodeId (XOR) before any request', async () => {
gw = await startGateway({
[route('POST', '/messages')]: { body: { success: true } },
});
await expect(
makeClient(gw.baseUrl).send({ text: 'x', channel: 3, toNodeId: '!1' }),
).rejects.toThrow(/mutually exclusive/);
expect(gw.requests).toHaveLength(0);
});
it('rejects a send with NEITHER channel nor toNodeId (XOR) before any request', async () => {
gw = await startGateway({
[route('POST', '/messages')]: { body: { success: true } },
});
await expect(makeClient(gw.baseUrl).send({ text: 'x' })).rejects.toThrow(
/required \(XOR\)/,
);
expect(gw.requests).toHaveLength(0);
});
it('rejects an empty text before any request', async () => {
gw = await startGateway({
[route('POST', '/messages')]: { body: { success: true } },
});
await expect(
makeClient(gw.baseUrl).send({ text: '', channel: 3 }),
).rejects.toThrow(/non-empty/);
expect(gw.requests).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Error handling (non-2xx → MeshMonitorError with the HTTP status)
// ---------------------------------------------------------------------------
describe('HttpMeshMonitorClient error handling', () => {
for (const status of [401, 413, 429, 503]) {
it(`maps HTTP ${status} to a MeshMonitorError carrying the status`, async () => {
gw = await startGateway({
[route('GET', '/status')]: {
status,
body: { success: false, error: `code ${status}` },
},
});
const err = await makeClient(gw.baseUrl)
.getStatus()
.catch((e) => e);
expect(err).toBeInstanceOf(MeshMonitorError);
expect(err.status).toBe(status);
expect(err.message).toContain(`HTTP ${status}`);
});
}
it('truncates a long error body to 300 chars', async () => {
const longMsg = 'x'.repeat(500);
gw = await startGateway({
[route('GET', '/status')]: {
status: 500,
body: { success: false, error: longMsg },
},
});
const err = await makeClient(gw.baseUrl)
.getStatus()
.catch((e) => e);
expect(err).toBeInstanceOf(MeshMonitorError);
expect(err.status).toBe(500);
expect((err.body ?? '').length).toBe(300);
});
});
// ---------------------------------------------------------------------------
// createMeshMonitorClientFromEnv
// ---------------------------------------------------------------------------
describe('createMeshMonitorClientFromEnv', () => {
it('returns null when no token is configured', () => {
delete process.env.MESHMONITOR_API_TOKEN;
expect(createMeshMonitorClientFromEnv()).toBeNull();
});
it('builds an HttpMeshMonitorClient from env with default base + source', () => {
process.env.MESHMONITOR_API_TOKEN = TOKEN;
const c = createMeshMonitorClientFromEnv();
expect(c).toBeInstanceOf(HttpMeshMonitorClient);
});
it('honours MESHMONITOR_API_BASE and MESHMONITOR_SOURCE overrides without throwing', () => {
process.env.MESHMONITOR_API_TOKEN = TOKEN;
process.env.MESHMONITOR_API_BASE = 'http://example.org/base/';
process.env.MESHMONITOR_SOURCE = 'custom-src';
expect(createMeshMonitorClientFromEnv()).toBeInstanceOf(
HttpMeshMonitorClient,
);
});
});
+547
View File
@@ -0,0 +1,547 @@
/**
* MeshMonitor HTTP gateway client — a stateless REST replacement for the raw
* Meshtastic protobuf-over-TCP bridge. Every request just carries
* `Authorization: Bearer <token>` (no session/handshake/subscribe).
*
* The OpenAPI spec served by the gateway is OUTDATED — the live JSON shapes
* confirmed by curl (see the notes below and the spec in this module's tests)
* differ from it. Trust the mappers here:
* - canonical resource paths are source-scoped: `/sources/{sourceId}/…`
* (the legacy root `/channels` and `/telemetry` 404 on this deployment)
* - node position is FLAT: `latitude`/`longitude`/`altitude` (no nested `position{}`)
* - node `lastHeard`/`firstHeard` are EPOCH SECONDS; `createdAt`/`positionTimestamp` are ms
* - message `timestamp`/`rxTime`/`createdAt` are EPOCH MILLISECONDS
* - telemetry is LONG-format: one row per metric with `telemetryType`/`value`/`unit`
* - a message's packet id is the 3rd segment of its `id` string
* (`sourceId_fromNodeNum_packetId`) and equals `requestId` for outbound
*/
export const BROADCAST_ADDR = 0xffffffff;
/** uint32 node number → Meshtastic `!hex` node id (3994646728 → `!ee1978c8`). */
export function nodeNumToId(num: number): string {
return '!' + (num >>> 0).toString(16).padStart(8, '0');
}
/** `!hex` node id → uint32 node number (the inverse of {@link nodeNumToId}). */
export function nodeIdToNum(id: string): number | null {
const m = /^!([0-9a-fA-F]{1,8})$/.exec(id.trim());
if (!m) return null;
return parseInt(m[1], 16) >>> 0;
}
/**
* Shared send-target validation: the gateway requires EXACTLY ONE of
* `channel` (broadcast to a channel) or `toNodeId` (direct message) — an
* XOR. Enforced here so both the HTTP client and the test fake fail
* identically on a malformed send.
*/
export function assertSendTarget(msg: {
text: string;
toNodeId?: string;
channel?: number;
}): void {
if (typeof msg.text !== 'string' || msg.text.length === 0)
throw new Error('text must be a non-empty string');
const hasChannel = msg.channel != null;
const hasTo = msg.toNodeId != null;
if (hasChannel && hasTo)
throw new Error('channel and toNodeId are mutually exclusive (XOR)');
if (!hasChannel && !hasTo)
throw new Error('one of channel or toNodeId is required (XOR)');
}
// ---------------------------------------------------------------------------
// Typed shapes the downstream MeshService consumes (gateway JSON → these)
// ---------------------------------------------------------------------------
/** The source's health / identity. */
export interface MMStatus {
/** Whether the physical device behind the source is currently connected. */
connected: boolean;
/** The physical node number the source relays through (the bot's "my" node). */
myNodeNum: number;
longName?: string;
nodeResponsive?: boolean;
}
/** One mesh node as the gateway reports it. */
export interface MMNode {
/** uint32 node number. */
num: number;
longName?: string;
shortName?: string;
/** meshtastic.HardwareModel enum VALUE (int) — the service maps it to a name. */
hwModel?: number;
/** 0-100 = battery percent (device convention: >100 = mains powered). */
batteryLevel?: number;
voltage?: number;
firmwareVersion?: string;
rssi?: number;
snr?: number;
/** EPOCH SECONDS of the last contact — same unit as the existing NodeSummary.lastHeard. Omitted when unknown/never heard. */
lastHeard?: number;
/** Only present when both coordinates are known. */
position?: { lat: number; lon: number; alt?: number };
hopsAway?: number;
/** True when the node info arrived via MQTT rather than LoRa. */
viaMqtt?: boolean;
}
/** A configured channel; the psk is ALWAYS redacted. */
export interface MMChannel {
index: number;
name: string;
/** Always the redaction marker — the raw key never leaves the client. */
psk?: string;
uplinkEnabled?: boolean;
downlinkEnabled?: boolean;
}
/** A telemetry reading (one per metric, from the gateway's long-format feed). */
export interface MMTelemetry {
fromNum: number;
/** EPOCH MILLISECONDS. */
at: number;
/** The metric name (e.g. "airUtilTx", "altitude", "batteryLevel"). */
kind: string;
/** The metric payload: the row's value + unit (plus other non-null fields). */
data: object;
}
/** A mesh message (inbound or outbound), newest first. */
export interface MMMessage {
/** uint32 packet id — the 3rd segment of the gateway `id` (== `requestId` for outbound). */
packetId: number;
fromNum?: number;
fromId?: string;
toNum?: number;
toId?: string;
text: string;
/** Channel index; -1 = direct message. */
channel: number;
portnum?: number;
/** Outbound only: the packet id used to match delivery ACKs. */
requestId?: number;
/** The packet id this message quotes / replies to (threaded quote). */
replyId?: number;
/** EPOCH MILLISECONDS. */
at: number;
snr?: number;
viaMqtt?: boolean;
deliveryState?: 'pending' | 'delivered' | 'confirmed' | 'failed' | null;
}
/**
* The transport the MeshService talks to. Phase 2 wires the real
* {@link HttpMeshMonitorClient} (or the test {@link FakeMeshMonitorClient})
* into the service; this interface is the stable contract between them.
*/
export interface MeshMonitorClient {
/** Source health / identity (`GET /sources/{id}/status`). */
getStatus(): Promise<MMStatus>;
/** All known nodes (`GET /sources/{id}/nodes`). */
getNodes(): Promise<MMNode[]>;
/** One node by node number, numeric string, or `!hex` id; null when missing. */
getNode(nodeIdOrNum: string | number): Promise<MMNode | null>;
/** Configured channels, psk redacted (`GET /sources/{id}/channels`). */
getChannels(): Promise<MMChannel[]>;
/** Telemetry readings (`GET /sources/{id}/telemetry`). */
getTelemetry(): Promise<MMTelemetry[]>;
/**
* Recent messages, newest first (`GET /sources/{id}/messages`). `since` is
* ignored by this deployment — callers dedupe by `packetId`/`id`.
*/
getMessages(opts?: {
limit?: number;
channel?: number;
toNodeId?: string;
fromNodeId?: string;
}): Promise<MMMessage[]>;
/**
* Send a text message (`POST /sources/{id}/messages`). Exactly one of
* `toNodeId` (DM) or `channel` (broadcast) is required (XOR); `replyId`
* quotes an earlier packet. Returns the assigned packet id, delivery state
* and the gateway's message id.
*/
send(msg: {
text: string;
toNodeId?: string;
channel?: number;
replyId?: number;
}): Promise<{ packetId: number; deliveryState: string; messageId?: string }>;
}
// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
/** A non-2xx response from the gateway. `status` is the HTTP status; `body` the (truncated) server payload. */
export class MeshMonitorError extends Error {
constructor(
message: string,
readonly status?: number,
readonly body?: string,
) {
super(message);
this.name = 'MeshMonitorError';
}
}
// ---------------------------------------------------------------------------
// Raw gateway JSON shapes (confirmed live — the spec is outdated)
// ---------------------------------------------------------------------------
interface RawStatus {
localNodeNum?: number;
localNodeId?: string;
longName?: string | null;
shortName?: string | null;
connected?: boolean;
nodeResponsive?: boolean;
}
interface RawNode {
nodeNum?: number;
nodeId?: string;
longName?: string | null;
shortName?: string | null;
hwModel?: number | null;
role?: number | null;
batteryLevel?: number | null;
voltage?: number | null;
firmwareVersion?: string | null;
rssi?: number | null;
snr?: number | null;
/** EPOCH SECONDS (not ms). */
lastHeard?: number | null;
latitude?: number | null;
longitude?: number | null;
altitude?: number | null;
hopsAway?: number | null;
viaMqtt?: boolean | null;
}
interface RawChannel {
id?: number;
name?: string | null;
displayName?: string | null;
role?: number | null;
uplinkEnabled?: boolean | null;
downlinkEnabled?: boolean | null;
psk?: string | null;
pskSet?: boolean | null;
}
interface RawTelemetry {
nodeNum?: number | null;
nodeId?: string | null;
telemetryType?: string | null;
/** EPOCH MILLISECONDS. */
timestamp?: number | null;
value?: unknown;
unit?: string | null;
packetId?: number | null;
channel?: number | null;
gpsAccuracy?: number | null;
rxSnr?: number | null;
}
interface RawMessage {
id?: string | null;
fromNodeNum?: number | null;
fromNodeId?: string | null;
toNodeNum?: number | null;
toNodeId?: string | null;
text?: string | null;
channel?: number | null;
portnum?: number | null;
requestId?: number | null;
replyId?: number | null;
/** EPOCH MILLISECONDS. */
timestamp?: number | null;
rxSnr?: number | null;
viaMqtt?: boolean | null;
deliveryState?: string | null;
}
// ---------------------------------------------------------------------------
// Mappers (raw JSON → typed shapes)
// ---------------------------------------------------------------------------
function mapStatus(d: RawStatus): MMStatus {
const out: MMStatus = {
connected: d.connected ?? false,
myNodeNum: d.localNodeNum ?? 0,
};
if (d.longName != null) out.longName = d.longName;
if (d.nodeResponsive != null) out.nodeResponsive = d.nodeResponsive;
return out;
}
function mapNode(n: RawNode): MMNode {
const out: MMNode = { num: n.nodeNum ?? 0 };
if (n.longName) out.longName = n.longName;
if (n.shortName) out.shortName = n.shortName;
if (n.hwModel != null) out.hwModel = n.hwModel;
if (n.batteryLevel != null) out.batteryLevel = n.batteryLevel;
if (n.voltage != null) out.voltage = n.voltage;
if (n.firmwareVersion) out.firmwareVersion = n.firmwareVersion;
if (n.rssi != null) out.rssi = n.rssi;
if (n.snr != null) out.snr = n.snr;
// gateway lastHeard is epoch SECONDS and 0 = never heard — keep the unit, drop zero
if (n.lastHeard != null && n.lastHeard > 0) out.lastHeard = n.lastHeard;
if (n.hopsAway != null) out.hopsAway = n.hopsAway;
if (n.viaMqtt === true) out.viaMqtt = true;
// flat coordinates (NOT a nested position{} object) — present only when both are known
if (n.latitude != null && n.longitude != null) {
out.position = { lat: n.latitude, lon: n.longitude };
if (n.altitude != null) out.position.alt = n.altitude;
}
return out;
}
function mapChannel(c: RawChannel): MMChannel {
const out: MMChannel = {
index: c.id ?? 0,
// the gateway's configured name ("" for the unconfigured primary); it also
// exposes a human-readable `displayName` (e.g. "Primary") if ever needed
name: c.name ?? '',
};
// the psk (base64 key) is never exposed — replaced with a marker when set
if (c.psk != null) out.psk = 'REDACTED';
if (c.uplinkEnabled != null) out.uplinkEnabled = c.uplinkEnabled;
if (c.downlinkEnabled != null) out.downlinkEnabled = c.downlinkEnabled;
return out;
}
function mapTelemetry(t: RawTelemetry): MMTelemetry {
const data: Record<string, unknown> = {};
if (t.value !== undefined && t.value !== null) data.value = t.value;
if (t.unit != null) data.unit = t.unit;
if (t.packetId != null) data.packetId = t.packetId;
if (t.channel != null) data.channel = t.channel;
if (t.gpsAccuracy != null) data.gpsAccuracy = t.gpsAccuracy;
if (t.rxSnr != null) data.rxSnr = t.rxSnr;
return {
fromNum: t.nodeNum ?? 0,
at: t.timestamp ?? 0, // epoch ms
kind: t.telemetryType ?? '',
data,
};
}
/** Packet id = 3rd segment of the gateway `id` (`sourceId_fromNodeNum_packetId`). */
function parsePacketId(id?: string | null): number {
if (!id) return 0;
const last = id.split('_').pop() ?? '';
const n = Number(last);
return Number.isFinite(n) ? n : 0;
}
function mapMessage(m: RawMessage): MMMessage {
const out: MMMessage = {
packetId: parsePacketId(m.id),
text: m.text ?? '',
channel: m.channel ?? 0,
at: m.timestamp ?? 0, // epoch ms
};
if (m.fromNodeNum != null) out.fromNum = m.fromNodeNum;
if (m.fromNodeId != null) out.fromId = m.fromNodeId;
if (m.toNodeNum != null) out.toNum = m.toNodeNum;
if (m.toNodeId != null) out.toId = m.toNodeId;
if (m.portnum != null) out.portnum = m.portnum;
if (m.requestId != null) out.requestId = m.requestId;
if (m.replyId != null) out.replyId = m.replyId;
if (m.rxSnr != null) out.snr = m.rxSnr;
if (m.viaMqtt === true) out.viaMqtt = true;
if (m.deliveryState != null)
out.deliveryState = m.deliveryState as MMMessage['deliveryState'];
return out;
}
// ---------------------------------------------------------------------------
// HTTP implementation
// ---------------------------------------------------------------------------
export interface HttpMeshMonitorClientOptions {
/** Base URL of the gateway API, e.g. `https://meshmonitor.kkhost.pl/api/v1`. */
baseUrl: string;
/** Source id (a UUID, or the literal `default` alias). */
sourceId: string;
/** Bearer token (`mm_v1_…`). */
token: string;
/** Per-request timeout in ms (default 15000). */
timeoutMs?: number;
/** `GET /nodes?active=` (default true). */
nodeActive?: boolean;
/** `GET /nodes?sinceDays=` (default 7). */
nodeSinceDays?: number;
}
/**
* Native-`fetch` MeshMonitor client. Stateless — every request carries the
* Bearer token; there is no session to establish or keep alive.
*/
export class HttpMeshMonitorClient implements MeshMonitorClient {
private readonly baseUrl: string;
private readonly sourceId: string;
private readonly token: string;
private readonly timeoutMs: number;
private readonly nodeActive: boolean;
private readonly nodeSinceDays: number;
constructor(opts: HttpMeshMonitorClientOptions) {
this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
this.sourceId = opts.sourceId;
this.token = opts.token;
this.timeoutMs = opts.timeoutMs ?? 15000;
this.nodeActive = opts.nodeActive ?? true;
this.nodeSinceDays = opts.nodeSinceDays ?? 7;
}
/** Issue a request and parse the JSON envelope; throw {@link MeshMonitorError} on non-2xx. */
private async request<T>(path: string, init?: RequestInit): Promise<T> {
const url = `${this.baseUrl}${path}`;
const res = await fetch(url, {
...init,
headers: {
...(init?.headers as Record<string, string> | undefined),
authorization: `Bearer ${this.token}`,
},
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!res.ok) {
const body = (await res.text().catch(() => '')).slice(0, 300);
throw new MeshMonitorError(
`MeshMonitor ${init?.method ?? 'GET'} ${path} failed: HTTP ${res.status}${body ? ` ${body}` : ''}`,
res.status,
body,
);
}
return (await res.json()) as T;
}
async getStatus(): Promise<MMStatus> {
const body = await this.request<{ data?: RawStatus }>(
`/sources/${this.sourceId}/status`,
);
return mapStatus(body.data ?? {});
}
async getNodes(): Promise<MMNode[]> {
const q = new URLSearchParams();
if (this.nodeActive) q.set('active', 'true');
if (this.nodeSinceDays != null)
q.set('sinceDays', String(this.nodeSinceDays));
const qs = q.toString();
const body = await this.request<{ data?: RawNode[] }>(
`/sources/${this.sourceId}/nodes${qs ? `?${qs}` : ''}`,
);
return (body.data ?? []).map(mapNode);
}
async getNode(nodeIdOrNum: string | number): Promise<MMNode | null> {
const id =
typeof nodeIdOrNum === 'number' ? nodeNumToId(nodeIdOrNum) : nodeIdOrNum;
try {
const body = await this.request<{ data?: RawNode | null }>(
`/sources/${this.sourceId}/nodes/${encodeURIComponent(id.trim())}`,
);
return body.data ? mapNode(body.data) : null;
} catch (err) {
// an unknown node is a 404 — surface it as "no such node", not an error
if (err instanceof MeshMonitorError && err.status === 404) return null;
throw err;
}
}
async getChannels(): Promise<MMChannel[]> {
const body = await this.request<{ data?: RawChannel[] }>(
`/sources/${this.sourceId}/channels`,
);
return (body.data ?? []).map(mapChannel).sort((a, b) => a.index - b.index);
}
async getTelemetry(): Promise<MMTelemetry[]> {
const body = await this.request<{ data?: RawTelemetry[] }>(
`/sources/${this.sourceId}/telemetry`,
);
return (body.data ?? []).map(mapTelemetry);
}
async getMessages(opts?: {
limit?: number;
channel?: number;
toNodeId?: string;
fromNodeId?: string;
}): Promise<MMMessage[]> {
const q = new URLSearchParams();
if (opts?.limit != null) q.set('limit', String(opts.limit));
if (opts?.channel != null) q.set('channel', String(opts.channel));
if (opts?.toNodeId != null) q.set('toNodeId', opts.toNodeId);
if (opts?.fromNodeId != null) q.set('fromNodeId', opts.fromNodeId);
const qs = q.toString();
const body = await this.request<{ data?: RawMessage[] }>(
`/sources/${this.sourceId}/messages${qs ? `?${qs}` : ''}`,
);
// the gateway returns newest first — keep that order
return (body.data ?? []).map(mapMessage);
}
async send(msg: {
text: string;
toNodeId?: string;
channel?: number;
replyId?: number;
}): Promise<{ packetId: number; deliveryState: string; messageId?: string }> {
assertSendTarget(msg);
const body: Record<string, unknown> = { text: msg.text };
if (msg.channel != null) body.channel = msg.channel;
if (msg.toNodeId != null) body.toNodeId = msg.toNodeId;
if (msg.replyId != null) body.replyId = msg.replyId;
const res = await this.request<{
data?: {
messageId?: string;
requestId?: number;
deliveryState?: string;
};
}>(`/sources/${this.sourceId}/messages`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const d = res.data ?? {};
const out: {
packetId: number;
deliveryState: string;
messageId?: string;
} = {
// 201 carries the packet id as requestId; a 202 (auto-split/queued) may omit it → 0
packetId: d.requestId ?? 0,
deliveryState: d.deliveryState ?? 'pending',
};
if (d.messageId != null) out.messageId = d.messageId;
return out;
}
}
/**
* Build the real client from the environment, or `null` when
* `MESHMONITOR_API_TOKEN` is unset (the caller then keeps the TCP bridge).
* Used by Phase 2 to hand MeshService a client instance.
*/
export function createMeshMonitorClientFromEnv(): MeshMonitorClient | null {
const token = process.env.MESHMONITOR_API_TOKEN;
if (!token) return null;
return new HttpMeshMonitorClient({
baseUrl:
process.env.MESHMONITOR_API_BASE ??
'https://meshmonitor.kkhost.pl/api/v1',
sourceId: process.env.MESHMONITOR_SOURCE ?? 'default',
token,
timeoutMs: parseInt(process.env.MESHMONITOR_TIMEOUT_MS ?? '15000', 10),
});
}
@@ -0,0 +1,33 @@
import { Module } from '@nestjs/common';
import {
createMeshMonitorClientFromEnv,
type MeshMonitorClient,
} from './mesh-monitor-client.js';
/**
* DI token for the MeshMonitor client. Phase 2 imports {@link MeshMonitorModule}
* into `MeshModule` and injects this token to decide between the HTTP gateway
* (token set → a real {@link MeshMonitorClient}) and the legacy TCP bridge
* (token unset → `null`).
*/
export const MESH_MONITOR_CLIENT = 'MESH_MONITOR_CLIENT';
/**
* Thin provider around {@link createMeshMonitorClientFromEnv()}. It builds the
* real `HttpMeshMonitorClient` from the environment
* (`MESHMONITOR_API_TOKEN`, `MESHMONITOR_API_BASE`, `MESHMONITOR_SOURCE`,
* `MESHMONITOR_TIMEOUT_MS`) or `null` when no token is configured — the caller
* then keeps the existing TCP bridge. Not imported anywhere yet: Phase 2 wires
* it into `MeshModule`.
*/
@Module({
providers: [
{
provide: MESH_MONITOR_CLIENT,
useFactory: (): MeshMonitorClient | null =>
createMeshMonitorClientFromEnv(),
},
],
exports: [MESH_MONITOR_CLIENT],
})
export class MeshMonitorModule {}
+43 -25
View File
@@ -72,7 +72,7 @@ describe('OpencodeService.notify', () => {
expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2);
const [url, init] = fetchMock.mock.calls[1]; const [url, init] = fetchMock.mock.calls[1];
expect(url).toBe(`${API}/proxy/abc/session/ses_pinned/message`); expect(url).toBe(`${API}/proxy/abc/api/session/ses_pinned/prompt`);
const opts = init as { const opts = init as {
method?: string; method?: string;
headers?: Record<string, string>; headers?: Record<string, string>;
@@ -83,15 +83,10 @@ describe('OpencodeService.notify', () => {
expect(opts.headers?.authorization).toBe( expect(opts.headers?.authorization).toBe(
'Basic ' + Buffer.from('u:p').toString('base64'), 'Basic ' + Buffer.from('u:p').toString('base64'),
); );
// body is EXACTLY {"parts":[{"type":"text","text":msg}]} — em-dash, not a hyphen // body is EXACTLY {"text": msg} — em-dash, not a hyphen — and a 2xx resolves notify as delivered
expect(String(opts.body)).toBe( expect(String(opts.body)).toBe(
JSON.stringify({ JSON.stringify({
parts: [
{
type: 'text',
text: 'New mesh message (id 7) from node 42: "hi" — please react to it.', text: 'New mesh message (id 7) from node 42: "hi" — please react to it.',
},
],
}), }),
); );
}); });
@@ -111,12 +106,7 @@ describe('OpencodeService.notify', () => {
const [, init] = fetchMock.mock.calls[1]; const [, init] = fetchMock.mock.calls[1];
expect(String((init as { body?: string }).body)).toBe( expect(String((init as { body?: string }).body)).toBe(
JSON.stringify({ JSON.stringify({
parts: [
{
type: 'text',
text: 'New mesh message (id 7): "hi" — please react to it.', text: 'New mesh message (id 7): "hi" — please react to it.',
},
],
}), }),
); );
}); });
@@ -149,7 +139,7 @@ describe('OpencodeService.notify', () => {
await svc.notify(7, 'hi'); await svc.notify(7, 'hi');
expect(fetchMock.mock.calls[1][0]).toBe( expect(fetchMock.mock.calls[1][0]).toBe(
`${API}/proxy/abc/session/ses_pinned/message`, `${API}/proxy/abc/api/session/ses_pinned/prompt`,
); );
}); });
@@ -182,7 +172,7 @@ describe('OpencodeService.notify', () => {
await svc.notify(7, 'hi'); await svc.notify(7, 'hi');
expect(fetchMock.mock.calls[1][0]).toBe( expect(fetchMock.mock.calls[1][0]).toBe(
`${API}/proxy/b/session/ses_pinned/message`, `${API}/proxy/b/api/session/ses_pinned/prompt`,
); );
}); });
@@ -191,18 +181,37 @@ describe('OpencodeService.notify', () => {
fetchMock fetchMock
.mockResolvedValueOnce(json([WORKSPACE])) .mockResolvedValueOnce(json([WORKSPACE]))
.mockResolvedValueOnce( .mockResolvedValueOnce(
json([ // v2 envelope {data, cursor}; the /elsewhere session is more recent but a different
{ id: 'ses_old', time: { updated: 100 } }, // directory, so the location.directory filter must exclude it
{ id: 'ses_new', time: { updated: 200 } }, json({
]), data: [
{
id: 'ses_old',
location: { directory: WS_PATH },
time: { updated: 100 },
},
{
id: 'ses_other_ws',
location: { directory: '/elsewhere' },
time: { updated: 300 },
},
{
id: 'ses_new',
location: { directory: WS_PATH },
time: { updated: 200 },
},
],
cursor: { previous: null, next: null },
}),
) )
.mockResolvedValueOnce(json({})); .mockResolvedValueOnce(json({}));
await svc.notify(7, 'hi'); await svc.notify(7, 'hi');
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/session`); // discovery GET hits the v2 route; the prompt POST goes to the most recently updated session of THIS directory
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/api/session`);
expect(fetchMock.mock.calls[2][0]).toBe( expect(fetchMock.mock.calls[2][0]).toBe(
`${API}/proxy/abc/session/ses_new/message`, `${API}/proxy/abc/api/session/ses_new/prompt`,
); );
}); });
@@ -210,13 +219,22 @@ describe('OpencodeService.notify', () => {
const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' }); const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' });
fetchMock fetchMock
.mockResolvedValueOnce(json([WORKSPACE])) .mockResolvedValueOnce(json([WORKSPACE]))
.mockResolvedValueOnce(json([{ id: 'ses_a' }, { id: 'ses_b', time: {} }])) .mockResolvedValueOnce(
json({
data: [
{ id: 'ses_a', location: { directory: WS_PATH } },
{ id: 'ses_b', location: { directory: WS_PATH }, time: {} },
],
cursor: { previous: null, next: null },
}),
)
.mockResolvedValueOnce(json({})); .mockResolvedValueOnce(json({}));
await svc.notify(7, 'hi'); await svc.notify(7, 'hi');
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/api/session`);
expect(fetchMock.mock.calls[2][0]).toBe( expect(fetchMock.mock.calls[2][0]).toBe(
`${API}/proxy/abc/session/ses_a/message`, `${API}/proxy/abc/api/session/ses_a/prompt`,
); );
}); });
@@ -232,11 +250,11 @@ describe('OpencodeService.notify', () => {
await svc.notify(7, 'hi'); await svc.notify(7, 'hi');
// workspaces GET + message POST only — no /session discovery GET // workspaces GET + prompt POST only — no /api/session discovery GET
expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][0]).toBe(`${API}/api/workspaces`); expect(fetchMock.mock.calls[0][0]).toBe(`${API}/api/workspaces`);
expect(fetchMock.mock.calls[1][0]).toBe( expect(fetchMock.mock.calls[1][0]).toBe(
`${API}/proxy/abc/session/ses_pinned/message`, `${API}/proxy/abc/api/session/ses_pinned/prompt`,
); );
}); });
@@ -248,7 +266,7 @@ describe('OpencodeService.notify', () => {
}); });
fetchMock fetchMock
.mockResolvedValueOnce(json([WORKSPACE])) .mockResolvedValueOnce(json([WORKSPACE]))
.mockRejectedValueOnce(timeoutError()); // the message POST times out — opencode holds the response until the turn finishes .mockRejectedValueOnce(timeoutError()); // the prompt POST times out — a read timeout is still treated as delivered (safety net)
const logSpy = vi.spyOn(Logger.prototype, 'log'); const logSpy = vi.spyOn(Logger.prototype, 'log');
await expect(svc.notify('hi')).resolves.toBeUndefined(); await expect(svc.notify('hi')).resolves.toBeUndefined();
+24 -13
View File
@@ -2,7 +2,8 @@ import { Injectable, Logger } from '@nestjs/common';
// ponytail: hardcoded backoff [2s, 10s, 30s] (~45 s window) — no env knob; tune here if outages run longer // 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]; 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 // opencode v2 returns 2xx immediately (it no longer holds the response until the agent turn finishes);
// the 15 s timeout only guards a stuck connection — a read timeout is still treated as delivered
const TIMEOUT_MS = 15_000; const TIMEOUT_MS = 15_000;
/** Entry of GET {api}/api/workspaces (only the fields we use). */ /** Entry of GET {api}/api/workspaces (only the fields we use). */
@@ -14,9 +15,11 @@ interface WorkspaceEntry {
proxyPath?: string; proxyPath?: string;
} }
/** Entry of GET {base}/session (only the fields we use). */ /** Entry of GET {base}/api/session (v2; only the fields we use — directory lives under location, time is epoch ms). */
interface SessionEntry { interface SessionEntry {
id: string; id: string;
title?: string;
location?: { directory?: string };
time?: { updated?: number }; time?: { updated?: number };
} }
@@ -43,11 +46,11 @@ export class OpencodeService {
} }
/** /**
* Push an rx mesh message into the opencode session as a text part. Resolves when delivered — * Push an rx mesh message into the opencode session (v2: POST {base}/api/session/{id}/prompt with a
* including on a final-POST read timeout, which means DELIVERED (opencode holds the response until * `{text}` body). Resolves when delivered — including on a final-POST read timeout, which still means
* the agent turn finishes) and is never retried. Workspace/session resolution failures are retried * DELIVERED (kept as a harmless safety net; v2 normally returns 2xx immediately) and is never retried.
* up to RETRY_DELAYS_MS.length + 1 attempts total; the last failure logs `opencode notify dropped * Workspace/session resolution failures are retried up to RETRY_DELAYS_MS.length + 1 attempts total;
* after N attempts`. No-op when disabled (no credentials). * the last failure logs `opencode notify dropped after N attempts`. No-op when disabled (no credentials).
*/ */
async notify(id: number, text: string, from?: number): Promise<void> { async notify(id: number, text: string, from?: number): Promise<void> {
if (!this.enabled) return; if (!this.enabled) return;
@@ -112,12 +115,19 @@ export class OpencodeService {
const base = this.api + (ws.proxyPath ?? ''); const base = this.api + (ws.proxyPath ?? '');
let sid = this.sessionOverride; let sid = this.sessionOverride;
if (!sid) { if (!sid) {
const sessions = await this.getJson<SessionEntry[]>(`${base}/session`); // v2 discovery: GET {base}/api/session returns an envelope { data, cursor } — NOT a bare array
const envelope = await this.getJson<{ data?: SessionEntry[] }>(
`${base}/api/session`,
);
// only sessions of THIS workspace directory — the instance proxy may serve other sessions too
const sessions = (envelope.data ?? []).filter(
(s) => s.location?.directory === this.workspacePath,
);
if (sessions.length === 0) if (sessions.length === 0)
throw new Error( throw new Error(
`no sessions in workspace ${ws.id} (${this.workspacePath})`, `no sessions in workspace ${ws.id} (${this.workspacePath})`,
); );
// most recently updated session; first entry when none carry time.updated // most recently updated session (epoch ms); first entry when none carry time.updated
const updated = (s: SessionEntry): number => s.time?.updated ?? 0; const updated = (s: SessionEntry): number => s.time?.updated ?? 0;
sid = sessions.some((s) => updated(s)) sid = sessions.some((s) => updated(s))
? sessions.reduce((a, b) => (updated(b) > updated(a) ? b : a)).id ? sessions.reduce((a, b) => (updated(b) > updated(a) ? b : a)).id
@@ -148,16 +158,17 @@ export class OpencodeService {
msg: string, msg: string,
): Promise<void> { ): Promise<void> {
try { try {
const res = await fetch(`${base}/session/${sid}/message`, { const res = await fetch(`${base}/api/session/${sid}/prompt`, {
method: 'POST', method: 'POST',
headers: { ...this.authHeaders(), 'content-type': 'application/json' }, headers: { ...this.authHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ parts: [{ type: 'text', text: msg }] }), body: JSON.stringify({ text: msg }),
signal: AbortSignal.timeout(TIMEOUT_MS), signal: AbortSignal.timeout(TIMEOUT_MS),
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); // HTTP errors retry like network failures if (!res.ok) throw new Error(`HTTP ${res.status}`); // HTTP errors retry like network failures
} catch (err) { } catch (err) {
// a read timeout here means DELIVERED, not failed — opencode accepted the message and is holding // a read timeout here means DELIVERED, not failed — kept as a harmless safety net: v2 normally
// the response until the agent turn finishes; never retried // returns 2xx immediately, but if the response is still pending past the timeout we treat the
// message as accepted and never retry
if ((err as Error).name === 'TimeoutError') { if ((err as Error).name === 'TimeoutError') {
this.logger.log( this.logger.log(
`opencode notify accepted (response still pending after ${TIMEOUT_MS / 1000} s)`, `opencode notify accepted (response still pending after ${TIMEOUT_MS / 1000} s)`,
+28
View File
@@ -0,0 +1,28 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const srcDir = fileURLToPath(new URL('.', import.meta.url));
function moduleFiles(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
entry.isDirectory()
? moduleFiles(join(dir, entry.name))
: entry.name.endsWith('.module.ts')
? [join(dir, entry.name)]
: [],
);
}
describe('ScheduleModule.forRoot single registration', () => {
// Regression guard (v0.21.7): each forRoot() call creates its own ScheduleExplorer that scans all providers, so a second call made every @Cron/@Interval job fire twice.
it('appears exactly once across all src/**/*.module.ts', () => {
const hits = moduleFiles(srcDir).flatMap((file) =>
(
readFileSync(file, 'utf8').match(/ScheduleModule\.forRoot\(/g) ?? []
).map(() => file.slice(srcDir.length)),
);
expect(hits).toHaveLength(1);
});
});