Second production zombie slipped past v0.21.1's watchdog: text+telemetry RX dead for >1 h while TX worked and the watchdog never fired. Root cause proven at runtime via /proc/net/snmp — InSegs delta = 2 segments per 30 s (OutSegs 0): the bridge peer keeps dribbling small NON-frame bytes (~every 15 s, keepalive noise or a desynced stream) and v0.21.1 updated lastPacketAt on EVERY socket chunk, so 'no traffic for MESH_IDLE_TIMEOUT_MS' was never true even though zero valid Meshtastic frames decoded. - The 'data' handler no longer updates lastPacketAt per chunk; it is updated only when extractFrames yields >=1 valid packet (the reset in the 'connect' handler is unchanged), so a stream that decodes nothing trips the watchdog after MESH_IDLE_TIMEOUT_MS even while raw bytes keep arriving. - Buffer-overflow backstop: if the undecoded frame buffer exceeds MESH_MAX_FRAME_BUFFER (new env, default 256000) — e.g. one data event of a desynced stream that never forms frames — log ERROR and destroy the socket; the existing close -> scheduleReconnect loop self-heals it. The undecoded buffer is also reset on every (re)connect so leftover garbage from a dead session can't re-trigger the backstop or corrupt the first frames of a healthy new stream. Tests: 169 -> 171 — raw non-frame bytes do NOT keep the watchdog alive (garbage-only stream forces disconnect + reconnect, today's incident shape), and an undecoded chunk over MESH_MAX_FRAME_BUFFER triggers destroy + a fresh session that decodes normally (cap env-overridden to 100 since Node delivers socket data in ~64 KB events, far below the 256_000 default).
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
npm i
npm run start:dev
Docker
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">, "replyId"?: <packet id> }, returns{ id }. Numbers pass through as-is (integer in 1..0xffffffff); names resolve likeGET /api/nodes/:id(exact long/short name, then unique substring) — unknown or ambiguous targets are a 400. OptionalreplyId(integer in 1..0xffffffff) references an earlier message's packet id fromGET /api/messages— when set, the recipient app renders the text as a threaded reply/quote of that packetPOST /api/respond— body{ "messageId": <packet id>, "content": "..." }, returns{ id }. Reply to an earlier message by packet id (fromGET /api/messages) with automatic routing: rx records are answered as a DM back to the original sender unless the original was a broadcast (then the reply is a broadcast), tx records go to their destination, injected messages without a sender get a broadcast. The reply always rides the record's channel and carriesreplyId = messageId, so the recipient app renders it as a threaded quote of that packet. 400 on invalid messageId/content, 404 when no message has the given id (in memory or in SQLite), 503 when the bridge is downGET /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 reconnectGET /api/messages— message history, newest first: inbound text (dir:'rx', withfromandto) and our sends (dir:'tx', withto+status). tx records startpendingand move todelivered/failed(with human-readableackError) when the routing ACK/NACK arrives; broadcasts staypendingbecause firmware strips want_ack before TX. Backed by SQLite: the in-memory buffer (last 100) is seeded from the database on startup and re-upserted every second, so history survives process restarts and bridge reconnects; the database keeps the newest 1000 rowsPOST /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 inGET /api/messageslike any other rx messageGET /api/forwarded— the last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as{ id, from?, text, channel, at }[]: channel in theMESH_NOTIFY_CHANNELlist (ch0: directed DMs only), not a self-echo. The id is the packet id (real rx) or generated record id (injected);fromomitted when there is no sender. In-memory only — reset on restartGET /api/telemetry— telemetry history, newest first: liveTELEMETRY_APPpackets from any node as{ from, at /* epoch ms */, kind, data }[].kindis themeshtastic.Telemetryoneof case name (deviceMetrics,environmentMetrics,airQualityMetrics,powerMetrics,localStats,healthMetrics,hostMetricsortrafficManagementStats) anddatacarries that variant's payload (proto uint64 fields as decimal strings; unset optional fields omitted). In-memory only, last 100, reset on every bridge reconnectGET /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,lastHeardis epoch seconds (null = never heard),positiononly when both coordinates are known, and snr/hopsAway/viaMqtt only when carried by the NodeInfoGET /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.
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 -X POST http://localhost:3000/api/respond -H 'content-type: application/json' -d '{"messageId":123456789,"content":"on my way"}'
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/forwarded
curl http://localhost:3000/api/telemetry
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; sends ride the pinned channel like their REST
counterparts (respond rides the original message's channel):
mesh_status— bridge connection state ({ connected, myNodeNum, host, port })send_message(text, to?, replyId?)— send a text message;toresolves likePOST /api/send(omitted/""/"!b"/"broadcast" → broadcast, number or numeric string → node number, else name lookup); optionalreplyIdmakes the message render as a threaded reply/quote of that packet id fromget_messages; returns{ id }; unknown/ambiguous targets are tool errorsrespond(messageId, content)— reply to an earlier message by packet id (fromget_messages) with automatic routing: rx → DM back to the sender unless the original was a broadcast, tx → its destination, no-sender injected records → broadcast; rides the record's channel withreplyId = messageId(threaded quote); returns{ id }; invalid args or an unknown id are tool errorslist_channels— device channels (keys redacted)list_nodes— known nodes as lean summariesget_node(id)— one node by number or name; tool error when missing/ambiguousget_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 inget_messageslike any other rx recordget_telemetry— telemetry history, newest first (last 100): device metrics and environment sensor readings from any node;kindis the Telemetry oneof case namedevice_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):
{ "mcpServers": { "mesh-bot": { "url": "http://bot.dev.kkhost.pl/mcp" } } }
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"}'
Automated weather reports
Hourly cron job (src/weather): fetches current conditions for Lublin from Open-Meteo (free, no API key) and broadcasts a one-line report on the pinned send channel (index 3, test) — e.g. Lublin weather: 18°C (feels 17), partly cloudy, humidity 65%, wind 12 km/h. A failed fetch or send only logs a warn; it never crashes the app.
WEATHER_CRON(default'0 * * * *') — cron expression for the report scheduleWEATHER_LAT(default51.2465) /WEATHER_LON(default22.5684) — coordinates queried from Open-MeteoWEATHER_CITY(default"Lublin") — city name in the report line
Opencode notifier
In-process service (src/opencode, no separate compose service): when an rx text message arrives on a channel in the MESH_NOTIFY_CHANNEL comma-separated list, it pushes it to the mesh-bot folder's live opencode session (Basic auth). On ch0 only directed DMs are pushed — broadcasts there are ignored. The opencode workspace UUID is regenerated on host restarts, so the notifier resolves it dynamically per request via the opencode API: GET {OPENCODE_API}/api/workspaces → match by path == OPENCODE_WORKSPACE_PATH (fallback name mesh-bot, status ready preferred when several match) → GET {proxyPath}/session → most recently updated session (time.updated; first entry if absent). No caching — messages are rare. The message POST body is exactly {"parts":[{"type":"text","text":msg}]} with msg = New mesh message (id <packetId>)[ from node <n>]: "<text>" — please react to it. (the from node part omitted when there is no sender; the packet id is the rx packet's id, or the generated record id for injected messages). opencode accepts the message immediately but holds the HTTP response until the agent turn finishes, so a 15 s read timeout on that POST means delivered — treated as success, never retried; workspace/session GET failures are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped.
Config lives in .env (gitignored) and is passed via compose environment — credentials are never committed to the repo:
OPENCODE_API(defaulthttp://172.24.0.1:9898) — opencode API base URL as seen from the bot container (dev_net bridge → host; opencode binds 0.0.0.0 on the host)OPENCODE_WORKSPACE_PATH(default/home/krzyw/projects/sandbox/mesh-bot) — workspace matched by itspathfield (fallback namemesh-bot; statusreadypreferred when several match)OPENCODE_SESSION(optional, default empty) — pins a session ID; when set, session discovery is skipped. Empty/unset → the most recently updated session of the resolved workspace is used per requestOPENCODE_USER/OPENCODE_PASS— Basic auth credentials; missing/empty disables the notifier (one startup log line, no-op) instead of failing
Env vars
MESH_HOST(default192.168.1.4) — bridge hostMESH_PORT(default4403) — bridge portMESH_RECONNECT_MS(default5000) — reconnect interval in msMESH_SEND_CHANNEL(default3) — channel index all sends target (test; never the primary channel)MESH_NOTIFY_CHANNEL(default3) — comma-separated list of channel indices whose rx texts trigger the opencode notifier (see "Opencode notifier"; on ch0 only directed DMs notify, broadcasts ignored; synthetic injected messages, channel 0, are exempt and trigger it when0is in the list; messages from our own node are never notified)COMMAND_CHANNEL(default2) — channel index of the auto-responder: exact#commandtexts on that channel get an automatic reply (directed back to the sender, else broadcast); currently only#ping→#pong!MESH_DB_PATH(default./data/mesh-bot.db) — SQLite file backing the message history (parent dir created if missing; in Docker, mounted at/app/datavia a named volume so it survives container recreation)MESH_POLL_MS(default1000) — how often the poller re-upserts the message history into SQLite and prunes rows beyond the newest 1000PORT(default3000) — HTTP listen portLLM_API_BASE(defaulthttp://192.168.1.70:1111/v1) — OpenAI-compatible LLM server base URL used by the in-processLlmService(src/llm; no auth)LLM_MODEL(defaultqwen3.8-27b) — model id sent to/chat/completionsLLM_TIMEOUT_MS(default60000) — per-request timeout for LLM calls (the local 27B model can be slow)
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.