Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0571c7095e | ||
|
|
ba5e60daf0 | ||
|
|
4beaa377c4 | ||
|
|
b31fb35464 | ||
|
|
108aa400d3 | ||
|
|
f640f6658c | ||
|
|
57ad27d66f | ||
|
|
5292303786 | ||
|
|
adfbc9800b | ||
|
|
3062f40899 | ||
|
|
266aadf635 | ||
|
|
22a209e654 |
@@ -0,0 +1,69 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
name: build + unit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# The runner provides a working docker CLI out of the box (full-dind) — no endpoint
|
||||||
|
# resolution needed, same as flight's CI.
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
# Job-level dep cache via the runner's built-in cache server. continue-on-error: an
|
||||||
|
# unreachable/broken cache server must never break the run (flight convention). npm ci
|
||||||
|
# wipes node_modules, so cache npm's own tarball store instead — restoring node_modules
|
||||||
|
# would just be deleted again. No explicit save step: caches marked by the restore step
|
||||||
|
# are uploaded automatically at job end.
|
||||||
|
- name: Restore deps cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
path: $HOME/.npm
|
||||||
|
key: ${{ runner.os }}-deps-npm-${{ hashFiles('package-lock.json') }}
|
||||||
|
|
||||||
|
# Retried: the runner's outbound network/DNS is flaky (transient EAI_AGAIN seen in CI).
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
set -u
|
||||||
|
ok=0
|
||||||
|
for i in 1 2 3; do
|
||||||
|
if npm ci; then ok=1; break; fi
|
||||||
|
echo "::warning::dependency install attempt $i/3 failed (likely transient network) — retrying in 5s" >&2
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
[ "$ok" = "1" ] || { echo "::error::dependency install failed after 3 attempts" >&2; exit 1; }
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
# vitest auto-appends results to $GITHUB_STEP_SUMMARY when GITHUB_ACTIONS=true — no
|
||||||
|
# extra summary step needed (same as flight relies on for its unit stage).
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
# Verifies the production Dockerfile builds — the deploy path is `docker compose up -d --build`.
|
||||||
|
# Build only, nothing runs, so no cleanup/prune steps are needed (unlike flight's e2e stack).
|
||||||
|
# Retried: same flaky runner network as dep install.
|
||||||
|
- name: Docker image build
|
||||||
|
run: |
|
||||||
|
set -u
|
||||||
|
ok=0
|
||||||
|
for i in 1 2 3; do
|
||||||
|
if docker compose build --progress=plain; then ok=1; break; fi
|
||||||
|
echo "::warning::docker build attempt $i/3 failed (likely transient network) — retrying in 5s" >&2
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
[ "$ok" = "1" ] || { echo "::error::docker image build failed after 3 attempts" >&2; exit 1; }
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
## API
|
## API
|
||||||
|
|
||||||
1. **Live base URL** — `http://bot.dev.kkhost.pl` (Traefik → docker-compose stack on this host; container port 3000 via external network dev_net, no published ports).
|
1. **Live base URL** — `http://bot.dev.kkhost.pl` (Traefik → docker-compose stack on this host; container port 3000 via external network dev_net, no published ports).
|
||||||
2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: <node number | "!b" | "broadcast"> }`, returns `{ id }`), and `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller.
|
2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast"> }` — numbers pass through as-is (integer 1..0xffffffff), names resolve like `GET /api/nodes/:id`, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. Returns `{ id }`), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `GET /api/device` (device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {<section>: ...}, moduleConfig: {<module>: ...} }` — DeviceMetadata as-is plus per-section Config/ModuleConfig messages; security publicKey/privateKey/adminKey and network wifiPsk redacted to `"REDACTED"`; in-memory, reset on reconnect), `GET /api/messages` (message history newest first: rx text with `from`, tx sends with `to` + `status` pending→delivered/failed via routing ACKs — broadcasts stay pending because firmware strips want_ack; last 100, in-memory, reset on reconnect), `GET /api/telemetry` (telemetry history newest first as `{ from, at /* epoch ms */, kind, data }[]`: live TELEMETRY_APP packets — device metrics and environment sensor readings where nodes have them; kind is the meshtastic.Telemetry oneof case name (deviceMetrics/environmentMetrics/airQualityMetrics/powerMetrics/localStats/healthMetrics/hostMetrics/trafficManagementStats), data that variant's payload with proto uint64 fields as decimal strings; last 100, in-memory, reset on reconnect), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, from the initial sync plus live POSITION_APP updates; reset on reconnect) and `GET /api/nodes/:id` (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. All `POST /api/send` traffic targets channel index 3 (`test`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `3`).
|
||||||
|
3. **MCP endpoint** — `POST /mcp` (excluded from the `/api` prefix in main.ts, like Scalar at `/`): an in-process MCP server over the official SDK's Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`, fresh `McpServer` + transport per request). Tools map 1:1 to MeshService methods: `mesh_status`, `send_message(text, to?)` (same resolution as `POST /api/send`; errors surface as tool errors with the same messages), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), `get_messages`, `get_telemetry` (same bounded recent window as `GET /api/telemetry`), `device_info` (same redaction as `GET /api/device`). Results are JSON in a text content block; sends ride channel index 3 like everything else. Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (`@modelcontextprotocol/sdk/server/mcp.js` etc.).
|
||||||
|
|
||||||
## Other
|
## Other
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,76 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.8.0 — 2026-09-24 21:40
|
||||||
|
|
||||||
|
- New `GET /api/telemetry` endpoint (and matching MCP tool `get_telemetry`, now eight tools): the telemetry stream captured from live `TELEMETRY_APP` packets (portnum 67) — device metrics (uptime/battery/voltage/channel+air utilization) and environment sensor readings where nodes have them. Records are `{ from, at /* epoch ms */, kind, data }[]` newest first: `kind` is the `meshtastic.Telemetry` oneof case name (`deviceMetrics`, `environmentMetrics`, `airQualityMetrics`, `powerMetrics`, `localStats`, `healthMetrics`, `hostMetrics` or `trafficManagementStats`) and `data` is that variant's payload. In-memory only, last 100 (same bounded pattern as message history), reset on every bridge reconnect.
|
||||||
|
- `MeshService.telemetry()` captures the new portnum in the existing packet handler: the `Data.payload` is decoded as a `meshtastic.Telemetry`, and packets without a variant payload are ignored. The variant payload goes through the same module-scope `jsonSafe` converter added in v0.7.1 — telemetry variants carry proto `uint64` fields (e.g. host-metrics byte counters) that decode to native bigint and would 500 Express' `res.json` otherwise, so they come back as decimal strings; unset optional fields are omitted from `data`.
|
||||||
|
- Verified against the installed `@meshtastic/protobufs` 2.8.0: `PortNum.TELEMETRY_APP = 67`; `Telemetry` is a `fixed32 time` plus oneof variant with exactly those eight cases; `DeviceMetrics` fields are batteryLevel/voltage/channelUtilization/airUtilTx/uptimeSeconds and `EnvironmentMetrics` the full sensor set (temperature, relativeHumidity, barometricPressure, gasResistance, voltage, current, iaq, distance, lux variants, wind/rainfall/soil/lightning, adc channels).
|
||||||
|
- Swagger: `GET /api/telemetry` documented with the kind values and an example carrying one record per kind. README + AGENTS.md endpoint/tool lists updated.
|
||||||
|
- Tests: fake-bridge gains a shared `telemetryPacketFrame` builder; new telemetry specs (device-metrics + environment variants → correct from/kind/data, cap at 100 dropping the oldest, reset on reconnect); MCP spec updated to eight tools incl. a `get_telemetry` call check.
|
||||||
|
|
||||||
|
## v0.7.1 — 2026-09-24 21:10
|
||||||
|
|
||||||
|
- Fix `GET /api/device` and MCP `device_info` crashing with `TypeError: Do not know how to serialize a BigInt` on live hardware (fw 2.7.15): protobuf-es decodes proto `uint64` fields as native JS `bigint`, which Express' `res.json`/`JSON.stringify` reject, and `MeshService.deviceInfo()` passed raw decoded proto objects through unconverted. Known affected fields: `Config.PowerConfig.powermonEnables` (in `localConfig.power`) and host-metrics byte counters (`freememBytes`/`diskfree*`).
|
||||||
|
- Fix at the single choke point: one small recursive converter (`jsonSafe`, bigint → decimal string, plain objects/arrays/primitives pass through) applied in `deviceInfo()` to everything that comes from raw decoded proto messages — DeviceMetadata plus every localConfig and moduleConfig section. No per-field patching; any uint64 field in those messages is covered generically. Values that were bigint now serialize as strings (e.g. `"powermonEnables": "123"`); no other endpoint/shape changes, redaction logic untouched.
|
||||||
|
- Tests: new device-info spec feeds a power config section frame with `powermonEnables` set to a real bigint through the fake bridge and asserts the value comes back as its string form (`"123"`) and that `JSON.stringify(deviceInfo())` succeeds — the old fixtures never set a uint64 field, which is why this slipped past the suite.
|
||||||
|
|
||||||
|
## v0.7.0 — 2026-09-24 20:21
|
||||||
|
|
||||||
|
- New `GET /api/device` endpoint (and matching MCP tool `device_info`, now seven tools): device info captured during the initial sync — this device's node number, firmware version and metadata (`meshtastic.DeviceMetadata` as-is; it carries no key material), plus per-section config and module-config messages keyed by section name. In-memory only, reset on every bridge reconnect like channels/nodes.
|
||||||
|
- `MeshService.deviceInfo()` captures the new FromRadio cases during sync: `metadata` (DeviceMetadata), `config` (one `meshtastic.Config` per section — oneof `payloadVariant` cases device/position/power/network/display/lora/bluetooth/security/sessionkey/deviceUi) and `moduleConfig` (one ModuleConfig per module). Verified against the installed `@meshtastic/protobufs` 2.8.0: FromRadio carries `meshtastic.Config`, not `LocalConfig` (that type lives in a separate localonly namespace and is not part of FromRadio).
|
||||||
|
- **Mandatory redaction (trust boundary):** key material never leaves the process — security section `publicKey`/`privateKey`/`adminKey` and network section `wifiPsk` are replaced with `"REDACTED"` in all output.
|
||||||
|
- Node summaries (`GET /api/nodes`, `GET /api/nodes/:id`, MCP `list_nodes`/`get_node`) gain optional fields from the NodeInfo: `snr` (dB), `hopsAway`, `viaMqtt` — present only when the device reported them. Note: `meshtastic.NodeInfo` in 2.8.0 has no firmware version field, so per-node firmwareVersion is not captured (device-level firmware version comes from DeviceMetadata via `/api/device`).
|
||||||
|
- Swagger: `GET /api/device` documented with the redaction note and an example; nodes endpoint example/description extended for the new optional fields. README + AGENTS.md endpoint lists updated.
|
||||||
|
- Tests: fake-bridge gains `metadataFrame`, `configSectionFrame`, `moduleConfigSectionFrame`; new device-info specs (metadata → firmwareVersion, config/module-config sections captured with security key material absent from the JSON output), NodeInfo snr/hopsAway/viaMqtt in summaries; MCP spec updated to seven tools incl. a `device_info` call check.
|
||||||
|
|
||||||
|
## v0.6.0 — 2026-09-24 18:28
|
||||||
|
|
||||||
|
- New MCP (Model Context Protocol) server endpoint at `POST /mcp` (outside the `/api` prefix, like Scalar at `/`), served in-process via the official `@modelcontextprotocol/sdk` Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`; a fresh `McpServer` + transport per request). Six tools map 1:1 to existing `MeshService` methods with no new logic: `mesh_status`, `send_message(text, to?)` (same target resolution as `POST /api/send` — omitted/""/"!b"/"broadcast" → broadcast, numbers pass through, names via the node DB; resolution errors surface as tool errors with the same messages), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), and `get_messages`. Tool results are JSON in a text content block.
|
||||||
|
- New dependency: `@modelcontextprotocol/sdk` (^1.30.1; zod comes with it). Note: the SDK's root entry is broken in all published 1.x tarballs (upstream issue #2273 — the exports map points to a dist index that was never shipped), so imports use the documented subpaths (`@modelcontextprotocol/sdk/server/mcp.js`, `.../server/streamableHttp.js`, `.../inMemory.js`, `.../client/index.js`).
|
||||||
|
- Test helpers shared by both specs extracted from `mesh.service.spec.ts` into `src/mesh/fake-bridge.ts` (fake bridge, waitFor, nodeInfoFrame, KAMIL fixtures); new `mcp.controller.spec.ts` drives the MCP server over an in-memory transport pair — all six tools listed with descriptions/schemas, and mesh_status / send_message (broadcast + name resolution + error messages) / get_node / list_channels / list_nodes / get_messages return correct shapes.
|
||||||
|
- README gains an MCP section; AGENTS.md endpoint list updated for `/mcp`.
|
||||||
|
|
||||||
|
## v0.5.0 — 2026-09-24 17:37
|
||||||
|
|
||||||
|
- Publishing channel moved from `testowy` (index 2) to the new `test` channel (index 3); `MESH_SEND_CHANNEL` default is now `3`. All docs/comments referencing the old pinning updated accordingly.
|
||||||
|
|
||||||
|
## v0.4.0 — 2026-09-24 14:39
|
||||||
|
|
||||||
|
- `POST /api/send` now accepts node names and explicit broadcast sentinels in `to`: omitted/"" or `"!b"`/`"broadcast"` (case-insensitive, trimmed) → broadcast address `0xffffffff`; numbers pass through as-is (must be an integer in 1..0xffffffff); numeric strings are parsed the same way; anything else is looked up by name via the existing node DB lookup (exact long/short name, then unique substring). Unknown or ambiguous targets are a 400 with `no node matching '<to>'`. New service method `MeshService.resolveDest(to?)` does the resolution — `sendText()` keeps its resolved-number signature.
|
||||||
|
- Swagger: `SendTextDto.to` documented as `number | string` with the full resolution order and the DM-vs-broadcast caveat (DMs are unicast wantAck-tracked via `GET /api/messages`; broadcasts stay pending because firmware strips want_ack); `@ApiOperation` notes all sends still ride channel index 2 (`testowy`) regardless of DM or broadcast.
|
||||||
|
- README + AGENTS.md endpoint lists updated for the new `to` semantics.
|
||||||
|
- Unit tests: `resolveDest` coverage on the fake bridge — omitted/""/"!b"/"BROADCAST" → broadcast, number passthrough, numeric string, exact longName match → num (node DB seeded via framed FromRadio nodeInfo), unknown name → `no node matching`, and invalid numbers (0, negative, non-integer, > 0xffffffff) rejected.
|
||||||
|
|
||||||
|
## v0.3.0 — 2026-09-24 14:08
|
||||||
|
|
||||||
|
- Message history with delivery tracking, exposed as `GET /api/messages` (newest first): inbound TEXT_MESSAGE_APP packets (`dir:'rx'`, with `from`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` when the routing ACK/NACK arrives — ROUTING_APP packets carry a serialized `meshtastic.Routing` in `Data.payload` whose oneof variant is `errorReason` (`Routing_Error`: 0 NONE = delivered, anything else failed with a human-readable `ackError`, e.g. NO_ROUTE/GOT_NAK/TIMEOUT, fallback `reason <n>`), matched to our sends via `Data.requestId`. Broadcasts stay `pending` because firmware strips want_ack pre-TX (documented behavior, no timer). History is a bounded in-memory buffer of the last 100 records (newest kept) and resets on every bridge reconnect alongside channels/nodes.
|
||||||
|
- Codec: new `PORT_ROUTING_APP = 5` constant (verified against the installed `@meshtastic/protobufs` PortNum enum), `decodeRouting()` for ROUTING_APP payloads, and `describeRoutingError()` which maps a Routing.Error code to its name from the installed package's `Mesh.Routing_Error` enum.
|
||||||
|
- Swagger: `GET /api/messages` documented with the pending→delivered/failed lifecycle, the broadcast caveat, and an example matching the record shape; README + AGENTS.md endpoint lists updated.
|
||||||
|
|
||||||
|
## v0.2.0 — 2026-09-24 13:08
|
||||||
|
|
||||||
|
- Node database collected during the initial sync (`FromRadio.nodeInfo` frames, upserted by node number) and kept fresh by live `POSITION_APP` packets (the Position is serialized in `Data.payload`; unknown senders get a stub entry). The map resets on every bridge reconnect alongside channels.
|
||||||
|
- New endpoints: `GET /api/nodes` — lean summaries sorted by node number as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard (epoch secs | null), position? { lat, lon, alt? } }[]`; names/hwModel/battery appear only when the device reported them, `hwModel` is the enum's string name (UNSET omitted), and `position` only when both coordinates are known. `GET /api/nodes/:id` — lookup by exact node number (numbers/numeric strings), then case-insensitive exact long/short name, else unique case-insensitive substring; 404 with a clear message when nothing or more than one node matches.
|
||||||
|
- Codec: new `PORT_POSITION_APP = 3` constant (verified against the installed `@meshtastic/protobufs` PortNum enum), `decodePosition()` for POSITION_APP payloads, and `loadHwModelNames()` reverse lookup of the HardwareModel enum.
|
||||||
|
- Swagger docs made genuinely useful: `Mesh` tag on the controller (description via `DocumentBuilder.addTag` — v12's `ApiTagOptions` has no description field) plus `@ApiOperation` summary/description and `@ApiResponse` examples on all endpoints, existing and new.
|
||||||
|
- Unit tests build real framed FromRadio bytes for nodeInfo/POSITION_APP: sync collection + summary shape (lat/lon = value × 1e-7, battery), stub entry from an unknown POSITION_APP sender, lookup ladder (exact num / numeric string / exact name / unique substring) with ambiguous and missing → null, and reconnect reset.
|
||||||
|
|
||||||
|
## v0.1.4 — 2026-09-23 20:33
|
||||||
|
|
||||||
|
- Send a `ToRadio` with `want_config_id` (random non-zero uint32 nonce) on every bridge connect, so the firmware streams its initial sync data (`myInfo`, channels, `config_complete_id`) — previously nothing was written on connect, which is why `/api/channels` stayed empty and `myNodeNum` stayed null in production. New codec helper `buildWantConfigToRadio`; unit test captures the outbound frame on the fake bridge, asserts the `wantConfigId` oneof case with a non-zero value, then feeds back `myInfo` + `configCompleteId` frames and checks service state.
|
||||||
|
|
||||||
|
## v0.1.3 — 2026-09-23 19:58
|
||||||
|
|
||||||
|
- Sends are now pinned to the test channel `testowy` (channel index 2) instead of the hardcoded primary channel (`channel: 0`). The target channel is configurable via `MESH_SEND_CHANNEL` (default `2`) and parsed once at service construction; `buildTextToRadio` takes the channel index as a parameter. Unit tests now decode the outbound ToRadio frame on the fake bridge and assert `packet.channel === 2` on the default send path.
|
||||||
|
|
||||||
|
## v0.1.2 — 2026-09-23 19:51
|
||||||
|
|
||||||
|
- Added a Gitea Actions CI pipeline (`.gitea/workflows/ci.yml`), mirroring flight's runner conventions: on push/PR to `main` (plus manual dispatch) one job runs `npm ci`, `nest build`, the vitest unit suite, and a production Docker image build — with retry loops for the runner's flaky uplink. No app code changed.
|
||||||
|
|
||||||
|
## v0.1.1 — 2026-09-23 19:38
|
||||||
|
|
||||||
|
- Fixed the default bridge port to `4403`, the meshtastic-serial-bridge's actual TCP port (the old value pointed at a closed port). Updated the `MESH_PORT` default in code, the docker-compose environment, and docs.
|
||||||
|
|
||||||
## v0.1.0 — 2026-09-23 18:54
|
## v0.1.0 — 2026-09-23 18:54
|
||||||
|
|
||||||
- New `GET /api/channels` endpoint listing the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }`; psk/keys are redacted and never exposed over HTTP. Channels are collected from `FromRadio.channel` frames streamed during initial sync (before `config_complete_id`) and reset on every bridge reconnect so stale data is dropped after a disconnect.
|
- New `GET /api/channels` endpoint listing the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }`; psk/keys are redacted and never exposed over HTTP. Channels are collected from `FromRadio.channel` frames streamed during initial sync (before `config_complete_id`) and reset on every bridge reconnect so stale data is dropped after a disconnect.
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@ Credentials are deliberately NOT stored in this file — see §1 for where they
|
|||||||
## 7. App architecture quick map
|
## 7. App architecture quick map
|
||||||
|
|
||||||
- `src/mesh/codec.ts` — framing (`extractFrames`, `frame`) + protobuf encode/decode (`decodeFromRadio`, `buildTextToRadio`); unit tests in codec.spec.ts.
|
- `src/mesh/codec.ts` — framing (`extractFrames`, `frame`) + protobuf encode/decode (`decodeFromRadio`, `buildTextToRadio`); unit tests in codec.spec.ts.
|
||||||
- `src/mesh/mesh.service.ts` — TCP connection to the bridge (MESH_HOST/MESH_PORT env, defaults 192.168.1.4:4003), buffer + frame extraction on data, FromRadio dispatch (`myInfo` → myNodeNum, `channel` → upsert into channelMap keyed by index — reset on every reconnect, `configCompleteId` → ready log, `packet` → text-message logging), fixed-interval reconnect (MESH_RECONNECT_MS, default 5 s).
|
- `src/mesh/mesh.service.ts` — TCP connection to the bridge (MESH_HOST/MESH_PORT env, defaults 192.168.1.4:4403), buffer + frame extraction on data, FromRadio dispatch (`myInfo` → myNodeNum, `channel` → upsert into channelMap keyed by index — reset on every reconnect, `configCompleteId` → ready log, `packet` → text-message logging), fixed-interval reconnect (MESH_RECONNECT_MS, default 5 s).
|
||||||
- `src/mesh/mesh.controller.ts` — `GET /status`, `POST /send`, `GET /channels` (all under the `/api` global prefix set in main.ts; Scalar docs at `/`, OpenAPI JSON at `/docs-json`).
|
- `src/mesh/mesh.controller.ts` — `GET /status`, `POST /send`, `GET /channels` (all under the `/api` global prefix set in main.ts; Scalar docs at `/`, OpenAPI JSON at `/docs-json`).
|
||||||
|
|
||||||
## 8. Finding current state (this file keeps no snapshots)
|
## 8. Finding current state (this file keeps no snapshots)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# mesh-bot
|
# mesh-bot
|
||||||
|
|
||||||
Minimal NestJS bot for Meshtastic. Long-running service that TCP-connects to a
|
Minimal NestJS bot for Meshtastic. Long-running service that TCP-connects to a
|
||||||
Meshtastic serial bridge (raw socat pipe) at `192.168.1.4:4003`, parses inbound
|
Meshtastic serial bridge (raw socat pipe) at `192.168.1.4:4403`, sends
|
||||||
protobuf frames, logs received text messages, and exposes HTTP endpoints to send text.
|
`want_config_id` on connect so the firmware streams its initial sync data,
|
||||||
|
parses inbound protobuf frames, logs received text messages, and exposes HTTP
|
||||||
|
endpoints to send text.
|
||||||
|
|
||||||
## Local run
|
## Local run
|
||||||
|
|
||||||
@@ -24,8 +26,13 @@ Exposed via Traefik only (no published ports) at `http://bot.dev.kkhost.pl`.
|
|||||||
All API endpoints live under the `/api` prefix:
|
All API endpoints live under the `/api` prefix:
|
||||||
|
|
||||||
- `GET /api/status` — `{ connected, myNodeNum, host, port }`
|
- `GET /api/status` — `{ connected, myNodeNum, host, port }`
|
||||||
- `POST /api/send` — body `{ "text": "...", "to"?: <node number | "!b" | "broadcast"> }`, returns `{ id }`
|
- `POST /api/send` — body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast"> }`, returns `{ id }`. Numbers pass through as-is (integer in 1..0xffffffff); names resolve like `GET /api/nodes/:id` (exact long/short name, then unique substring) — unknown or ambiguous targets are a 400
|
||||||
- `GET /api/channels` — the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` (psk/keys are redacted; empty until the initial sync completes)
|
- `GET /api/channels` — the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` (psk/keys are redacted; empty until the initial sync completes)
|
||||||
|
- `GET /api/device` — device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {<section>: ...}, moduleConfig: {<module>: ...} }`. Key material is never exposed (security publicKey/privateKey/adminKey and network wifiPsk are replaced with `"REDACTED"`); in-memory only, reset on every bridge reconnect
|
||||||
|
- `GET /api/messages` — message history, newest first: inbound text (`dir:'rx'`, with `from`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` (with human-readable `ackError`) when the routing ACK/NACK arrives; broadcasts stay `pending` because firmware strips want_ack before TX. In-memory only, last 100, reset on every bridge reconnect
|
||||||
|
- `GET /api/telemetry` — telemetry history, newest first: live `TELEMETRY_APP` packets from any node as `{ from, at /* epoch ms */, kind, data }[]`. `kind` is the `meshtastic.Telemetry` oneof case name (`deviceMetrics`, `environmentMetrics`, `airQualityMetrics`, `powerMetrics`, `localStats`, `healthMetrics`, `hostMetrics` or `trafficManagementStats`) and `data` carries that variant's payload (proto uint64 fields as decimal strings; unset optional fields omitted). In-memory only, last 100, reset on every bridge reconnect
|
||||||
|
- `GET /api/nodes` — known mesh nodes as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, sorted by node number. Collected during the initial sync plus live position updates; names/hwModel/battery appear only when reported, `lastHeard` is epoch seconds (null = never heard), `position` only when both coordinates are known, and snr/hopsAway/viaMqtt only when carried by the NodeInfo
|
||||||
|
- `GET /api/nodes/:id` — one node looked up by exact node number (numbers or numeric strings), then case-insensitive exact long/short name, else the unique case-insensitive substring match; 404 when nothing or more than one node matches
|
||||||
|
|
||||||
Interactive API docs (Scalar) are served at `/`, with the OpenAPI JSON at
|
Interactive API docs (Scalar) are served at `/`, with the OpenAPI JSON at
|
||||||
`/docs-json`.
|
`/docs-json`.
|
||||||
@@ -35,15 +42,56 @@ curl http://localhost:3000/api/status
|
|||||||
curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"text":"hi"}'
|
curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"text":"hi"}'
|
||||||
curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"to":123456789,"text":"hello"}'
|
curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"to":123456789,"text":"hello"}'
|
||||||
curl http://localhost:3000/api/channels
|
curl http://localhost:3000/api/channels
|
||||||
|
curl http://localhost:3000/api/device
|
||||||
|
curl http://localhost:3000/api/messages
|
||||||
|
curl http://localhost:3000/api/telemetry
|
||||||
|
curl http://localhost:3000/api/nodes
|
||||||
|
curl http://localhost:3000/api/nodes/kamil
|
||||||
```
|
```
|
||||||
|
|
||||||
`to` omitted, `"!b"`, or `"broadcast"` sends to the broadcast address `0xffffffff`.
|
`to` omitted, `"!b"`, or `"broadcast"` (case-insensitive) sends to the
|
||||||
|
broadcast address `0xffffffff`; anything else is a node number or name.
|
||||||
|
DMs are unicast and wantAck-tracked via `GET /api/messages`; broadcasts stay
|
||||||
|
pending because firmware strips want_ack before TX.
|
||||||
|
|
||||||
|
All sends always target channel index 3 (`test`) — never the primary
|
||||||
|
channel. Override with `MESH_SEND_CHANNEL`.
|
||||||
|
|
||||||
|
## MCP server
|
||||||
|
|
||||||
|
The bot also exposes an MCP (Model Context Protocol) endpoint at
|
||||||
|
`POST /mcp` (outside the `/api` prefix), served in-process over the SDK's
|
||||||
|
Streamable HTTP transport in stateless mode. It maps 1:1 to the REST
|
||||||
|
endpoints — no new logic, same channel pinning:
|
||||||
|
|
||||||
|
- `mesh_status` — bridge connection state (`{ connected, myNodeNum, host, port }`)
|
||||||
|
- `send_message(text, to?)` — send a text message; `to` resolves like `POST /api/send` (omitted/""/"!b"/"broadcast" → broadcast, number or numeric string → node number, else name lookup); returns `{ id }`; unknown/ambiguous targets are tool errors
|
||||||
|
- `list_channels` — device channels (keys redacted)
|
||||||
|
- `list_nodes` — known nodes as lean summaries
|
||||||
|
- `get_node(id)` — one node by number or name; tool error when missing/ambiguous
|
||||||
|
- `get_messages` — message history, newest first (last 100, tx records carry delivery status)
|
||||||
|
- `get_telemetry` — telemetry history, newest first (last 100): device metrics and environment sensor readings from any node; `kind` is the Telemetry oneof case name
|
||||||
|
- `device_info` — device info from the initial sync: node number, firmware version, metadata and per-section config/module-config (key material redacted as `"REDACTED"`)
|
||||||
|
|
||||||
|
Point any MCP client at it (Streamable HTTP URL):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "mcpServers": { "mesh-bot": { "url": "http://bot.dev.kkhost.pl/mcp" } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST http://localhost:3000/mcp \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-H 'accept: application/json, text/event-stream' \
|
||||||
|
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
|
||||||
|
```
|
||||||
|
|
||||||
## Env vars
|
## Env vars
|
||||||
|
|
||||||
- `MESH_HOST` (default `192.168.1.4`) — bridge host
|
- `MESH_HOST` (default `192.168.1.4`) — bridge host
|
||||||
- `MESH_PORT` (default `4003`) — bridge port
|
- `MESH_PORT` (default `4403`) — bridge port
|
||||||
- `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms
|
- `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms
|
||||||
|
- `MESH_SEND_CHANNEL` (default `3`) — channel index all sends target (`test`; never the primary channel)
|
||||||
- `PORT` (default `3000`) — HTTP listen port
|
- `PORT` (default `3000`) — HTTP listen port
|
||||||
|
|
||||||
Note: the serial bridge accepts only ONE client at a time — if another app is
|
Note: the serial bridge accepts only ONE client at a time — if another app is
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
environment:
|
environment:
|
||||||
MESH_HOST: 192.168.1.4
|
MESH_HOST: 192.168.1.4
|
||||||
MESH_PORT: '4003'
|
MESH_PORT: '4403'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- dev_net
|
- dev_net
|
||||||
|
|||||||
Generated
+211
-8
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.0.1",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.0.1",
|
"version": "0.5.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^2.15.0",
|
"@bufbuild/protobuf": "^2.15.0",
|
||||||
"@meshtastic/protobufs": "^2.8.0",
|
"@meshtastic/protobufs": "^2.8.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.30.1",
|
||||||
"@nestjs/common": "^12.0.1",
|
"@nestjs/common": "^12.0.1",
|
||||||
"@nestjs/core": "^12.0.1",
|
"@nestjs/core": "^12.0.1",
|
||||||
"@nestjs/platform-express": "^12.0.1",
|
"@nestjs/platform-express": "^12.0.1",
|
||||||
@@ -221,6 +222,18 @@
|
|||||||
"node": ">=0.1.90"
|
"node": ">=0.1.90"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@hono/node-server": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"hono": "^4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@inquirer/ansi": {
|
"node_modules/@inquirer/ansi": {
|
||||||
"version": "2.0.8",
|
"version": "2.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz",
|
||||||
@@ -617,6 +630,46 @@
|
|||||||
"integrity": "sha512-p68VexhnH7ojf3U27RdryUDqnKJgKaWUbCL96vJR7N0mqDuhKG5OIECk3sCPPdynxjk+yoVhRkScqtv5KQjzsw==",
|
"integrity": "sha512-p68VexhnH7ojf3U27RdryUDqnKJgKaWUbCL96vJR7N0mqDuhKG5OIECk3sCPPdynxjk+yoVhRkScqtv5KQjzsw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
|
"version": "1.30.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.1.tgz",
|
||||||
|
"integrity": "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^1.19.9 || ^2.0.5",
|
||||||
|
"ajv": "^8.17.1",
|
||||||
|
"ajv-formats": "^3.0.1",
|
||||||
|
"content-type": "^1.0.5",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"cross-spawn": "^7.0.5",
|
||||||
|
"eventsource": "^3.0.2",
|
||||||
|
"eventsource-parser": "^3.0.0",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^8.2.1",
|
||||||
|
"hono": "^4.11.4",
|
||||||
|
"jose": "^6.1.3",
|
||||||
|
"json-schema-typed": "^8.0.2",
|
||||||
|
"pkce-challenge": "^5.0.0",
|
||||||
|
"raw-body": "^3.0.0",
|
||||||
|
"zod": "^3.25 || ^4.0",
|
||||||
|
"zod-to-json-schema": "^3.25.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@cfworker/json-schema": "^4.1.1",
|
||||||
|
"zod": "^3.25 || ^4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@cfworker/json-schema": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"zod": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/cli": {
|
"node_modules/@nestjs/cli": {
|
||||||
"version": "12.0.5",
|
"version": "12.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-12.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-12.0.5.tgz",
|
||||||
@@ -2179,7 +2232,6 @@
|
|||||||
"version": "8.20.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
@@ -2196,7 +2248,6 @@
|
|||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.0.0"
|
"ajv": "^8.0.0"
|
||||||
@@ -2764,6 +2815,20 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-spawn": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.1.0",
|
||||||
|
"shebang-command": "^2.0.0",
|
||||||
|
"which": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -2991,6 +3056,27 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventsource": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eventsource-parser": "^3.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eventsource-parser": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expect-type": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||||
@@ -3044,6 +3130,25 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "8.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
|
||||||
|
"integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"ip-address": "^10.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/external-editor": {
|
"node_modules/external-editor": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
|
||||||
@@ -3083,7 +3188,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-safe-stringify": {
|
"node_modules/fast-safe-stringify": {
|
||||||
@@ -3113,7 +3217,6 @@
|
|||||||
"version": "3.1.8",
|
"version": "3.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3428,6 +3531,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hono": {
|
||||||
|
"version": "4.13.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.9.tgz",
|
||||||
|
"integrity": "sha512-7dMkQmZoC4E6F7AtaQSPhlWAdnBti+j7rreMZl8QB4jFiEhP9TWbGWUMi8WYzBCgmgulxuvLQupKqo+Co6Omyg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/html-escaper": {
|
"node_modules/html-escaper": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
@@ -3618,6 +3730,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ip-address": {
|
||||||
|
"version": "10.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
|
||||||
|
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -3669,6 +3790,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/isexe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/istanbul-lib-coverage": {
|
"node_modules/istanbul-lib-coverage": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||||
@@ -3717,6 +3844,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
|
||||||
|
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||||
@@ -3750,9 +3886,14 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-typed": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/json5": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
@@ -4658,6 +4799,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-key": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "8.4.2",
|
"version": "8.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||||
@@ -4695,6 +4845,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pkce-challenge": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pluralize": {
|
"node_modules/pluralize": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||||
@@ -4850,7 +5009,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -5037,6 +5195,27 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/shebang-command": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"shebang-regex": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-regex": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
@@ -5866,6 +6045,21 @@
|
|||||||
"defaults": "^1.0.3"
|
"defaults": "^1.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/node-which"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/why-is-node-running": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
@@ -5925,6 +6119,15 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod-to-json-schema": {
|
||||||
|
"version": "3.25.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
|
||||||
|
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.28 || ^4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.1.0",
|
"version": "0.8.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^2.15.0",
|
"@bufbuild/protobuf": "^2.15.0",
|
||||||
"@meshtastic/protobufs": "^2.8.0",
|
"@meshtastic/protobufs": "^2.8.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.30.1",
|
||||||
"@nestjs/common": "^12.0.1",
|
"@nestjs/common": "^12.0.1",
|
||||||
"@nestjs/core": "^12.0.1",
|
"@nestjs/core": "^12.0.1",
|
||||||
"@nestjs/platform-express": "^12.0.1",
|
"@nestjs/platform-express": "^12.0.1",
|
||||||
|
|||||||
+4
-2
@@ -9,14 +9,16 @@ import { AppModule } from './app.module.js';
|
|||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
app.setGlobalPrefix('api');
|
// 'mcp' is excluded so the MCP endpoint lives at /mcp, not /api/mcp (like Scalar at /)
|
||||||
|
app.setGlobalPrefix('api', { exclude: ['mcp'] });
|
||||||
|
|
||||||
// Scalar API docs at / (OpenAPI JSON at /docs-json), outside the api prefix.
|
// Scalar API docs at / (OpenAPI JSON at /docs-json), outside the api prefix.
|
||||||
const docConfig = new DocumentBuilder()
|
const docConfig = new DocumentBuilder()
|
||||||
.setTitle('MeshBot API')
|
.setTitle('MeshBot API')
|
||||||
.setDescription(
|
.setDescription(
|
||||||
'Meshtastic bridge bot: status, send text messages, list device channels.',
|
'Meshtastic bridge bot: status, send text messages, list device channels and known nodes.',
|
||||||
)
|
)
|
||||||
|
.addTag('Mesh', 'Meshtastic bridge control and telemetry')
|
||||||
.setVersion(pkg.version)
|
.setVersion(pkg.version)
|
||||||
.build();
|
.build();
|
||||||
const document = SwaggerModule.createDocument(app, docConfig);
|
const document = SwaggerModule.createDocument(app, docConfig);
|
||||||
|
|||||||
+66
-1
@@ -10,6 +10,12 @@ async function loadProto(): Promise<typeof import('@meshtastic/protobufs')> {
|
|||||||
export const BROADCAST_ADDR = 0xffffffff;
|
export const BROADCAST_ADDR = 0xffffffff;
|
||||||
// official portnums.proto: TEXT_MESSAGE_APP=1 (task spec draft said 3 — the enum value wins)
|
// official portnums.proto: TEXT_MESSAGE_APP=1 (task spec draft said 3 — the enum value wins)
|
||||||
export const PORT_TEXT_MESSAGE_APP = 1;
|
export const PORT_TEXT_MESSAGE_APP = 1;
|
||||||
|
// verified against installed @meshtastic/protobufs Portnums.PortNum.POSITION_APP
|
||||||
|
export const PORT_POSITION_APP = 3;
|
||||||
|
// verified against installed @meshtastic/protobufs Portnums.PortNum.ROUTING_APP
|
||||||
|
export const PORT_ROUTING_APP = 5;
|
||||||
|
// verified against installed @meshtastic/protobufs Portnums.PortNum.TELEMETRY_APP
|
||||||
|
export const PORT_TELEMETRY_APP = 67;
|
||||||
|
|
||||||
const START1 = 0x94;
|
const START1 = 0x94;
|
||||||
const START2 = 0xc3;
|
const START2 = 0xc3;
|
||||||
@@ -61,9 +67,58 @@ export async function decodeFromRadio(
|
|||||||
return fromBinary(P.Mesh.FromRadioSchema, bytes);
|
return fromBinary(P.Mesh.FromRadioSchema, bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** POSITION_APP packets carry a serialized meshtastic.Position in Data.payload. */
|
||||||
|
export async function decodePosition(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
): Promise<import('@meshtastic/protobufs').Mesh.Position> {
|
||||||
|
const P = await loadProto();
|
||||||
|
return fromBinary(P.Mesh.PositionSchema, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ROUTING_APP packets carry a serialized meshtastic.Routing in Data.payload (ACK/NACK for want_ack sends). */
|
||||||
|
export async function decodeRouting(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
): Promise<import('@meshtastic/protobufs').Mesh.Routing> {
|
||||||
|
const P = await loadProto();
|
||||||
|
return fromBinary(P.Mesh.RoutingSchema, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TELEMETRY_APP packets carry a serialized meshtastic.Telemetry in Data.payload. */
|
||||||
|
export async function decodeTelemetry(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
): Promise<import('@meshtastic/protobufs').Telemetry.Telemetry> {
|
||||||
|
const P = await loadProto();
|
||||||
|
return fromBinary(P.Telemetry.TelemetrySchema, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable name for a Routing.Error code (from the installed @meshtastic/protobufs enum), `reason <n>` fallback. */
|
||||||
|
export async function describeRoutingError(code: number): Promise<string> {
|
||||||
|
const P = await loadProto();
|
||||||
|
const hit = Object.entries(P.Mesh.Routing_Error).find(
|
||||||
|
([, v]) => typeof v === 'number' && v === code,
|
||||||
|
);
|
||||||
|
return hit ? String(hit[0]) : `reason ${code}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hwModelNames: Map<number, string> | null = null;
|
||||||
|
/** Reverse lookup of meshtastic.HardwareModel (enum value -> name), loaded once. */
|
||||||
|
export async function loadHwModelNames(): Promise<Map<number, string>> {
|
||||||
|
if (!hwModelNames) {
|
||||||
|
const P = await loadProto();
|
||||||
|
hwModelNames = new Map(
|
||||||
|
Object.entries(P.Mesh.HardwareModel).map(([k, v]) => [
|
||||||
|
Number(k),
|
||||||
|
String(v),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return hwModelNames;
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildTextToRadio(
|
export async function buildTextToRadio(
|
||||||
to: number,
|
to: number,
|
||||||
text: string,
|
text: string,
|
||||||
|
channelIndex: number,
|
||||||
): Promise<{ bytes: Uint8Array; id: number }> {
|
): Promise<{ bytes: Uint8Array; id: number }> {
|
||||||
const P = await loadProto();
|
const P = await loadProto();
|
||||||
let id = Math.floor(Math.random() * 0x100000000) >>> 0;
|
let id = Math.floor(Math.random() * 0x100000000) >>> 0;
|
||||||
@@ -72,7 +127,7 @@ export async function buildTextToRadio(
|
|||||||
to,
|
to,
|
||||||
id,
|
id,
|
||||||
wantAck: true,
|
wantAck: true,
|
||||||
channel: 0,
|
channel: channelIndex,
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
case: 'decoded',
|
case: 'decoded',
|
||||||
value: {
|
value: {
|
||||||
@@ -87,3 +142,13 @@ export async function buildTextToRadio(
|
|||||||
});
|
});
|
||||||
return { bytes: frame(toBinary(P.Mesh.ToRadioSchema, toRadio)), id };
|
return { bytes: frame(toBinary(P.Mesh.ToRadioSchema, toRadio)), id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function buildWantConfigToRadio(
|
||||||
|
wantConfigId: number,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const P = await loadProto();
|
||||||
|
const toRadio = create(P.Mesh.ToRadioSchema, {
|
||||||
|
payloadVariant: { case: 'wantConfigId', value: wantConfigId },
|
||||||
|
});
|
||||||
|
return toBinary(P.Mesh.ToRadioSchema, toRadio);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { create, toBinary } from '@bufbuild/protobuf';
|
||||||
|
import * as P from '@meshtastic/protobufs';
|
||||||
|
import * as net from 'node:net';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import { 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 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||||
|
import type * as net from 'node:net';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { BROADCAST_ADDR } from './codec.js';
|
||||||
|
import {
|
||||||
|
KAMIL_NUM,
|
||||||
|
kamilNodeInfo,
|
||||||
|
nodeInfoFrame,
|
||||||
|
startBridge,
|
||||||
|
telemetryPacketFrame,
|
||||||
|
waitFor,
|
||||||
|
} from './fake-bridge.js';
|
||||||
|
import { createMeshMcpServer } from './mcp.controller.js';
|
||||||
|
import { MeshService } from './mesh.service.js';
|
||||||
|
|
||||||
|
// callTool returns CallToolResult | a legacy compatibility shape; we only ever get the former
|
||||||
|
type ToolOutcome = { content: { type: string; text?: string }[] };
|
||||||
|
|
||||||
|
function toolText(r: Awaited<ReturnType<Client['callTool']>>): string {
|
||||||
|
const c = (r as ToolOutcome).content[0];
|
||||||
|
if (!c || c.type !== 'text') throw new Error('expected a text content block');
|
||||||
|
return c.text ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectedService(): Promise<{
|
||||||
|
svc: MeshService;
|
||||||
|
sockets: net.Socket[];
|
||||||
|
close: () => void;
|
||||||
|
}> {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(() => bridge.sockets.length === 1 && svc.status().connected);
|
||||||
|
return {
|
||||||
|
svc,
|
||||||
|
sockets: bridge.sockets,
|
||||||
|
close: () => {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive the MCP server over an in-memory transport pair (no HTTP involved)
|
||||||
|
async function connectMcp(svc: MeshService): Promise<{
|
||||||
|
client: Client;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
const [clientTransport, serverTransport] =
|
||||||
|
InMemoryTransport.createLinkedPair();
|
||||||
|
const server = createMeshMcpServer(svc);
|
||||||
|
await server.connect(serverTransport);
|
||||||
|
const client = new Client({ name: 'mesh-bot-test', version: '0.0.0' });
|
||||||
|
await client.connect(clientTransport);
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
close: async () => {
|
||||||
|
await client.close();
|
||||||
|
await server.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MCP server tools', () => {
|
||||||
|
it('lists all eight tools with descriptions and input schemas', async () => {
|
||||||
|
const env = await connectedService();
|
||||||
|
try {
|
||||||
|
const mcp = await connectMcp(env.svc);
|
||||||
|
try {
|
||||||
|
const { tools } = await mcp.client.listTools();
|
||||||
|
expect(tools.map((t) => t.name).sort()).toEqual([
|
||||||
|
'device_info',
|
||||||
|
'get_messages',
|
||||||
|
'get_node',
|
||||||
|
'get_telemetry',
|
||||||
|
'list_channels',
|
||||||
|
'list_nodes',
|
||||||
|
'mesh_status',
|
||||||
|
'send_message',
|
||||||
|
]);
|
||||||
|
for (const t of tools) expect(t.description?.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const send = tools.find((t) => t.name === 'send_message')!;
|
||||||
|
expect(send.inputSchema.required).toEqual(['text']);
|
||||||
|
expect(Object.keys(send.inputSchema.properties ?? {})).toEqual([
|
||||||
|
'text',
|
||||||
|
'to',
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await mcp.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
env.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mesh_status returns the bridge connection state', async () => {
|
||||||
|
const env = await connectedService();
|
||||||
|
try {
|
||||||
|
const mcp = await connectMcp(env.svc);
|
||||||
|
try {
|
||||||
|
const r = await mcp.client.callTool({
|
||||||
|
name: 'mesh_status',
|
||||||
|
arguments: {},
|
||||||
|
});
|
||||||
|
expect(r.isError).not.toBe(true);
|
||||||
|
expect(JSON.parse(toolText(r))).toEqual({
|
||||||
|
connected: true,
|
||||||
|
myNodeNum: null,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: Number(process.env.MESH_PORT),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await mcp.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
env.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('send_message broadcasts by default and resolves names to node numbers', async () => {
|
||||||
|
const env = await connectedService();
|
||||||
|
try {
|
||||||
|
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||||
|
await waitFor(async () => (await env.svc.nodes()).length === 1);
|
||||||
|
|
||||||
|
const mcp = await connectMcp(env.svc);
|
||||||
|
try {
|
||||||
|
const r1 = await mcp.client.callTool({
|
||||||
|
name: 'send_message',
|
||||||
|
arguments: { text: 'hello' },
|
||||||
|
});
|
||||||
|
expect(r1.isError).not.toBe(true);
|
||||||
|
const bcast = JSON.parse(toolText(r1)) as { id: number };
|
||||||
|
expect(bcast.id).toBeGreaterThan(0);
|
||||||
|
expect(env.svc.messages()[0]).toMatchObject({
|
||||||
|
dir: 'tx',
|
||||||
|
to: BROADCAST_ADDR,
|
||||||
|
});
|
||||||
|
|
||||||
|
// name lookup via the node DB (unique substring)
|
||||||
|
const r2 = await mcp.client.callTool({
|
||||||
|
name: 'send_message',
|
||||||
|
arguments: { text: 'dm', to: 'kamil' },
|
||||||
|
});
|
||||||
|
expect(r2.isError).not.toBe(true);
|
||||||
|
expect(env.svc.messages()[0]).toMatchObject({
|
||||||
|
dir: 'tx',
|
||||||
|
to: KAMIL_NUM,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await mcp.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
env.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('send_message surfaces resolution errors as tool errors with the REST messages', async () => {
|
||||||
|
const env = await connectedService();
|
||||||
|
try {
|
||||||
|
const mcp = await connectMcp(env.svc);
|
||||||
|
try {
|
||||||
|
const r1 = await mcp.client.callTool({
|
||||||
|
name: 'send_message',
|
||||||
|
arguments: { text: 'x', to: 'zzz-nope' },
|
||||||
|
});
|
||||||
|
expect(r1.isError).toBe(true);
|
||||||
|
expect(toolText(r1)).toContain("no node matching 'zzz-nope'");
|
||||||
|
|
||||||
|
const r2 = await mcp.client.callTool({
|
||||||
|
name: 'send_message',
|
||||||
|
arguments: { text: 'x', to: '0' },
|
||||||
|
});
|
||||||
|
expect(r2.isError).toBe(true);
|
||||||
|
expect(toolText(r2)).toContain('invalid node number');
|
||||||
|
} finally {
|
||||||
|
await mcp.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
env.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get_node resolves by name and errors when missing', async () => {
|
||||||
|
const env = await connectedService();
|
||||||
|
try {
|
||||||
|
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||||
|
await waitFor(async () => (await env.svc.nodes()).length === 1);
|
||||||
|
|
||||||
|
const mcp = await connectMcp(env.svc);
|
||||||
|
try {
|
||||||
|
const r1 = await mcp.client.callTool({
|
||||||
|
name: 'get_node',
|
||||||
|
arguments: { id: 'kamil' },
|
||||||
|
});
|
||||||
|
expect(r1.isError).not.toBe(true);
|
||||||
|
expect(JSON.parse(toolText(r1))).toMatchObject({
|
||||||
|
num: KAMIL_NUM,
|
||||||
|
longName: 'Kamil SP8MK/P',
|
||||||
|
shortName: 'KK',
|
||||||
|
});
|
||||||
|
|
||||||
|
const r2 = await mcp.client.callTool({
|
||||||
|
name: 'get_node',
|
||||||
|
arguments: { id: 'zzz' },
|
||||||
|
});
|
||||||
|
expect(r2.isError).toBe(true);
|
||||||
|
expect(toolText(r2)).toContain("no node matching 'zzz'");
|
||||||
|
} finally {
|
||||||
|
await mcp.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
env.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list_channels, list_nodes, get_messages and device_info return the service data', async () => {
|
||||||
|
const env = await connectedService();
|
||||||
|
try {
|
||||||
|
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||||
|
await waitFor(async () => (await env.svc.nodes()).length === 1);
|
||||||
|
|
||||||
|
const mcp = await connectMcp(env.svc);
|
||||||
|
try {
|
||||||
|
expect(
|
||||||
|
JSON.parse(
|
||||||
|
toolText(
|
||||||
|
await mcp.client.callTool({
|
||||||
|
name: 'list_channels',
|
||||||
|
arguments: {},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toEqual([]); // no channel frames fed yet
|
||||||
|
|
||||||
|
// no metadata/config frames fed yet → empty sections, null node num
|
||||||
|
expect(
|
||||||
|
JSON.parse(
|
||||||
|
toolText(
|
||||||
|
await mcp.client.callTool({ name: 'device_info', arguments: {} }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toEqual({ myNodeNum: null, localConfig: {}, moduleConfig: {} });
|
||||||
|
|
||||||
|
const nodes = JSON.parse(
|
||||||
|
toolText(
|
||||||
|
await mcp.client.callTool({ name: 'list_nodes', arguments: {} }),
|
||||||
|
),
|
||||||
|
) as { num: number; longName?: string }[];
|
||||||
|
expect(nodes).toHaveLength(1);
|
||||||
|
expect(nodes[0].num).toBe(KAMIL_NUM);
|
||||||
|
expect(nodes[0].longName).toBe('Kamil SP8MK/P');
|
||||||
|
|
||||||
|
await mcp.client.callTool({
|
||||||
|
name: 'send_message',
|
||||||
|
arguments: { text: 'hi' },
|
||||||
|
});
|
||||||
|
const msgs = JSON.parse(
|
||||||
|
toolText(
|
||||||
|
await mcp.client.callTool({ name: 'get_messages', arguments: {} }),
|
||||||
|
),
|
||||||
|
) as { dir: string; to?: number }[];
|
||||||
|
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(
|
||||||
|
toolText(
|
||||||
|
await mcp.client.callTool({ name: 'get_telemetry', arguments: {} }),
|
||||||
|
),
|
||||||
|
) as { from: number; kind: string; data: object }[];
|
||||||
|
expect(tel).toHaveLength(1);
|
||||||
|
expect(tel[0]).toMatchObject({
|
||||||
|
from: KAMIL_NUM,
|
||||||
|
kind: 'deviceMetrics',
|
||||||
|
});
|
||||||
|
expect(tel[0].data).toEqual({ batteryLevel: 87 });
|
||||||
|
} finally {
|
||||||
|
await mcp.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
env.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { Controller, Post, Req, Res } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import type { ServerResponse } from 'node:http';
|
||||||
|
import { ApiExcludeEndpoint } from '@nestjs/swagger';
|
||||||
|
import { z } from 'zod';
|
||||||
|
// The SDK's root entry is broken in all published 1.x tarballs (upstream #2273: the exports map points to a dist index that was never shipped) — use the documented subpath imports instead.
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import * as pkg from '../../package.json';
|
||||||
|
import { MeshService } from './mesh.service.js';
|
||||||
|
|
||||||
|
const CHANNEL_NOTE =
|
||||||
|
' All sends ride the pinned "test" channel (index 3) — never the primary channel.';
|
||||||
|
|
||||||
|
function jsonResult(value: unknown): {
|
||||||
|
content: { type: 'text'; text: string }[];
|
||||||
|
} {
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(value) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build an MCP server exposing the MeshService as tools (1:1 with the REST endpoints). */
|
||||||
|
export function createMeshMcpServer(mesh: MeshService): McpServer {
|
||||||
|
const server = new McpServer({ name: 'mesh-bot', version: pkg.version });
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'mesh_status',
|
||||||
|
{
|
||||||
|
description: `Bridge connection state: whether the bot is connected to the meshtastic bridge, this device's node number, and the bridge host/port.${CHANNEL_NOTE}`,
|
||||||
|
},
|
||||||
|
async () => jsonResult(mesh.status()),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'send_message',
|
||||||
|
{
|
||||||
|
description: `Send a text message over the mesh; returns { id } — the Meshtastic packet id. to: omitted/"" or "!b"/"broadcast" (case-insensitive) → broadcast; number or numeric string → that node number (integer in 1..0xffffffff); anything else is looked up by name (exact long/short name, then unique substring). Unknown or ambiguous targets are an error.${CHANNEL_NOTE}`,
|
||||||
|
inputSchema: { text: z.string(), to: z.string().optional() },
|
||||||
|
},
|
||||||
|
async ({ text, to }) =>
|
||||||
|
jsonResult({ id: await mesh.sendText(await mesh.resolveDest(to), text) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'list_channels',
|
||||||
|
{
|
||||||
|
description: `The device's Meshtastic channels as { index, name, uplinkEnabled, downlinkEnabled }[] sorted by index (psk/keys redacted); empty until the initial sync completes.${CHANNEL_NOTE}`,
|
||||||
|
},
|
||||||
|
async () => jsonResult(mesh.channels()),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'list_nodes',
|
||||||
|
{
|
||||||
|
description: `Known mesh nodes as lean summaries sorted by node number; names/hwModel/batteryLevel appear only when the device reported them, lastHeard is epoch seconds (null = never heard), position only when both coordinates are known.${CHANNEL_NOTE}`,
|
||||||
|
},
|
||||||
|
async () => jsonResult(await mesh.nodes()),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'get_node',
|
||||||
|
{
|
||||||
|
description: `Look up one node by exact node number (number or numeric string), then case-insensitive exact long/short name, else the unique case-insensitive substring match. Errors when nothing or more than one node matches.${CHANNEL_NOTE}`,
|
||||||
|
inputSchema: { id: z.string() },
|
||||||
|
},
|
||||||
|
async ({ id }) => {
|
||||||
|
const n = await mesh.node(id);
|
||||||
|
if (!n) throw new Error(`no node matching '${id}'`);
|
||||||
|
return jsonResult(n);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'get_messages',
|
||||||
|
{
|
||||||
|
description: `Message history, newest first (last 100): inbound text (dir "rx", with from) and our sends (dir "tx", with to + status pending→delivered/failed; broadcasts stay pending because firmware strips want_ack). In-memory only, reset on every bridge reconnect.${CHANNEL_NOTE}`,
|
||||||
|
},
|
||||||
|
async () => jsonResult(mesh.messages()),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'get_telemetry',
|
||||||
|
{
|
||||||
|
description: `Telemetry history, newest first (bounded recent window, last 100): TELEMETRY_APP packets from any node — device metrics (uptime/battery/voltage/channel+air utilization) and environment sensor readings where nodes have them. kind is the meshtastic.Telemetry oneof case name; data carries that variant's payload (proto uint64 fields as decimal strings). In-memory only, reset on every bridge reconnect.${CHANNEL_NOTE}`,
|
||||||
|
},
|
||||||
|
async () => jsonResult(mesh.telemetry()),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'device_info',
|
||||||
|
{
|
||||||
|
description: `Device info captured during the initial sync: this device's node number, firmware version and metadata, plus per-section config/module-config (security publicKey/privateKey/adminKey and network wifiPsk redacted as "REDACTED"). In-memory only, reset on every bridge reconnect.${CHANNEL_NOTE}`,
|
||||||
|
},
|
||||||
|
async () => jsonResult(mesh.deviceInfo()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class McpController {
|
||||||
|
constructor(private readonly mesh: MeshService) {}
|
||||||
|
|
||||||
|
// MCP Streamable HTTP endpoint (stateless mode): a fresh server + transport per request, outside the /api prefix.
|
||||||
|
@Post('mcp')
|
||||||
|
@ApiExcludeEndpoint()
|
||||||
|
async mcp(@Req() req: Request, @Res() res: ServerResponse): Promise<void> {
|
||||||
|
const server = createMeshMcpServer(this.mesh);
|
||||||
|
try {
|
||||||
|
const transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: undefined,
|
||||||
|
});
|
||||||
|
await server.connect(transport);
|
||||||
|
await transport.handleRequest(req, res, req.body);
|
||||||
|
res.on('close', () => {
|
||||||
|
void transport.close();
|
||||||
|
void server.close();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.setHeader('content-type', 'application/json');
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
error: { code: -32603, message: String(error) },
|
||||||
|
id: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+225
-12
@@ -3,10 +3,17 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
Post,
|
Post,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiProperty } from '@nestjs/swagger';
|
import {
|
||||||
import { BROADCAST_ADDR } from './codec.js';
|
ApiOperation,
|
||||||
|
ApiProperty,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import type { DeviceInfo } from './mesh.service.js';
|
||||||
import { MeshService } from './mesh.service.js';
|
import { MeshService } from './mesh.service.js';
|
||||||
|
|
||||||
export class SendTextDto {
|
export class SendTextDto {
|
||||||
@@ -15,37 +22,62 @@ export class SendTextDto {
|
|||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
required: false,
|
required: false,
|
||||||
description:
|
description:
|
||||||
'Decimal node number, "!b", or "broadcast" (defaults to broadcast)',
|
'Send target. Resolution order: omitted or "" → broadcast (0xffffffff); number → used as-is (integer in 1..0xffffffff); numeric string → that node number; "!b" or "broadcast" (case-insensitive) → broadcast; anything else is looked up by name — exact long/short name, then unique substring. DMs are unicast and wantAck-tracked via GET /api/messages (pending→delivered/failed); broadcasts stay pending because firmware strips want_ack.',
|
||||||
example: '!b',
|
example: '!b',
|
||||||
})
|
})
|
||||||
to?: string | number;
|
to?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tag description is set via DocumentBuilder.addTag in main.ts (v12 ApiTagOptions has no description)
|
||||||
|
@ApiTags('Mesh')
|
||||||
@Controller()
|
@Controller()
|
||||||
export class MeshController {
|
export class MeshController {
|
||||||
constructor(private readonly mesh: MeshService) {}
|
constructor(private readonly mesh: MeshService) {}
|
||||||
|
|
||||||
@Get('status')
|
@Get('status')
|
||||||
@ApiOperation({ summary: 'Bridge connection status' })
|
@ApiOperation({
|
||||||
|
summary: 'Bridge connection status',
|
||||||
|
description:
|
||||||
|
'Whether the bot is connected to the meshtastic-serial-bridge and which node number this device has.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Connection status',
|
||||||
|
example: {
|
||||||
|
connected: true,
|
||||||
|
myNodeNum: 123456789,
|
||||||
|
host: '192.168.1.4',
|
||||||
|
port: 4403,
|
||||||
|
},
|
||||||
|
})
|
||||||
status() {
|
status() {
|
||||||
return this.mesh.status();
|
return this.mesh.status();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('send')
|
@Post('send')
|
||||||
@ApiOperation({ summary: 'Send a text message over the mesh' })
|
@ApiOperation({
|
||||||
|
summary: 'Send a text message over the mesh',
|
||||||
|
description:
|
||||||
|
'Queues a TEXT_MESSAGE_APP packet to the resolved target (node number, node name, or broadcast). DMs are unicast and wantAck-tracked via GET /api/messages; broadcasts stay pending because firmware strips want_ack. All sends ride the pinned test channel (index 3, "test" — never the primary channel) regardless of DM or broadcast, since modern firmware PKI handles unicast encryption/channel anyway. Returns the Meshtastic packet id.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Packet queued',
|
||||||
|
example: { id: 123456789 },
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: 'Invalid text or target' })
|
||||||
|
@ApiResponse({ status: 503, description: 'Bridge not connected' })
|
||||||
async send(@Body() body: SendTextDto) {
|
async send(@Body() body: SendTextDto) {
|
||||||
if (typeof body?.text !== 'string' || body.text.length === 0) {
|
if (typeof body?.text !== 'string' || body.text.length === 0) {
|
||||||
throw new BadRequestException('text must be a non-empty string');
|
throw new BadRequestException('text must be a non-empty string');
|
||||||
}
|
}
|
||||||
let to = BROADCAST_ADDR;
|
let to: number;
|
||||||
const t = body.to;
|
try {
|
||||||
if (t !== undefined && t !== '!b' && t !== 'broadcast') {
|
to = await this.mesh.resolveDest(body.to);
|
||||||
const n = typeof t === 'number' ? t : Number(String(t));
|
} catch (err) {
|
||||||
if (!Number.isFinite(n))
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'to must be a decimal node number, "!b", or "broadcast"',
|
err instanceof Error ? err.message : String(err),
|
||||||
);
|
);
|
||||||
to = n;
|
|
||||||
}
|
}
|
||||||
return { id: await this.mesh.sendText(to, body.text) };
|
return { id: await this.mesh.sendText(to, body.text) };
|
||||||
}
|
}
|
||||||
@@ -53,8 +85,189 @@ export class MeshController {
|
|||||||
@Get('channels')
|
@Get('channels')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "The device's Meshtastic channels (keys redacted)",
|
summary: "The device's Meshtastic channels (keys redacted)",
|
||||||
|
description:
|
||||||
|
'Channel table streamed during the initial sync; empty until it completes. psk/keys are never exposed.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Channels sorted by index',
|
||||||
|
example: [
|
||||||
|
{ index: 0, name: 'Default', uplinkEnabled: true, downlinkEnabled: true },
|
||||||
|
{
|
||||||
|
index: 3,
|
||||||
|
name: 'test',
|
||||||
|
uplinkEnabled: false,
|
||||||
|
downlinkEnabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
channels() {
|
channels() {
|
||||||
return this.mesh.channels();
|
return this.mesh.channels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('device')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Device info (metadata + config, keys redacted)',
|
||||||
|
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.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Device info',
|
||||||
|
example: {
|
||||||
|
myNodeNum: 123456789,
|
||||||
|
firmwareVersion: '2.7.0.1',
|
||||||
|
metadata: {
|
||||||
|
firmwareVersion: '2.7.0.1',
|
||||||
|
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 {
|
||||||
|
return this.mesh.deviceInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('messages')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Message history (last 100)',
|
||||||
|
description:
|
||||||
|
'Inbound text messages and our sends, newest first. tx records start "pending" and move to "delivered"/"failed" when the routing ACK/NACK arrives; broadcasts stay "pending" because firmware strips want_ack before TX. History is in-memory only (last 100) and resets on every bridge reconnect.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Messages newest first',
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
id: 456789,
|
||||||
|
dir: 'tx',
|
||||||
|
to: 42,
|
||||||
|
text: 'hello test',
|
||||||
|
channel: 3,
|
||||||
|
at: 1758000123456,
|
||||||
|
status: 'delivered',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 123456,
|
||||||
|
dir: 'rx',
|
||||||
|
from: 987654321,
|
||||||
|
text: 'ping',
|
||||||
|
channel: 2,
|
||||||
|
at: 1758000000000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
messages() {
|
||||||
|
return this.mesh.messages();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('telemetry')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Telemetry history (last 100)',
|
||||||
|
description:
|
||||||
|
"TELEMETRY_APP packets from any node, newest first. Device metrics (uptime/battery/voltage/channel+air utilization) and environment sensor readings where nodes have them; kind is the meshtastic.Telemetry oneof case name — deviceMetrics, environmentMetrics, airQualityMetrics, powerMetrics, localStats, healthMetrics, hostMetrics or trafficManagementStats — and data carries that variant's payload (proto uint64 fields as decimal strings). In-memory only (last 100), reset on every bridge reconnect.",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Telemetry records newest first',
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
from: 987654321,
|
||||||
|
at: 1758000123456,
|
||||||
|
kind: 'deviceMetrics',
|
||||||
|
data: {
|
||||||
|
batteryLevel: 87,
|
||||||
|
voltage: 4.1,
|
||||||
|
channelUtilization: 12.5,
|
||||||
|
airUtilTx: 3.2,
|
||||||
|
uptimeSeconds: 86400,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 123456789,
|
||||||
|
at: 1758000000000,
|
||||||
|
kind: 'environmentMetrics',
|
||||||
|
data: { temperature: 21.5, relativeHumidity: 48 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
telemetry() {
|
||||||
|
return this.mesh.telemetry();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('nodes')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Known mesh nodes (lean summaries)',
|
||||||
|
description:
|
||||||
|
'Node database collected during the initial sync plus live POSITION_APP updates; reset on every bridge reconnect. Names, hwModel and batteryLevel appear only when the device reported them; snr/hopsAway/viaMqtt only when carried by the NodeInfo.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Nodes sorted by node number',
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
num: 123456789,
|
||||||
|
longName: 'Kamil SP8MK/P',
|
||||||
|
shortName: 'KK',
|
||||||
|
hwModel: 'TBEAM',
|
||||||
|
batteryLevel: 87,
|
||||||
|
lastHeard: 1758000000,
|
||||||
|
position: { lat: 52.1049637, lon: 20.9833152, alt: 123 },
|
||||||
|
snr: -2.5,
|
||||||
|
hopsAway: 1,
|
||||||
|
viaMqtt: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
async nodes() {
|
||||||
|
return this.mesh.nodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('nodes/:id')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Look up one node by number or name',
|
||||||
|
description:
|
||||||
|
'Exact node number first (numbers and numeric strings), then case-insensitive exact long/short name, else the unique case-insensitive substring match across names. 404 when nothing or more than one node matches.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'The matching node',
|
||||||
|
example: {
|
||||||
|
num: 123456789,
|
||||||
|
longName: 'Kamil SP8MK/P',
|
||||||
|
shortName: 'KK',
|
||||||
|
lastHeard: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 404,
|
||||||
|
description: 'No node (or more than one) matching the given id or name',
|
||||||
|
})
|
||||||
|
async node(@Param('id') id: string) {
|
||||||
|
const n = await this.mesh.node(id);
|
||||||
|
if (!n) throw new NotFoundException(`no node matching '${id}'`);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { McpController } from './mcp.controller.js';
|
||||||
import { MeshController } from './mesh.controller.js';
|
import { MeshController } from './mesh.controller.js';
|
||||||
import { MeshService } from './mesh.service.js';
|
import { MeshService } from './mesh.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [MeshController],
|
controllers: [MeshController, McpController],
|
||||||
providers: [MeshService],
|
providers: [MeshService],
|
||||||
})
|
})
|
||||||
export class MeshModule {}
|
export class MeshModule {}
|
||||||
|
|||||||
+806
-23
@@ -1,35 +1,103 @@
|
|||||||
import { create, toBinary } from '@bufbuild/protobuf';
|
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
||||||
import * as P from '@meshtastic/protobufs';
|
import * as P from '@meshtastic/protobufs';
|
||||||
import * as net from 'node:net';
|
|
||||||
import type { AddressInfo } from 'node:net';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { frame } from './codec.js';
|
import { BROADCAST_ADDR, extractFrames, frame } from './codec.js';
|
||||||
|
import {
|
||||||
|
KAMIL_NUM,
|
||||||
|
configSectionFrame,
|
||||||
|
kamilNodeInfo,
|
||||||
|
metadataFrame,
|
||||||
|
moduleConfigSectionFrame,
|
||||||
|
nodeInfoFrame,
|
||||||
|
startBridge,
|
||||||
|
telemetryPacketFrame,
|
||||||
|
waitFor,
|
||||||
|
} from './fake-bridge.js';
|
||||||
import { MeshService } from './mesh.service.js';
|
import { MeshService } from './mesh.service.js';
|
||||||
|
|
||||||
function startBridge(): Promise<{
|
// live position update: POSITION_APP packet with a serialized Position in Data.payload
|
||||||
port: number;
|
function positionPacketFrame(
|
||||||
sockets: net.Socket[];
|
from: number,
|
||||||
close: () => void;
|
pos: { latitudeI?: number; longitudeI?: number },
|
||||||
}> {
|
): Uint8Array {
|
||||||
const sockets: net.Socket[] = [];
|
const data = create(P.Mesh.DataSchema, {
|
||||||
const server = net.createServer((socket) => sockets.push(socket));
|
portnum: P.Portnums.PortNum.POSITION_APP,
|
||||||
return new Promise((resolve) => {
|
payload: toBinary(
|
||||||
server.listen(0, '127.0.0.1', () => {
|
P.Mesh.PositionSchema,
|
||||||
resolve({
|
create(P.Mesh.PositionSchema, pos),
|
||||||
port: (server.address() as AddressInfo).port,
|
),
|
||||||
sockets,
|
|
||||||
close: () => server.close(),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
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 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitFor(cond: () => boolean, timeoutMs = 5000): Promise<void> {
|
// inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload
|
||||||
const t0 = Date.now();
|
function textPacketFrame(
|
||||||
while (!cond()) {
|
from: number,
|
||||||
if (Date.now() - t0 > timeoutMs) throw new Error('timed out waiting');
|
to: number,
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
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 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ACK/NACK for our sends: ROUTING_APP packet with Data.requestId = original id and a serialized Routing in the payload
|
||||||
|
function routingFrame(
|
||||||
|
requestId: number,
|
||||||
|
errorReason: P.Mesh.Routing_Error,
|
||||||
|
): Uint8Array {
|
||||||
|
const data = create(P.Mesh.DataSchema, {
|
||||||
|
portnum: P.Portnums.PortNum.ROUTING_APP,
|
||||||
|
requestId,
|
||||||
|
payload: toBinary(
|
||||||
|
P.Mesh.RoutingSchema,
|
||||||
|
create(P.Mesh.RoutingSchema, {
|
||||||
|
variant: { case: 'errorReason', value: errorReason },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const pkt = create(P.Mesh.MeshPacketSchema, {
|
||||||
|
from: 1,
|
||||||
|
id: 2,
|
||||||
|
payloadVariant: { case: 'decoded', value: data },
|
||||||
|
});
|
||||||
|
return frame(
|
||||||
|
toBinary(
|
||||||
|
P.Mesh.FromRadioSchema,
|
||||||
|
create(P.Mesh.FromRadioSchema, {
|
||||||
|
payloadVariant: { case: 'packet', value: pkt },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function channelFrame(
|
function channelFrame(
|
||||||
@@ -116,3 +184,718 @@ describe('MeshService channel pipeline', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MeshService connect', () => {
|
||||||
|
it('sends want_config_id on connect and applies the initial sync', async () => {
|
||||||
|
const out: Uint8Array[] = [];
|
||||||
|
const bridge = await startBridge((s) => s.on('data', (c) => out.push(c)));
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(() => svc.status().connected && out.length > 0);
|
||||||
|
|
||||||
|
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
|
||||||
|
let off = 0;
|
||||||
|
for (const c of out) {
|
||||||
|
merged.set(c, off);
|
||||||
|
off += c.length;
|
||||||
|
}
|
||||||
|
const { packets } = extractFrames(merged);
|
||||||
|
expect(packets.length).toBeGreaterThan(0);
|
||||||
|
const tr = fromBinary(P.Mesh.ToRadioSchema, packets[0]);
|
||||||
|
expect(tr.payloadVariant?.case).toBe('wantConfigId');
|
||||||
|
const nonce =
|
||||||
|
tr.payloadVariant && tr.payloadVariant.case === 'wantConfigId'
|
||||||
|
? tr.payloadVariant.value
|
||||||
|
: -1;
|
||||||
|
expect(nonce).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// feed back the initial sync: myInfo, then configCompleteId echoing the nonce
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
frame(
|
||||||
|
toBinary(
|
||||||
|
P.Mesh.FromRadioSchema,
|
||||||
|
create(P.Mesh.FromRadioSchema, {
|
||||||
|
payloadVariant: { case: 'myInfo', value: { myNodeNum: 1234 } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
frame(
|
||||||
|
toBinary(
|
||||||
|
P.Mesh.FromRadioSchema,
|
||||||
|
create(P.Mesh.FromRadioSchema, {
|
||||||
|
payloadVariant: { case: 'configCompleteId', value: nonce },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => svc.status().myNodeNum === 1234);
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MeshService send', () => {
|
||||||
|
it('sends on the test channel (index 3) by default, never primary', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
delete process.env.MESH_SEND_CHANNEL; // default path: "test" (index 3)
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
const out: Uint8Array[] = [];
|
||||||
|
bridge.sockets[0].on('data', (c) => out.push(c));
|
||||||
|
const id = await svc.sendText(42, 'hello test');
|
||||||
|
expect(id).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const readOutbound = (): P.Mesh.MeshPacket | undefined => {
|
||||||
|
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
|
||||||
|
let off = 0;
|
||||||
|
for (const c of out) {
|
||||||
|
merged.set(c, off);
|
||||||
|
off += c.length;
|
||||||
|
}
|
||||||
|
const { packets } = extractFrames(merged);
|
||||||
|
// outbound now starts with the want_config_id frame — find the text packet
|
||||||
|
for (const p of packets) {
|
||||||
|
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
|
||||||
|
if (tr.payloadVariant?.case === 'packet')
|
||||||
|
return tr.payloadVariant.value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
await waitFor(() => readOutbound() !== undefined);
|
||||||
|
const pkt = readOutbound()!;
|
||||||
|
expect(pkt.channel).toBe(3); // "test" — never the primary channel (0)
|
||||||
|
expect(pkt.to).toBe(42);
|
||||||
|
const decoded =
|
||||||
|
pkt.payloadVariant?.case === 'decoded'
|
||||||
|
? pkt.payloadVariant.value
|
||||||
|
: undefined;
|
||||||
|
expect(
|
||||||
|
new TextDecoder().decode(decoded?.payload ?? new Uint8Array()),
|
||||||
|
).toBe('hello test');
|
||||||
|
} finally {
|
||||||
|
delete process.env.MESH_SEND_CHANNEL;
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MeshService node database', () => {
|
||||||
|
it('collects NodeInfo during sync and summarizes nodes', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||||
|
bridge.sockets[0].write(nodeInfoFrame(987654321)); // bare entry: no user/position/metrics yet
|
||||||
|
|
||||||
|
await waitFor(async () => (await svc.nodes()).length === 2);
|
||||||
|
expect(await svc.nodes()).toEqual([
|
||||||
|
{
|
||||||
|
num: KAMIL_NUM,
|
||||||
|
longName: 'Kamil SP8MK/P',
|
||||||
|
shortName: 'KK',
|
||||||
|
hwModel: 'TBEAM',
|
||||||
|
batteryLevel: 87,
|
||||||
|
lastHeard: 1758000000,
|
||||||
|
position: { lat: 521049637 * 1e-7, lon: 209833152 * 1e-7, alt: 123 },
|
||||||
|
},
|
||||||
|
{ num: 987654321, lastHeard: null }, // names/hwModel/battery/position omitted when absent
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a stub entry for POSITION_APP packets from unknown nodes', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
positionPacketFrame(555, {
|
||||||
|
latitudeI: 10000000,
|
||||||
|
longitudeI: -20000000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(async () => (await svc.nodes()).length === 1);
|
||||||
|
expect(await svc.nodes()).toEqual([
|
||||||
|
{ num: 555, lastHeard: null, position: { lat: 1, lon: -2 } }, // no alt sent -> omitted
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('looks up nodes by exact num, numeric string, exact name and unique substring', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
nodeInfoFrame(42, {
|
||||||
|
user: { id: '!ot', longName: 'Other Node', shortName: 'OT' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(async () => (await svc.nodes()).length === 2);
|
||||||
|
|
||||||
|
expect((await svc.node(KAMIL_NUM))?.longName).toBe('Kamil SP8MK/P'); // exact num
|
||||||
|
expect((await svc.node(String(KAMIL_NUM)))?.num).toBe(KAMIL_NUM); // numeric string
|
||||||
|
expect((await svc.node('kamil sp8mk/p'))?.shortName).toBe('KK'); // case-insensitive exact name
|
||||||
|
expect((await svc.node('kamil'))?.num).toBe(KAMIL_NUM); // unique substring
|
||||||
|
expect(await svc.node('zzz-nope')).toBeNull(); // no match
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for ambiguous name matches', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
// two nodes whose names both contain "kamil" and share the short name "KK"
|
||||||
|
bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
nodeInfoFrame(777, {
|
||||||
|
user: { id: '!kj', longName: 'Kamil Jr.', shortName: 'KK' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(async () => (await svc.nodes()).length === 2);
|
||||||
|
|
||||||
|
expect(await svc.node('kamil')).toBeNull(); // substring matches two nodes
|
||||||
|
expect(await svc.node('kk')).toBeNull(); // exact name matches two nodes
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the node database on reconnect (no stale data)', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
process.env.MESH_RECONNECT_MS = '50';
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||||
|
await waitFor(async () => (await svc.nodes()).length === 1);
|
||||||
|
|
||||||
|
// drop the connection; bot reconnects and must start with an empty node DB
|
||||||
|
bridge.sockets[0].destroy();
|
||||||
|
await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected);
|
||||||
|
await waitFor(async () => (await svc.nodes()).length === 0);
|
||||||
|
} finally {
|
||||||
|
delete process.env.MESH_RECONNECT_MS;
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MeshService message history', () => {
|
||||||
|
it('captures inbound text as rx records', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 2));
|
||||||
|
|
||||||
|
await waitFor(() => svc.messages().length === 1);
|
||||||
|
expect(svc.messages()).toEqual([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
dir: 'rx',
|
||||||
|
from: KAMIL_NUM,
|
||||||
|
text: 'ping',
|
||||||
|
channel: 2,
|
||||||
|
at: expect.any(Number),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks a tx send pending -> delivered on the routing ACK', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
const id = await svc.sendText(42, 'hello test');
|
||||||
|
expect(svc.messages()[0]).toMatchObject({
|
||||||
|
id,
|
||||||
|
dir: 'tx',
|
||||||
|
to: 42,
|
||||||
|
text: 'hello test',
|
||||||
|
channel: 3,
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
bridge.sockets[0].write(routingFrame(id, P.Mesh.Routing_Error.NONE));
|
||||||
|
|
||||||
|
await waitFor(() => svc.messages()[0]?.status === 'delivered');
|
||||||
|
expect(svc.messages()[0]).toMatchObject({
|
||||||
|
id,
|
||||||
|
dir: 'tx',
|
||||||
|
status: 'delivered',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a tx failed with the routing error reason on NACK', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
const id = await svc.sendText(42, 'will fail');
|
||||||
|
expect(svc.messages()[0]?.status).toBe('pending');
|
||||||
|
|
||||||
|
bridge.sockets[0].write(routingFrame(id, P.Mesh.Routing_Error.TIMEOUT));
|
||||||
|
|
||||||
|
await waitFor(() => svc.messages()[0]?.status === 'failed');
|
||||||
|
expect(svc.messages()[0]).toMatchObject({
|
||||||
|
id,
|
||||||
|
status: 'failed',
|
||||||
|
ackError: 'TIMEOUT', // human-readable Routing.Error name
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps history at 100 records, dropping the oldest', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < 105; i++)
|
||||||
|
bridge.sockets[0].write(textPacketFrame(42, KAMIL_NUM, `m-${i}`));
|
||||||
|
|
||||||
|
await waitFor(() => svc.messages().length === 100, 10000);
|
||||||
|
expect(svc.messages()[0]?.text).toBe('m-104'); // newest first
|
||||||
|
expect(svc.messages()[99]?.text).toBe('m-5'); // m-0..m-4 dropped
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MeshService telemetry', () => {
|
||||||
|
it('captures device metrics and environment variants with from/kind/data', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(svc.telemetry()).toEqual([]); // nothing before the first packet
|
||||||
|
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
telemetryPacketFrame(KAMIL_NUM, {
|
||||||
|
case: 'deviceMetrics',
|
||||||
|
value: { batteryLevel: 87, voltage: 4.1, uptimeSeconds: 3600 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
telemetryPacketFrame(987654321, {
|
||||||
|
case: 'environmentMetrics',
|
||||||
|
value: { temperature: 21.5, relativeHumidity: 48 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => svc.telemetry().length === 2);
|
||||||
|
expect(svc.telemetry()).toEqual([
|
||||||
|
{
|
||||||
|
from: 987654321, // newest first
|
||||||
|
at: expect.any(Number),
|
||||||
|
kind: 'environmentMetrics',
|
||||||
|
// the deprecated repeated one_wire_temperature field decodes as an empty list even when unset
|
||||||
|
data: {
|
||||||
|
temperature: 21.5,
|
||||||
|
relativeHumidity: 48,
|
||||||
|
oneWireTemperature: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: KAMIL_NUM,
|
||||||
|
at: expect.any(Number),
|
||||||
|
kind: 'deviceMetrics',
|
||||||
|
// proto float is IEEE-754 single precision — 4.1 widens to this exact double
|
||||||
|
data: {
|
||||||
|
batteryLevel: 87,
|
||||||
|
voltage: 4.099999904632568,
|
||||||
|
uptimeSeconds: 3600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps telemetry at 100 records, dropping the oldest', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < 105; i++)
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
telemetryPacketFrame(42, {
|
||||||
|
case: 'deviceMetrics',
|
||||||
|
value: { batteryLevel: i },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => svc.telemetry().length === 100, 10000);
|
||||||
|
expect(svc.telemetry()[0]?.data).toEqual({ batteryLevel: 104 }); // newest first
|
||||||
|
expect(svc.telemetry()[99]?.data).toEqual({ batteryLevel: 5 }); // i=0..4 dropped
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets telemetry on reconnect (no stale data)', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
process.env.MESH_RECONNECT_MS = '50';
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
telemetryPacketFrame(KAMIL_NUM, { case: 'deviceMetrics', value: {} }),
|
||||||
|
);
|
||||||
|
await waitFor(() => svc.telemetry().length === 1);
|
||||||
|
|
||||||
|
// drop the connection; bot reconnects and must start with an empty history
|
||||||
|
bridge.sockets[0].destroy();
|
||||||
|
await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected);
|
||||||
|
await waitFor(() => svc.telemetry().length === 0);
|
||||||
|
} finally {
|
||||||
|
delete process.env.MESH_RECONNECT_MS;
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MeshService resolveDest', () => {
|
||||||
|
it('resolves broadcast sentinels, numbers and names to node numbers', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||||
|
await waitFor(async () => (await svc.nodes()).length === 1);
|
||||||
|
|
||||||
|
expect(await svc.resolveDest(undefined)).toBe(BROADCAST_ADDR); // omitted → broadcast
|
||||||
|
expect(await svc.resolveDest('')).toBe(BROADCAST_ADDR); // empty → broadcast
|
||||||
|
expect(await svc.resolveDest('!b')).toBe(BROADCAST_ADDR);
|
||||||
|
expect(await svc.resolveDest('BROADCAST')).toBe(BROADCAST_ADDR); // case-insensitive
|
||||||
|
expect(await svc.resolveDest(42)).toBe(42); // number passthrough
|
||||||
|
expect(await svc.resolveDest(String(KAMIL_NUM))).toBe(KAMIL_NUM); // numeric string
|
||||||
|
expect(await svc.resolveDest('Kamil SP8MK/P')).toBe(KAMIL_NUM); // exact longName
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for unknown names and invalid node numbers', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||||
|
await waitFor(async () => (await svc.nodes()).length === 1);
|
||||||
|
|
||||||
|
await expect(svc.resolveDest('zzz-nope')).rejects.toThrow(
|
||||||
|
"no node matching 'zzz-nope'",
|
||||||
|
);
|
||||||
|
for (const bad of [0, -1, 42.5, BROADCAST_ADDR + 1]) {
|
||||||
|
await expect(svc.resolveDest(bad)).rejects.toThrow(
|
||||||
|
/invalid node number/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await expect(svc.resolveDest('0')).rejects.toThrow(/invalid node number/);
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MeshService device info', () => {
|
||||||
|
it('captures DeviceMetadata and exposes firmwareVersion', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
// before the sync: only the node number (null until myInfo arrives)
|
||||||
|
expect(svc.deviceInfo()).toEqual({
|
||||||
|
myNodeNum: null,
|
||||||
|
localConfig: {},
|
||||||
|
moduleConfig: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
metadataFrame({ firmwareVersion: '2.7.0.1', hasWifi: false, role: 3 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => svc.deviceInfo().firmwareVersion === '2.7.0.1');
|
||||||
|
const info = svc.deviceInfo();
|
||||||
|
expect(info.firmwareVersion).toBe('2.7.0.1');
|
||||||
|
expect(info.metadata?.firmwareVersion).toBe('2.7.0.1'); // metadata stored as-is
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures config/module-config sections and redacts key material', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
const secret = 'super-secret-wifi-psk';
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
configSectionFrame('security', {
|
||||||
|
publicKey: new Uint8Array([1, 2, 3]),
|
||||||
|
privateKey: new Uint8Array([4, 5, 6]),
|
||||||
|
adminKey: [new Uint8Array([7])],
|
||||||
|
isManaged: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
configSectionFrame('network', {
|
||||||
|
wifiEnabled: false,
|
||||||
|
wifiSsid: 'home',
|
||||||
|
wifiPsk: secret,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
bridge.sockets[0].write(configSectionFrame('device', { role: 3 }));
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
moduleConfigSectionFrame('telemetry', {
|
||||||
|
environmentUpdateInterval: 900,
|
||||||
|
powerMeasurementEnabled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() =>
|
||||||
|
Object.keys(svc.deviceInfo().localConfig).length === 3 &&
|
||||||
|
Object.keys(svc.deviceInfo().moduleConfig).length === 1,
|
||||||
|
);
|
||||||
|
const info = svc.deviceInfo();
|
||||||
|
expect(Object.keys(info.localConfig).sort()).toEqual([
|
||||||
|
'device',
|
||||||
|
'network',
|
||||||
|
'security',
|
||||||
|
]);
|
||||||
|
expect(info.moduleConfig.telemetry).toMatchObject({
|
||||||
|
environmentUpdateInterval: 900,
|
||||||
|
powerMeasurementEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// key material is replaced with a marker — never exposed
|
||||||
|
expect(info.localConfig.security).toMatchObject({
|
||||||
|
publicKey: 'REDACTED',
|
||||||
|
privateKey: 'REDACTED',
|
||||||
|
adminKey: 'REDACTED',
|
||||||
|
isManaged: true, // non-secret fields pass through
|
||||||
|
});
|
||||||
|
expect(info.localConfig.network?.wifiPsk).toBe('REDACTED');
|
||||||
|
const json = JSON.stringify(info);
|
||||||
|
expect(json).not.toContain(secret);
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes proto uint64 fields as strings (JSON-safe)', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
// powermonEnables is a proto uint64 — protobuf-es decodes it as native bigint
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
configSectionFrame('power', { powermonEnables: 123n }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => svc.deviceInfo().localConfig.power !== undefined);
|
||||||
|
const info = svc.deviceInfo();
|
||||||
|
expect(info.localConfig.power?.powermonEnables).toBe('123'); // bigint -> decimal string
|
||||||
|
expect(() => JSON.stringify(info)).not.toThrow(); // no "Do not know how to serialize a BigInt"
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarizes NodeInfo snr/hopsAway/viaMqtt when present', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
nodeInfoFrame(42, {
|
||||||
|
user: { id: '!ot', longName: 'Other Node' },
|
||||||
|
snr: -2.5,
|
||||||
|
hopsAway: 1,
|
||||||
|
viaMqtt: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(async () => (await svc.nodes()).length === 2);
|
||||||
|
const nodes = await svc.nodes();
|
||||||
|
expect(nodes.find((n) => n.num === 42)).toEqual({
|
||||||
|
num: 42,
|
||||||
|
longName: 'Other Node',
|
||||||
|
lastHeard: null,
|
||||||
|
snr: -2.5,
|
||||||
|
hopsAway: 1,
|
||||||
|
viaMqtt: true,
|
||||||
|
});
|
||||||
|
// absent in the proto → omitted from the summary
|
||||||
|
expect(nodes.find((n) => n.num === KAMIL_NUM)).not.toHaveProperty('snr');
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+393
-9
@@ -6,23 +6,134 @@ import {
|
|||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import * as net from 'node:net';
|
import * as net from 'node:net';
|
||||||
|
import type * as Proto from '@meshtastic/protobufs';
|
||||||
import {
|
import {
|
||||||
|
BROADCAST_ADDR,
|
||||||
|
PORT_POSITION_APP,
|
||||||
|
PORT_ROUTING_APP,
|
||||||
|
PORT_TELEMETRY_APP,
|
||||||
PORT_TEXT_MESSAGE_APP,
|
PORT_TEXT_MESSAGE_APP,
|
||||||
buildTextToRadio,
|
buildTextToRadio,
|
||||||
|
buildWantConfigToRadio,
|
||||||
decodeFromRadio,
|
decodeFromRadio,
|
||||||
|
decodePosition,
|
||||||
|
decodeRouting,
|
||||||
|
decodeTelemetry,
|
||||||
|
describeRoutingError,
|
||||||
extractFrames,
|
extractFrames,
|
||||||
|
frame,
|
||||||
|
loadHwModelNames,
|
||||||
} from './codec.js';
|
} from './codec.js';
|
||||||
|
|
||||||
|
/** One entry in the message history: an inbound text (dir 'rx') or one of our sends (dir 'tx'). */
|
||||||
|
export interface MessageRecord {
|
||||||
|
id: number;
|
||||||
|
dir: 'rx' | 'tx';
|
||||||
|
/** sender node num — rx only */
|
||||||
|
from?: number;
|
||||||
|
/** destination node num (broadcast addr for broadcasts) — tx only */
|
||||||
|
to?: number;
|
||||||
|
text: string;
|
||||||
|
channel: number;
|
||||||
|
/** epoch ms */
|
||||||
|
at: number;
|
||||||
|
/** tx only: pending until the routing ACK/NACK arrives; broadcasts stay pending (firmware strips want_ack pre-TX) */
|
||||||
|
status?: 'pending' | 'delivered' | 'failed';
|
||||||
|
/** tx only, when failed: human-readable Routing.Error reason */
|
||||||
|
ackError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry in the telemetry history: a TELEMETRY_APP packet from some node. */
|
||||||
|
export interface TelemetryRecord {
|
||||||
|
/** sender node num */
|
||||||
|
from: number;
|
||||||
|
/** epoch ms */
|
||||||
|
at: number;
|
||||||
|
/** meshtastic.Telemetry oneof case name (e.g. "deviceMetrics", "environmentMetrics") */
|
||||||
|
kind: string;
|
||||||
|
/** the variant payload, JSON-safe (proto uint64 fields as decimal strings); unset optional fields omitted */
|
||||||
|
data: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bounded history: keep the newest N records, in-memory only
|
||||||
|
const HISTORY_LIMIT = 100;
|
||||||
|
|
||||||
|
/** What we know about a node so far: full NodeInfo after sync, possibly just {num}+position for live-only nodes. */
|
||||||
|
type NodeEntry = Pick<Proto.Mesh.NodeInfo, 'num'> &
|
||||||
|
Partial<Omit<Proto.Mesh.NodeInfo, 'num'>>;
|
||||||
|
|
||||||
|
export interface NodeSummary {
|
||||||
|
num: number;
|
||||||
|
longName?: string;
|
||||||
|
shortName?: string;
|
||||||
|
/** meshtastic.HardwareModel enum name (e.g. "TBEAM"); omitted when unknown or UNSET */
|
||||||
|
hwModel?: string;
|
||||||
|
/** 0-100 = battery percent, >100 = mains powered (device convention) */
|
||||||
|
batteryLevel?: number;
|
||||||
|
/** epoch seconds of the last heard packet; null when never heard */
|
||||||
|
lastHeard: number | null;
|
||||||
|
position?: { lat: number; lon: number; alt?: number };
|
||||||
|
/** signal-to-noise ratio of the last received packet, dB (NodeInfo.snr) */
|
||||||
|
snr?: number;
|
||||||
|
/** hops from this node to the one that reported it (optional in proto) */
|
||||||
|
hopsAway?: number;
|
||||||
|
/** true when the node info arrived via MQTT rather than LoRa */
|
||||||
|
viaMqtt?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape of deviceInfo() output: sync-captured device data, key material redacted. */
|
||||||
|
export interface DeviceInfo {
|
||||||
|
myNodeNum: number | null;
|
||||||
|
firmwareVersion?: string;
|
||||||
|
metadata?: Proto.Mesh.DeviceMetadata;
|
||||||
|
localConfig: Record<string, Record<string, unknown>>;
|
||||||
|
moduleConfig: Record<string, Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// protobuf-es decodes proto uint64 fields as native bigint, which JSON.stringify rejects;
|
||||||
|
// recursively convert so deviceInfo()/telemetry() output is always JSON-safe (bigint -> decimal string).
|
||||||
|
// $-prefixed keys are protobuf-es internals ($typeName/$unknown), not device data — dropped.
|
||||||
|
function jsonSafe<T>(value: T): T {
|
||||||
|
if (typeof value === 'bigint') return String(value) as T;
|
||||||
|
if (Array.isArray(value)) return value.map(jsonSafe) as T;
|
||||||
|
if (value !== null && typeof value === 'object') {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(value))
|
||||||
|
if (!k.startsWith('$')) out[k] = jsonSafe(v);
|
||||||
|
return out as T;
|
||||||
|
}
|
||||||
|
return value; // primitives + undefined pass through
|
||||||
|
}
|
||||||
|
|
||||||
|
// key material must never leave the process — replaced with a marker so callers can see the field exists
|
||||||
|
const REDACTED = 'REDACTED';
|
||||||
|
/** Config sections that carry secrets: security (PKI keys) and network (wifi psk). */
|
||||||
|
function redactSection(
|
||||||
|
name: string,
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (name !== 'security' && name !== 'network') return value;
|
||||||
|
const out = { ...value };
|
||||||
|
for (const key of ['publicKey', 'privateKey', 'adminKey', 'wifiPsk'])
|
||||||
|
if (out[key] !== undefined) out[key] = REDACTED;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MeshService implements OnModuleInit, OnModuleDestroy {
|
export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(MeshService.name);
|
private readonly logger = new Logger(MeshService.name);
|
||||||
private readonly host = process.env.MESH_HOST ?? '192.168.1.4';
|
private readonly host = process.env.MESH_HOST ?? '192.168.1.4';
|
||||||
private readonly port = parseInt(process.env.MESH_PORT ?? '4003', 10);
|
private readonly port = parseInt(process.env.MESH_PORT ?? '4403', 10);
|
||||||
// ponytail: fixed-interval retry, no backoff — fine for single-bridge use
|
// ponytail: fixed-interval retry, no backoff — fine for single-bridge use
|
||||||
private readonly reconnectMs = parseInt(
|
private readonly reconnectMs = parseInt(
|
||||||
process.env.MESH_RECONNECT_MS ?? '5000',
|
process.env.MESH_RECONNECT_MS ?? '5000',
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
|
// sends are pinned to the test channel ("test", index 3) on purpose — never the primary channel
|
||||||
|
private readonly sendChannel = parseInt(
|
||||||
|
process.env.MESH_SEND_CHANNEL ?? '3',
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
private socket: net.Socket | null = null;
|
private socket: net.Socket | null = null;
|
||||||
private buffer: Uint8Array = new Uint8Array(0);
|
private buffer: Uint8Array = new Uint8Array(0);
|
||||||
@@ -33,6 +144,17 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
number,
|
number,
|
||||||
import('@meshtastic/protobufs').Channel.Channel
|
import('@meshtastic/protobufs').Channel.Channel
|
||||||
>();
|
>();
|
||||||
|
// node database: NodeInfo frames from the initial sync + live POSITION_APP updates
|
||||||
|
private nodeMap = new Map<number, NodeEntry>();
|
||||||
|
// device metadata + per-section config/module-config streamed during the initial sync
|
||||||
|
private metadata: Proto.Mesh.DeviceMetadata | null = null;
|
||||||
|
private configSections = new Map<string, Record<string, unknown>>();
|
||||||
|
private moduleConfigSections = new Map<string, Record<string, unknown>>();
|
||||||
|
// message history (newest kept, capped at HISTORY_LIMIT) + pending tx records awaiting their routing ACK/NACK
|
||||||
|
private history: MessageRecord[] = [];
|
||||||
|
private pendingTx = new Map<number, MessageRecord>();
|
||||||
|
// telemetry history (newest kept, capped at HISTORY_LIMIT)
|
||||||
|
private telemetryHistory: TelemetryRecord[] = [];
|
||||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||||
private shuttingDown = false;
|
private shuttingDown = false;
|
||||||
|
|
||||||
@@ -46,6 +168,7 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.socket?.destroy();
|
this.socket?.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bridge connection state: `{ connected, myNodeNum, host, port }`. */
|
||||||
status() {
|
status() {
|
||||||
return {
|
return {
|
||||||
connected: this.connected,
|
connected: this.connected,
|
||||||
@@ -56,6 +179,7 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// psk/keys are deliberately not included — never expose them over HTTP
|
// psk/keys are deliberately not included — never expose them over HTTP
|
||||||
|
/** Device channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` sorted by index; empty until the initial sync completes. */
|
||||||
channels(): {
|
channels(): {
|
||||||
index: number;
|
index: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -72,23 +196,202 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device info captured during the initial sync: this device's node number, firmware version and
|
||||||
|
* metadata (DeviceMetadata as-is — it carries no key material), plus per-section config and
|
||||||
|
* module-config messages keyed by section name. In-memory only; empty until the sync completes,
|
||||||
|
* reset on every bridge reconnect. Key material is redacted: security publicKey/privateKey/adminKey
|
||||||
|
* and network wifiPsk are replaced with "REDACTED" — never exposed over HTTP. Proto uint64 fields
|
||||||
|
* (e.g. power.powermonEnables) come back as decimal strings so the output always JSON-serializes.
|
||||||
|
*/
|
||||||
|
deviceInfo(): DeviceInfo {
|
||||||
|
const out: DeviceInfo = {
|
||||||
|
myNodeNum: this.myNodeNum,
|
||||||
|
localConfig: {},
|
||||||
|
moduleConfig: {},
|
||||||
|
};
|
||||||
|
if (this.metadata) {
|
||||||
|
out.firmwareVersion = this.metadata.firmwareVersion;
|
||||||
|
out.metadata = jsonSafe(this.metadata);
|
||||||
|
}
|
||||||
|
for (const [name, value] of this.configSections)
|
||||||
|
out.localConfig[name] = jsonSafe(redactSection(name, value));
|
||||||
|
for (const [name, value] of this.moduleConfigSections)
|
||||||
|
out.moduleConfig[name] = jsonSafe(value);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lean summaries of all known nodes, sorted by node number.
|
||||||
|
* Names/hwModel/batteryLevel/snr/hopsAway/viaMqtt appear only when the device reported them;
|
||||||
|
* `lastHeard` is epoch seconds (null = never heard); `position` only when both coordinates are known.
|
||||||
|
*/
|
||||||
|
async nodes(): Promise<NodeSummary[]> {
|
||||||
|
const names = await loadHwModelNames();
|
||||||
|
return [...this.nodeMap.values()]
|
||||||
|
.sort((a, b) => a.num - b.num)
|
||||||
|
.map((n) => this.summarize(n, names));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up one node: exact node number first (numbers and numeric strings),
|
||||||
|
* then case-insensitive EXACT long/short name, else the unique case-insensitive
|
||||||
|
* SUBSTRING match across long/short names. Returns null when nothing or more
|
||||||
|
* than one node matches. Reused by send-by-name lookups.
|
||||||
|
*/
|
||||||
|
async node(idOrName: string | number): Promise<NodeSummary | null> {
|
||||||
|
const q = String(idOrName).trim().toLowerCase();
|
||||||
|
if (!q) return null;
|
||||||
|
const names = await loadHwModelNames();
|
||||||
|
if (/^\d+$/.test(q)) {
|
||||||
|
const hit = this.nodeMap.get(Number(q));
|
||||||
|
if (hit) return this.summarize(hit, names);
|
||||||
|
}
|
||||||
|
const all = [...this.nodeMap.values()];
|
||||||
|
// exact name match wins over substring; a string always contains itself, so an ambiguous exact set can never become unique below
|
||||||
|
const exact = all.filter(
|
||||||
|
(n) =>
|
||||||
|
n.user &&
|
||||||
|
(n.user.longName.toLowerCase() === q ||
|
||||||
|
n.user.shortName.toLowerCase() === q),
|
||||||
|
);
|
||||||
|
if (exact.length === 1) return this.summarize(exact[0], names);
|
||||||
|
const sub = all.filter(
|
||||||
|
(n) =>
|
||||||
|
n.user &&
|
||||||
|
(n.user.longName.toLowerCase().includes(q) ||
|
||||||
|
n.user.shortName.toLowerCase().includes(q)),
|
||||||
|
);
|
||||||
|
if (sub.length === 1) return this.summarize(sub[0], names);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a send target to a node number for sendText().
|
||||||
|
* undefined/"" and "!b"/"broadcast" (case-insensitive, trimmed) → BROADCAST_ADDR;
|
||||||
|
* numbers pass through as-is (must be an integer in 1..0xffffffff); numeric strings are parsed the same way;
|
||||||
|
* anything else is looked up via node() — throws `no node matching '<to>'` when nothing or more than one matches.
|
||||||
|
*/
|
||||||
|
async resolveDest(to?: number | string): Promise<number> {
|
||||||
|
if (typeof to === 'number') {
|
||||||
|
this.assertNodeNum(to);
|
||||||
|
return to;
|
||||||
|
}
|
||||||
|
const s = String(to ?? '').trim();
|
||||||
|
if (!s) return BROADCAST_ADDR; // omitted or empty → broadcast
|
||||||
|
if (s === '!b' || s.toLowerCase() === 'broadcast') return BROADCAST_ADDR;
|
||||||
|
if (/^\d+$/.test(s)) {
|
||||||
|
this.assertNodeNum(Number(s));
|
||||||
|
return Number(s);
|
||||||
|
}
|
||||||
|
const hit = await this.node(s);
|
||||||
|
if (!hit) throw new Error(`no node matching '${to}'`);
|
||||||
|
return hit.num;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertNodeNum(n: number): void {
|
||||||
|
if (!Number.isInteger(n) || n < 1 || n > BROADCAST_ADDR)
|
||||||
|
throw new Error(`invalid node number '${n}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message history, newest first: inbound TEXT_MESSAGE_APP packets (dir 'rx') and our sends (dir 'tx').
|
||||||
|
* tx records start 'pending' and move to 'delivered'/'failed' when the routing ACK/NACK arrives;
|
||||||
|
* broadcasts stay 'pending' because firmware strips want_ack before TX. In-memory only, last 100, reset on reconnect.
|
||||||
|
*/
|
||||||
|
messages(): MessageRecord[] {
|
||||||
|
return [...this.history].reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telemetry records, newest first: TELEMETRY_APP packets from any node — device metrics
|
||||||
|
* (battery/voltage/uptime/channel+air utilization) and environment sensor readings where nodes have them.
|
||||||
|
* `kind` is the meshtastic.Telemetry oneof case name; `data` is that variant's payload with proto uint64
|
||||||
|
* fields as decimal strings, unset optional fields omitted. In-memory only, last 100, reset on reconnect.
|
||||||
|
*/
|
||||||
|
telemetry(): TelemetryRecord[] {
|
||||||
|
return [...this.telemetryHistory].reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send `text` to node `to` on the pinned test channel; returns the packet id. Throws ServiceUnavailable when the bridge is down. */
|
||||||
async sendText(to: number, text: string): Promise<number> {
|
async sendText(to: number, text: string): Promise<number> {
|
||||||
if (!this.connected || !this.socket)
|
if (!this.connected || !this.socket)
|
||||||
throw new ServiceUnavailableException('bridge not connected');
|
throw new ServiceUnavailableException('bridge not connected');
|
||||||
const { bytes, id } = await buildTextToRadio(to, text);
|
const { bytes, id } = await buildTextToRadio(to, text, this.sendChannel);
|
||||||
|
const rec: MessageRecord = {
|
||||||
|
id,
|
||||||
|
dir: 'tx',
|
||||||
|
to,
|
||||||
|
text,
|
||||||
|
channel: this.sendChannel,
|
||||||
|
at: Date.now(),
|
||||||
|
status: 'pending',
|
||||||
|
};
|
||||||
|
this.pushHistory(rec);
|
||||||
|
this.pendingTx.set(id, rec);
|
||||||
this.socket.write(bytes);
|
this.socket.write(bytes);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private pushHistory(rec: MessageRecord): void {
|
||||||
|
this.history.push(rec);
|
||||||
|
// ponytail: shift on a ≤100 array is fine; true ring buffer only if this ever gets hot
|
||||||
|
if (this.history.length > HISTORY_LIMIT) this.history.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
private pushTelemetry(rec: TelemetryRecord): void {
|
||||||
|
this.telemetryHistory.push(rec);
|
||||||
|
// ponytail: shift on a ≤100 array is fine; true ring buffer only if this ever gets hot
|
||||||
|
if (this.telemetryHistory.length > HISTORY_LIMIT)
|
||||||
|
this.telemetryHistory.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
private summarize(n: NodeEntry, names: Map<number, string>): NodeSummary {
|
||||||
|
const s: NodeSummary = { num: n.num, lastHeard: n.lastHeard || null };
|
||||||
|
if (n.user?.longName) s.longName = n.user.longName;
|
||||||
|
if (n.user?.shortName) s.shortName = n.user.shortName;
|
||||||
|
// omit UNSET (enum value 0) rather than report "UNSET"
|
||||||
|
const hwName = n.user?.hwModel ? names.get(n.user.hwModel) : undefined;
|
||||||
|
if (hwName && hwName !== 'UNSET') s.hwModel = hwName;
|
||||||
|
// optional proto fields: undefined when the device never reported them
|
||||||
|
const battery = n.deviceMetrics?.batteryLevel;
|
||||||
|
if (battery !== undefined) s.batteryLevel = battery;
|
||||||
|
// non-optional proto scalars decode with their defaults (snr=0, viaMqtt=false), so a default value means "not reported"
|
||||||
|
if (n.snr !== undefined && n.snr !== 0) s.snr = n.snr;
|
||||||
|
if (n.hopsAway !== undefined) s.hopsAway = n.hopsAway; // optional in proto → truly absent when not reported
|
||||||
|
if (n.viaMqtt === true) s.viaMqtt = true;
|
||||||
|
const p = n.position;
|
||||||
|
if (p?.latitudeI !== undefined && p.longitudeI !== undefined) {
|
||||||
|
// alt included only when the device sent it — protobuf-es leaves absent optionals undefined, so an explicit 0 (sea level) is kept
|
||||||
|
s.position = { lat: p.latitudeI * 1e-7, lon: p.longitudeI * 1e-7 };
|
||||||
|
if (p.altitude !== undefined) s.position.alt = p.altitude;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
private connect(): void {
|
private connect(): void {
|
||||||
if (this.shuttingDown) return;
|
if (this.shuttingDown) return;
|
||||||
const socket = net.createConnection({ host: this.host, port: this.port });
|
const socket = net.createConnection({ host: this.host, port: this.port });
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
// fresh sync on every (re)connect — drop stale channels from the old session
|
// fresh sync on every (re)connect — drop stale channels/nodes/device info/history from the old session
|
||||||
this.channelMap = new Map();
|
this.channelMap = new Map();
|
||||||
|
this.nodeMap = new Map();
|
||||||
|
this.metadata = null;
|
||||||
|
this.configSections.clear();
|
||||||
|
this.moduleConfigSections.clear();
|
||||||
|
this.history = [];
|
||||||
|
this.telemetryHistory = [];
|
||||||
|
this.pendingTx.clear();
|
||||||
this.logger.log(`bridge connected ${this.host}:${this.port}`);
|
this.logger.log(`bridge connected ${this.host}:${this.port}`);
|
||||||
|
const nonce = Math.floor(Math.random() * 0xffffffff) || 1;
|
||||||
|
buildWantConfigToRadio(nonce)
|
||||||
|
.then((bytes) => socket.write(frame(bytes)))
|
||||||
|
.catch((err: unknown) =>
|
||||||
|
this.logger.error(`want_config failed: ${String(err)}`),
|
||||||
|
);
|
||||||
|
this.logger.log(`requested initial sync (want_config_id=${nonce})`);
|
||||||
});
|
});
|
||||||
socket.on('data', (chunk) => {
|
socket.on('data', (chunk) => {
|
||||||
const merged = new Uint8Array(this.buffer.length + chunk.length);
|
const merged = new Uint8Array(this.buffer.length + chunk.length);
|
||||||
@@ -122,9 +425,9 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectMs);
|
this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleFromRadio(
|
private async handleFromRadio(
|
||||||
fr: import('@meshtastic/protobufs').Mesh.FromRadio,
|
fr: import('@meshtastic/protobufs').Mesh.FromRadio,
|
||||||
): void {
|
): Promise<void> {
|
||||||
const v = fr.payloadVariant;
|
const v = fr.payloadVariant;
|
||||||
switch (v?.case) {
|
switch (v?.case) {
|
||||||
case 'myInfo':
|
case 'myInfo':
|
||||||
@@ -135,9 +438,28 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// device streams one Channel per table slot during initial sync
|
// device streams one Channel per table slot during initial sync
|
||||||
this.channelMap.set(v.value.index, v.value);
|
this.channelMap.set(v.value.index, v.value);
|
||||||
break;
|
break;
|
||||||
|
case 'nodeInfo':
|
||||||
|
// device streams the whole node DB during initial sync
|
||||||
|
this.nodeMap.set(v.value.num, v.value);
|
||||||
|
break;
|
||||||
|
case 'metadata':
|
||||||
|
// DeviceMetadata streamed once during initial sync (no key material in it)
|
||||||
|
this.metadata = v.value;
|
||||||
|
break;
|
||||||
|
case 'config': {
|
||||||
|
// device streams one Config per section during initial sync
|
||||||
|
const sec = v.value.payloadVariant;
|
||||||
|
if (sec?.case) this.configSections.set(sec.case, sec.value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'moduleConfig': {
|
||||||
|
const mod = v.value.payloadVariant;
|
||||||
|
if (mod?.case) this.moduleConfigSections.set(mod.case, mod.value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'configCompleteId':
|
case 'configCompleteId':
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`bridge ready (config complete), ${this.channelMap.size} channels`,
|
`bridge ready (config complete), ${this.channelMap.size} channels, ${this.nodeMap.size} nodes`,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'packet': {
|
case 'packet': {
|
||||||
@@ -147,9 +469,71 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
? pkt.payloadVariant.value
|
? pkt.payloadVariant.value
|
||||||
: undefined;
|
: undefined;
|
||||||
if (decoded && decoded.portnum === PORT_TEXT_MESSAGE_APP) {
|
if (decoded && decoded.portnum === PORT_TEXT_MESSAGE_APP) {
|
||||||
this.logger.log(
|
const text = new TextDecoder().decode(decoded.payload);
|
||||||
`[mesh] ${pkt.from} -> ${pkt.to}: "${new TextDecoder().decode(decoded.payload)}"`,
|
this.logger.log(`[mesh] ${pkt.from} -> ${pkt.to}: "${text}"`);
|
||||||
);
|
this.pushHistory({
|
||||||
|
id: pkt.id,
|
||||||
|
dir: 'rx',
|
||||||
|
from: pkt.from,
|
||||||
|
text,
|
||||||
|
channel: pkt.channel ?? 0,
|
||||||
|
at: Date.now(),
|
||||||
|
});
|
||||||
|
} else if (decoded && decoded.portnum === PORT_POSITION_APP) {
|
||||||
|
// live position update: the Position is serialized in Data.payload
|
||||||
|
let pos: Proto.Mesh.Position | undefined;
|
||||||
|
try {
|
||||||
|
pos = await decodePosition(decoded.payload);
|
||||||
|
} catch {
|
||||||
|
pos = undefined; // malformed payload — skip, keep whatever we had
|
||||||
|
}
|
||||||
|
if (pos?.latitudeI !== undefined && pos.longitudeI !== undefined) {
|
||||||
|
const num = pkt.from;
|
||||||
|
this.nodeMap.set(num, {
|
||||||
|
...this.nodeMap.get(num),
|
||||||
|
num,
|
||||||
|
position: pos,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (decoded && decoded.portnum === PORT_ROUTING_APP) {
|
||||||
|
// ACK/NACK for our want_ack sends: Routing serialized in Data.payload,
|
||||||
|
// Data.requestId = the original packet id we sent
|
||||||
|
let routing: Proto.Mesh.Routing | undefined;
|
||||||
|
try {
|
||||||
|
routing = await decodeRouting(decoded.payload);
|
||||||
|
} catch {
|
||||||
|
routing = undefined; // malformed payload — skip
|
||||||
|
}
|
||||||
|
if (routing?.variant.case === 'errorReason' && decoded.requestId) {
|
||||||
|
const rec = this.pendingTx.get(decoded.requestId);
|
||||||
|
if (rec) {
|
||||||
|
const reason = routing.variant.value;
|
||||||
|
if (reason === 0)
|
||||||
|
rec.status = 'delivered'; // Routing_Error.NONE
|
||||||
|
else {
|
||||||
|
rec.status = 'failed';
|
||||||
|
rec.ackError = await describeRoutingError(reason);
|
||||||
|
}
|
||||||
|
this.pendingTx.delete(decoded.requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (decoded && decoded.portnum === PORT_TELEMETRY_APP) {
|
||||||
|
// live telemetry: the Telemetry is serialized in Data.payload
|
||||||
|
let tel: Proto.Telemetry.Telemetry | undefined;
|
||||||
|
try {
|
||||||
|
tel = await decodeTelemetry(decoded.payload);
|
||||||
|
} catch {
|
||||||
|
tel = undefined; // malformed payload — skip
|
||||||
|
}
|
||||||
|
const variant = tel?.variant;
|
||||||
|
if (variant?.case) {
|
||||||
|
this.pushTelemetry({
|
||||||
|
from: pkt.from,
|
||||||
|
at: Date.now(),
|
||||||
|
kind: variant.case,
|
||||||
|
data: jsonSafe(variant.value),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user