Compare commits

...
8 Commits
Author SHA1 Message Date
kamilkrzywda 756086d107 Inbox injection endpoint: POST /api/messages + MCP inject_message (#12) (#18)
CI / build + unit (push) Successful in 1m14s
2026-09-25 01:51:50 +02:00
kamilkrzywda 0571c7095e Telemetry stream: GET /api/telemetry + MCP get_telemetry (#16)
CI / build + unit (push) Successful in 21s
2026-09-24 23:56:11 +02:00
kamilkrzywda ba5e60daf0 Fix BigInt serialization in GET /api/device (500 on real hardware) (#14)
CI / build + unit (push) Successful in 18s
2026-09-24 23:14:32 +02:00
kamilkrzywda 4beaa377c4 Device info endpoint + richer node summaries (GET /api/device, MCP device_info) (#13)
CI / build + unit (push) Successful in 36s
2026-09-24 22:46:40 +02:00
kamilkrzywda b31fb35464 MCP server at POST /mcp (official SDK, stateless Streamable HTTP) (#11)
CI / build + unit (push) Successful in 1m6s
2026-09-24 20:39:45 +02:00
kamilkrzywda 108aa400d3 Publish on 'test' channel (index 3) by default (#10)
CI / build + unit (push) Successful in 34s
2026-09-24 19:44:11 +02:00
kamilkrzywda f640f6658c Send to by node name or broadcast sentinel (GET-free resolution via node DB) (#9)
CI / build + unit (push) Successful in 33s
2026-09-24 16:47:51 +02:00
kamilkrzywda 57ad27d66f Message history with delivery tracking (GET /api/messages) (#8)
CI / build + unit (push) Successful in 32s
2026-09-24 16:19:32 +02:00
14 changed files with 2103 additions and 111 deletions
+2 -1
View File
@@ -17,7 +17,8 @@
## API
1. **Live base URL** — `http://bot.dev.kkhost.pl` (Traefik → docker-compose stack on this host; container port 3000 via external network dev_net, no published ports).
2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: <node number | "!b" | "broadcast"> }`, returns `{ id }`), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? } }[]`, 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 2 (`testowy`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `2`).
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), `POST /api/messages` (body `{ "text": "...", "from"?: <node number> }`, returns `{ id }`; synthetic inbox injection for external triggers — records an rx entry in the history as if received on the mesh (channel 0) so external systems can push messages to trigger communication later; NOT transmitted over RF, nothing goes to the bridge or air; appears in `GET /api/messages` like any other rx message), `GET /api/telemetry` (telemetry history newest first as `{ from, at /* epoch ms */, kind, data }[]`: live TELEMETRY_APP packets — device metrics and environment sensor readings where nodes have them; kind is the meshtastic.Telemetry oneof case name (deviceMetrics/environmentMetrics/airQualityMetrics/powerMetrics/localStats/healthMetrics/hostMetrics/trafficManagementStats), data that variant's payload with proto uint64 fields as decimal strings; last 100, in-memory, reset on reconnect), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, from the initial sync plus live POSITION_APP updates; reset on reconnect) and `GET /api/nodes/:id` (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. All `POST /api/send` traffic targets channel index 3 (`test`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `3`).
3. **MCP endpoint** — `POST /mcp` (excluded from the `/api` prefix in main.ts, like Scalar at `/`): an in-process MCP server over the official SDK's Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`, fresh `McpServer` + transport per request). Tools map 1:1 to MeshService methods: `mesh_status`, `send_message(text, to?)` (same resolution as `POST /api/send`; errors surface as tool errors with the same messages), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), `get_messages`, `inject_message(text, from?)` (synthetic inbox injection for external triggers — records an rx entry in the history as if received on the mesh; NOT sent over RF; same validation as `POST /api/messages`), `get_telemetry` (same bounded recent window as `GET /api/telemetry`), `device_info` (same redaction as `GET /api/device`). Results are JSON in a text content block; sends ride channel index 3 like everything else. Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (`@modelcontextprotocol/sdk/server/mcp.js` etc.).
## Other
+55
View File
@@ -1,5 +1,60 @@
# Changelog
## v0.9.0 — 2026-09-24 23:45
- New `POST /api/messages` endpoint (and matching MCP tool `inject_message`, now nine tools): synthetic inbox injection for external triggers. Body `{ "text": "...", "from"?: <node number> }` records an rx entry in the message history as if received on the mesh (channel 0), so external systems can push messages into the bot's inbox to trigger communication later. NOT transmitted over RF — nothing is written to the bridge or air; the record appears in `GET /api/messages` like any other rx message and is subject to the same last-100 cap. Returns `{ id }` with a random uint32 packet id (same generation as tx sends).
- New service method `MeshService.injectMessage(text, from?)`: pushes an rx record through the existing `pushHistory` cap pattern; `from` is optional and omitted from the record when absent (integer in 1..0xffffffff when provided — invalid values throw like `resolveDest`). Packet-id generation extracted from `buildTextToRadio` into a shared `randomPacketId()` helper so tx sends and injected records use one source of truth.
- Validation: empty/missing text → 400 `text must be a non-empty string`; invalid `from` (non-integer, <1 or >0xffffffff) → 400 `invalid node number '<n>'`. Same messages surface as MCP tool errors for `inject_message`.
- Swagger: `POST /api/messages` documented with the synthetic-injection caveat and an example. README + AGENTS.md endpoint/tool lists updated.
- Tests: new service specs (injected rx records newest-first via `messages()`, `from` omitted when absent, empty-text/invalid-from rejection, cap at 100 dropping the oldest, shared buffer with captured packets); MCP spec updated to nine tools incl. an `inject_message` call check asserting nothing reaches the bridge socket (no ToRadio 'packet' variant on the wire).
## 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.
+46 -5
View File
@@ -26,9 +26,13 @@ Exposed via Traefik only (no published ports) at `http://bot.dev.kkhost.pl`.
All API endpoints live under the `/api` prefix:
- `GET /api/status` — `{ connected, myNodeNum, host, port }`
- `POST /api/send` — body `{ "text": "...", "to"?: <node number | "!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/nodes` — known mesh nodes as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? } }[]`, 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), and `position` only when both coordinates are known
- `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
- `POST /api/messages` — body `{ "text": "...", "from"?: <node number> }`, returns `{ id }`. Synthetic inbox injection for external triggers: records an rx entry in the history as if received on the mesh (channel 0) so external systems can push messages to trigger communication later. NOT transmitted over RF; appears in `GET /api/messages` like any other rx message
- `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
@@ -39,21 +43,58 @@ 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 '{"to":123456789,"text":"hello"}'
curl http://localhost:3000/api/channels
curl http://localhost:3000/api/device
curl http://localhost:3000/api/messages
curl -X POST http://localhost:3000/api/messages -H 'content-type: application/json' -d '{"text":"external trigger"}'
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 2 (`testowy`) — never the primary
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)
- `inject_message(text, from?)` — synthetic inbox injection for external triggers: records an rx entry in the history as if received on the mesh; NOT sent over RF; appears in `get_messages` like any other rx record
- `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
- `MESH_HOST` (default `192.168.1.4`) — bridge host
- `MESH_PORT` (default `4403`) — bridge port
- `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms
- `MESH_SEND_CHANNEL` (default `2`) — channel index all sends target (`testowy`; never the primary channel)
- `MESH_SEND_CHANNEL` (default `3`) — channel index all sends target (`test`; never the primary channel)
- `PORT` (default `3000`) — HTTP listen port
Note: the serial bridge accepts only ONE client at a time — if another app is
+211 -8
View File
@@ -1,16 +1,17 @@
{
"name": "mesh-bot",
"version": "0.0.1",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mesh-bot",
"version": "0.0.1",
"version": "0.5.0",
"license": "UNLICENSED",
"dependencies": {
"@bufbuild/protobuf": "^2.15.0",
"@meshtastic/protobufs": "^2.8.0",
"@modelcontextprotocol/sdk": "^1.30.1",
"@nestjs/common": "^12.0.1",
"@nestjs/core": "^12.0.1",
"@nestjs/platform-express": "^12.0.1",
@@ -221,6 +222,18 @@
"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": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz",
@@ -617,6 +630,46 @@
"integrity": "sha512-p68VexhnH7ojf3U27RdryUDqnKJgKaWUbCL96vJR7N0mqDuhKG5OIECk3sCPPdynxjk+yoVhRkScqtv5KQjzsw==",
"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": {
"version": "12.0.5",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-12.0.5.tgz",
@@ -2179,7 +2232,6 @@
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -2196,7 +2248,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
@@ -2764,6 +2815,20 @@
"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": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2991,6 +3056,27 @@
"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": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@@ -3044,6 +3130,25 @@
"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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
@@ -3083,7 +3188,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-safe-stringify": {
@@ -3113,7 +3217,6 @@
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"dev": true,
"funding": [
{
"type": "github",
@@ -3428,6 +3531,15 @@
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -3618,6 +3730,15 @@
"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": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -3669,6 +3790,12 @@
"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": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@@ -3717,6 +3844,15 @@
"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": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
@@ -3750,9 +3886,14 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"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": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -4658,6 +4799,15 @@
"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": {
"version": "8.4.2",
"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"
}
},
"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": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
@@ -4850,7 +5009,6 @@
"version": "2.0.2",
"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==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5037,6 +5195,27 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
@@ -5866,6 +6045,21 @@
"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": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -5925,6 +6119,15 @@
"funding": {
"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
View File
@@ -1,6 +1,6 @@
{
"name": "mesh-bot",
"version": "0.2.0",
"version": "0.9.0",
"description": "",
"author": "",
"private": true,
@@ -23,6 +23,7 @@
"dependencies": {
"@bufbuild/protobuf": "^2.15.0",
"@meshtastic/protobufs": "^2.8.0",
"@modelcontextprotocol/sdk": "^1.30.1",
"@nestjs/common": "^12.0.1",
"@nestjs/core": "^12.0.1",
"@nestjs/platform-express": "^12.0.1",
+2 -1
View File
@@ -9,7 +9,8 @@ import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
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.
const docConfig = new DocumentBuilder()
+37 -2
View File
@@ -12,6 +12,10 @@ export const BROADCAST_ADDR = 0xffffffff;
export const PORT_TEXT_MESSAGE_APP = 1;
// verified against installed @meshtastic/protobufs Portnums.PortNum.POSITION_APP
export const PORT_POSITION_APP = 3;
// verified against installed @meshtastic/protobufs Portnums.PortNum.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 START2 = 0xc3;
@@ -71,6 +75,31 @@ export async function decodePosition(
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>> {
@@ -86,14 +115,20 @@ export async function loadHwModelNames(): Promise<Map<number, string>> {
return hwModelNames;
}
/** Random non-zero uint32 packet id. */
export function randomPacketId(): number {
let id = Math.floor(Math.random() * 0x100000000) >>> 0;
if (id === 0) id = 1; // non-zero uint32
return id;
}
export async function buildTextToRadio(
to: number,
text: string,
channelIndex: number,
): Promise<{ bytes: Uint8Array; id: number }> {
const P = await loadProto();
let id = Math.floor(Math.random() * 0x100000000) >>> 0;
if (id === 0) id = 1; // non-zero uint32
const id = randomPacketId();
const meshPacket = create(P.Mesh.MeshPacketSchema, {
to,
id,
+165
View File
@@ -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 },
};
}
+366
View File
@@ -0,0 +1,366 @@
import { fromBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
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, extractFrames } 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 nine 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',
'inject_message',
'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',
]);
const inject = tools.find((t) => t.name === 'inject_message')!;
expect(inject.inputSchema.required).toEqual(['text']);
expect(Object.keys(inject.inputSchema.properties ?? {})).toEqual([
'text',
'from',
]);
} 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('inject_message records an rx entry without sending over RF', async () => {
const env = await connectedService();
try {
const mcp = await connectMcp(env.svc);
try {
// capture outbound bytes: the only frame ever written on connect is want_config,
// so any 'packet' variant after this point would be an RF transmission
const out: Uint8Array[] = [];
env.sockets[0].on('data', (c) => out.push(c));
const r1 = await mcp.client.callTool({
name: 'inject_message',
arguments: { text: 'trigger' },
});
expect(r1.isError).not.toBe(true);
const inj = JSON.parse(toolText(r1)) as { id: number };
expect(inj.id).toBeGreaterThan(0);
expect(env.svc.messages()[0]).toMatchObject({
dir: 'rx',
text: 'trigger',
channel: 0,
});
const r2 = await mcp.client.callTool({
name: 'inject_message',
arguments: { text: 'from kamil', from: KAMIL_NUM },
});
expect(r2.isError).not.toBe(true);
expect(env.svc.messages()[0]).toMatchObject({
dir: 'rx',
from: KAMIL_NUM,
text: 'from kamil',
});
const r3 = await mcp.client.callTool({
name: 'inject_message',
arguments: { text: 'x', from: 0 },
});
expect(r3.isError).toBe(true);
expect(toolText(r3)).toContain('invalid node number');
// nothing went over the wire — synthetic inbox injection only.
// The want_config frame (written on connect) may or may not have been captured;
// what matters is that no ToRadio 'packet' variant ever made it to the bridge.
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
let off = 0;
for (const c of out) {
merged.set(c, off);
off += c.length;
}
for (const p of extractFrames(merged).packets) {
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
expect(tr.payloadVariant?.case).not.toBe('packet');
}
} finally {
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();
}
});
});
+141
View File
@@ -0,0 +1,141 @@
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(
'inject_message',
{
description: `Synthetic inbox injection for external triggers: records an rx entry in the message history as if received on the mesh — NOT sent over RF, nothing goes to the bridge or air. Appears in get_messages like any other rx record (channel 0). from is optional (integer in 1..0xffffffff); empty text or invalid from are tool errors.${CHANNEL_NOTE}`,
inputSchema: { text: z.string(), from: z.number().int().optional() },
},
async ({ text, from }) => jsonResult(mesh.injectMessage(text, from)),
);
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,
}),
);
}
}
}
}
+162 -15
View File
@@ -13,16 +13,28 @@ import {
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { BROADCAST_ADDR } from './codec.js';
import type { DeviceInfo } from './mesh.service.js';
import { MeshService } from './mesh.service.js';
export class InjectMessageDto {
@ApiProperty({ description: 'Message text', example: 'external trigger' })
text!: string;
@ApiProperty({
required: false,
description:
'Sender node number (integer in 1..0xffffffff); omitted when the external source has no mesh identity.',
example: 987654321,
})
from?: number;
}
export class SendTextDto {
@ApiProperty({ description: 'Message text', example: 'hello' })
text!: string;
@ApiProperty({
required: false,
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',
})
to?: string | number;
@@ -58,7 +70,7 @@ export class MeshController {
@ApiOperation({
summary: 'Send a text message over the mesh',
description:
'Queues a TEXT_MESSAGE_APP packet on the pinned test channel (index 2, "testowy" — never the primary channel). Returns the Meshtastic packet id.',
'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,
@@ -71,15 +83,13 @@ export class MeshController {
if (typeof body?.text !== 'string' || body.text.length === 0) {
throw new BadRequestException('text must be a non-empty string');
}
let to = BROADCAST_ADDR;
const t = body.to;
if (t !== undefined && t !== '!b' && t !== 'broadcast') {
const n = typeof t === 'number' ? t : Number(String(t));
if (!Number.isFinite(n))
throw new BadRequestException(
'to must be a decimal node number, "!b", or "broadcast"',
);
to = n;
let to: number;
try {
to = await this.mesh.resolveDest(body.to);
} catch (err) {
throw new BadRequestException(
err instanceof Error ? err.message : String(err),
);
}
return { id: await this.mesh.sendText(to, body.text) };
}
@@ -96,8 +106,8 @@ export class MeshController {
example: [
{ index: 0, name: 'Default', uplinkEnabled: true, downlinkEnabled: true },
{
index: 2,
name: 'testowy',
index: 3,
name: 'test',
uplinkEnabled: false,
downlinkEnabled: false,
},
@@ -107,11 +117,145 @@ export class MeshController {
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();
}
@Post('messages')
@ApiOperation({
summary: 'Inject a synthetic inbound message into the history',
description:
'Synthetic inbox injection for external triggers: records an rx entry in the message history as if received on the mesh, so external systems can push messages to trigger communication later. NOT transmitted over RF — nothing is sent to the bridge or air. The record appears in GET /api/messages like any other rx message (dir "rx", channel 0). Returns { id } with a random uint32 packet id.',
})
@ApiResponse({
status: 200,
description: 'Record injected',
example: { id: 123456789 },
})
@ApiResponse({ status: 400, description: 'Invalid text or from' })
injectMessage(@Body() body: InjectMessageDto) {
try {
return this.mesh.injectMessage(body?.text, body?.from);
} catch (err) {
throw new BadRequestException(
err instanceof Error ? err.message : String(err),
);
}
}
@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.',
'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,
@@ -125,6 +269,9 @@ export class MeshController {
batteryLevel: 87,
lastHeard: 1758000000,
position: { lat: 52.1049637, lon: 20.9833152, alt: 123 },
snr: -2.5,
hopsAway: 1,
viaMqtt: true,
},
],
})
+2 -1
View File
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { McpController } from './mcp.controller.js';
import { MeshController } from './mesh.controller.js';
import { MeshService } from './mesh.service.js';
@Module({
controllers: [MeshController],
controllers: [MeshController, McpController],
providers: [MeshService],
})
export class MeshModule {}
+619 -69
View File
@@ -1,62 +1,24 @@
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import * as net from 'node:net';
import type { AddressInfo } from 'node:net';
import { describe, expect, it } from 'vitest';
import { extractFrames, 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';
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(),
});
});
});
}
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));
}
}
function nodeInfoFrame(
num: number,
fields?: Partial<P.Mesh.NodeInfo>,
): Uint8Array {
const ni = create(P.Mesh.NodeInfoSchema, { num, ...fields });
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'nodeInfo', value: ni },
}),
),
);
}
// live position update: POSITION_APP packet with a serialized Position in Data.payload
function positionPacketFrame(
from: number,
pos: Partial<P.Mesh.Position>,
pos: { latitudeI?: number; longitudeI?: number },
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.POSITION_APP,
@@ -80,19 +42,62 @@ function positionPacketFrame(
);
}
const KAMIL_NUM = 123456789;
function kamilNodeInfo(): Partial<P.Mesh.NodeInfo> {
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 },
};
// inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload
function textPacketFrame(
from: number,
to: number,
text: string,
channel = 0,
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP,
payload: new TextEncoder().encode(text),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from,
to,
id: 1,
channel,
payloadVariant: { case: 'decoded', value: data },
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'packet', value: pkt },
}),
),
);
}
// 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(
@@ -238,11 +243,11 @@ describe('MeshService connect', () => {
});
describe('MeshService send', () => {
it('sends on the test channel (index 2) by default, never primary', async () => {
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: "testowy" (index 2)
delete process.env.MESH_SEND_CHANNEL; // default path: "test" (index 3)
const svc = new MeshService();
try {
svc.onModuleInit();
@@ -252,7 +257,7 @@ describe('MeshService send', () => {
const out: Uint8Array[] = [];
bridge.sockets[0].on('data', (c) => out.push(c));
const id = await svc.sendText(42, 'hello testowy');
const id = await svc.sendText(42, 'hello test');
expect(id).toBeGreaterThan(0);
const readOutbound = (): P.Mesh.MeshPacket | undefined => {
@@ -274,7 +279,7 @@ describe('MeshService send', () => {
await waitFor(() => readOutbound() !== undefined);
const pkt = readOutbound()!;
expect(pkt.channel).toBe(2); // "testowy" — never the primary channel (0)
expect(pkt.channel).toBe(3); // "test" — never the primary channel (0)
expect(pkt.to).toBe(42);
const decoded =
pkt.payloadVariant?.case === 'decoded'
@@ -282,7 +287,7 @@ describe('MeshService send', () => {
: undefined;
expect(
new TextDecoder().decode(decoded?.payload ?? new Uint8Array()),
).toBe('hello testowy');
).toBe('hello test');
} finally {
delete process.env.MESH_SEND_CHANNEL;
svc.onModuleDestroy();
@@ -437,3 +442,548 @@ describe('MeshService node database', () => {
}
});
});
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 injectMessage', () => {
it('injects rx records that appear newest-first in messages()', () => {
const svc = new MeshService();
expect(svc.messages()).toEqual([]);
const r1 = svc.injectMessage('first');
const r2 = svc.injectMessage('second', KAMIL_NUM);
expect(r1.id).toBeGreaterThan(0); // random non-zero uint32, like tx ids
expect(r2.id).toBeGreaterThan(0);
expect(svc.messages()).toEqual([
{
id: r2.id,
dir: 'rx',
from: KAMIL_NUM,
text: 'second',
channel: 0,
at: expect.any(Number),
},
{
id: r1.id,
dir: 'rx',
text: 'first',
channel: 0,
at: expect.any(Number),
},
]);
});
it('omits from when not provided and rejects empty text or invalid from', () => {
const svc = new MeshService();
const rec = svc.injectMessage('no sender');
expect(svc.messages()[0]).toEqual({
id: rec.id,
dir: 'rx',
text: 'no sender',
channel: 0,
at: expect.any(Number),
});
expect(svc.messages()[0]).not.toHaveProperty('from');
for (const bad of ['', undefined]) {
expect(() => svc.injectMessage(bad as unknown as string)).toThrow(
'text must be a non-empty string',
);
}
for (const bad of [0, -1, 42.5, BROADCAST_ADDR + 1]) {
expect(() => svc.injectMessage('x', bad)).toThrow(/invalid node number/);
}
expect(svc.messages()).toHaveLength(1); // rejected calls add nothing
});
it('caps history at 100 records, dropping the oldest', () => {
const svc = new MeshService();
for (let i = 0; i < 105; i++) svc.injectMessage(`m-${i}`);
expect(svc.messages()).toHaveLength(100);
expect(svc.messages()[0]?.text).toBe('m-104'); // newest first
expect(svc.messages()[99]?.text).toBe('m-5'); // m-0..m-4 dropped
});
it('shares the history buffer with captured packets', 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,
);
svc.injectMessage('injected');
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'real', 2));
await waitFor(() => svc.messages().length === 2);
expect(svc.messages()[0]).toMatchObject({ dir: 'rx', text: 'real' }); // newest first
expect(svc.messages()[1]).toMatchObject({
dir: 'rx',
text: 'injected',
channel: 0,
});
} 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();
}
});
});
+293 -8
View File
@@ -8,17 +8,57 @@ import {
import * as net from 'node:net';
import type * as Proto from '@meshtastic/protobufs';
import {
BROADCAST_ADDR,
PORT_POSITION_APP,
PORT_ROUTING_APP,
PORT_TELEMETRY_APP,
PORT_TEXT_MESSAGE_APP,
buildTextToRadio,
buildWantConfigToRadio,
decodeFromRadio,
decodePosition,
decodeRouting,
decodeTelemetry,
describeRoutingError,
extractFrames,
frame,
loadHwModelNames,
randomPacketId,
} 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'>>;
@@ -34,6 +74,50 @@ export interface NodeSummary {
/** 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()
@@ -46,9 +130,9 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
process.env.MESH_RECONNECT_MS ?? '5000',
10,
);
// sends are pinned to the test channel ("testowy", index 2) on purpose — never the primary channel
// 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 ?? '2',
process.env.MESH_SEND_CHANNEL ?? '3',
10,
);
@@ -63,6 +147,15 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
>();
// 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 shuttingDown = false;
@@ -104,9 +197,34 @@ 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 appear only when the device reported them;
* 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[]> {
@@ -149,15 +267,111 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
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();
}
/**
* Inject a synthetic inbound (rx) record into the message history — as if received on the mesh.
* NOT transmitted over RF: nothing is written to the bridge or air; the record exists so external
* systems can push messages into the bot's inbox to trigger communication later. `from` is optional
* (integer in 1..0xffffffff when provided) and omitted from the record when absent. The record rides
* channel 0 like a primary-channel capture and appears in messages() newest first, subject to the
* same HISTORY_LIMIT cap as real captures. Returns { id } with a random uint32 packet id (same
* generation as tx sends). Throws on empty text or an invalid `from`.
*/
injectMessage(text: string, from?: number): { id: number } {
if (typeof text !== 'string' || text.length === 0)
throw new Error('text must be a non-empty string');
if (from !== undefined) this.assertNodeNum(from);
const rec: MessageRecord = {
id: randomPacketId(),
dir: 'rx',
...(from !== undefined ? { from } : {}),
text,
channel: 0,
at: Date.now(),
};
this.pushHistory(rec);
return { id: rec.id };
}
/**
* 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> {
if (!this.connected || !this.socket)
throw new ServiceUnavailableException('bridge not connected');
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);
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;
@@ -165,9 +379,13 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
// 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 field: undefined when the device never reported metrics
// 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
@@ -183,9 +401,15 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
this.socket = socket;
socket.on('connect', () => {
this.connected = true;
// fresh sync on every (re)connect — drop stale channels/nodes 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.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}`);
const nonce = Math.floor(Math.random() * 0xffffffff) || 1;
buildWantConfigToRadio(nonce)
@@ -244,6 +468,21 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
// 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':
this.logger.log(
`bridge ready (config complete), ${this.channelMap.size} channels, ${this.nodeMap.size} nodes`,
@@ -256,9 +495,16 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
? pkt.payloadVariant.value
: undefined;
if (decoded && decoded.portnum === PORT_TEXT_MESSAGE_APP) {
this.logger.log(
`[mesh] ${pkt.from} -> ${pkt.to}: "${new TextDecoder().decode(decoded.payload)}"`,
);
const text = 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;
@@ -275,6 +521,45 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
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;
}