96 lines
5.3 KiB
Markdown
96 lines
5.3 KiB
Markdown
# mesh-bot
|
|
|
|
Minimal NestJS bot for Meshtastic. Long-running service that TCP-connects to a
|
|
Meshtastic serial bridge (raw socat pipe) at `192.168.1.4:4403`, sends
|
|
`want_config_id` on connect so the firmware streams its initial sync data,
|
|
parses inbound protobuf frames, logs received text messages, and exposes HTTP
|
|
endpoints to send text.
|
|
|
|
## Local run
|
|
|
|
```sh
|
|
npm i
|
|
npm run start:dev
|
|
```
|
|
|
|
## Docker
|
|
|
|
```sh
|
|
docker compose up -d --build
|
|
```
|
|
|
|
Exposed via Traefik only (no published ports) at `http://bot.dev.kkhost.pl`.
|
|
|
|
## Endpoints
|
|
|
|
All API endpoints live under the `/api` prefix:
|
|
|
|
- `GET /api/status` — `{ connected, myNodeNum, host, port }`
|
|
- `POST /api/send` — body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast"> }`, returns `{ id }`. Numbers pass through as-is (integer in 1..0xffffffff); names resolve like `GET /api/nodes/:id` (exact long/short name, then unique substring) — unknown or ambiguous targets are a 400
|
|
- `GET /api/channels` — the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` (psk/keys are redacted; empty until the initial sync completes)
|
|
- `GET /api/device` — device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {<section>: ...}, moduleConfig: {<module>: ...} }`. Key material is never exposed (security publicKey/privateKey/adminKey and network wifiPsk are replaced with `"REDACTED"`); in-memory only, reset on every bridge reconnect
|
|
- `GET /api/messages` — message history, newest first: inbound text (`dir:'rx'`, with `from`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` (with human-readable `ackError`) when the routing ACK/NACK arrives; broadcasts stay `pending` because firmware strips want_ack before TX. In-memory only, last 100, reset on every bridge reconnect
|
|
- `GET /api/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
|
|
`/docs-json`.
|
|
|
|
```sh
|
|
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 http://localhost:3000/api/nodes
|
|
curl http://localhost:3000/api/nodes/kamil
|
|
```
|
|
|
|
`to` omitted, `"!b"`, or `"broadcast"` (case-insensitive) sends to the
|
|
broadcast address `0xffffffff`; anything else is a node number or name.
|
|
DMs are unicast and wantAck-tracked via `GET /api/messages`; broadcasts stay
|
|
pending because firmware strips want_ack before TX.
|
|
|
|
All sends always target channel index 3 (`test`) — never the primary
|
|
channel. Override with `MESH_SEND_CHANNEL`.
|
|
|
|
## MCP server
|
|
|
|
The bot also exposes an MCP (Model Context Protocol) endpoint at
|
|
`POST /mcp` (outside the `/api` prefix), served in-process over the SDK's
|
|
Streamable HTTP transport in stateless mode. It maps 1:1 to the REST
|
|
endpoints — no new logic, same channel pinning:
|
|
|
|
- `mesh_status` — bridge connection state (`{ connected, myNodeNum, host, port }`)
|
|
- `send_message(text, to?)` — send a text message; `to` resolves like `POST /api/send` (omitted/""/"!b"/"broadcast" → broadcast, number or numeric string → node number, else name lookup); returns `{ id }`; unknown/ambiguous targets are tool errors
|
|
- `list_channels` — device channels (keys redacted)
|
|
- `list_nodes` — known nodes as lean summaries
|
|
- `get_node(id)` — one node by number or name; tool error when missing/ambiguous
|
|
- `get_messages` — message history, newest first (last 100, tx records carry delivery status)
|
|
- `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 `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
|
|
connected to the radio, this bot will not get a connection.
|