n8n-nodes-meshcore
v0.9.0
Published
n8n community nodes for MeshCore devices over TCP/WiFi
Maintainers
Readme
n8n-nodes-meshcore
Community n8n nodes for controlling a MeshCore device over
TCP/WiFi (the companion_radio_wifi firmware). Built on
@liamcottle/meshcore.js.
Two nodes are provided:
- MeshCore — an action node exposing the device's commands (send messages, manage contacts/channels, read stats/telemetry, run diagnostics, repeater admin, …) plus a Utility resource that encodes and decodes raw packets with no device at all.
- MeshCore Trigger — starts a workflow on device events (incoming messages, adverts, delivery confirmations, telemetry, traces, …).
Self-hosted only. These nodes open a raw TCP socket to your device, which the n8n Cloud sandbox does not allow. They are intended for self-hosted n8n.
Requirements
- A self-hosted n8n instance.
- To develop: Node.js 22 for install/build/test (
@n8n/node-clirequires Node ≥ 20.12, and the dev dependencyisolated-vm@6does not compile on Node 26). Running current n8n itself (2.37+) needs Node ≥ 24 — see Development. - A MeshCore device running
companion_radio_wifi, reachable over your LAN on TCP (default port5000).
Installation
In your self-hosted n8n: Settings → Community Nodes → Install, then enter
n8n-nodes-meshcore.
Or build and link locally (see Development).
Credentials — MeshCore TCP API
| Field | Description |
|---|---|
| Host | IP/hostname of the device (e.g. 10.1.0.226) |
| Port | TCP port (default 5000) |
| Device PIN | Optional connection PIN, only if the firmware enforces one over TCP |
Use Test on the credential to verify connectivity — it opens a short-lived TCP connection to the device.
MeshCore node — operations
| Resource | Operations | |---|---| | Device | Get Self Info, Get Device Info, Get/Set Radio Parameters, Get Battery Voltage, Get/Set/Sync Device Time, Set Advert Name, Set Advert Lat/Long, Set TX Power, Get Stats, Reboot, Set Device Pin, Get/Set Custom Variable(s), Get/Set Tuning Parameters, Get Allowed Repeat Frequencies, Get/Set Auto Add Config, Set Path Hash Mode, Set Other Parameters, Sign Data, Get Self Telemetry, Export Private Key, Factory Reset | | Contact | Get Many, Get by Key, Get Advert Path, Find by Name, Find by Public Key Prefix, Add or Update, Set Path, Reset Path, Share, Export, Import, Remove | | Message | Send Direct Message (toggle: Reliable Delivery), Send Direct Message and Await Reply (toggle: Reliable Delivery), Send Channel Message (toggle: Reliable Delivery), Send Channel Message With Custom Nickname (toggle: Reliable Delivery), Await Delivery, Get Waiting Messages, Sync Next Message | | Channel | Get Channel, Get Many, Set, Delete, Send Data, Find by Name, Find by Secret | | Advert | Send Flood Advert, Send Zero-Hop Advert | | Diagnostics | Get Telemetry, Trace Path, Send Binary Request, Send Path Discovery, Discover Path, Await Event, Send Raw Data, Send Raw Packet | | Repeater | Login, Logout, Has Connection, Get Status, Get Neighbours, Get Access List, Get Owner Info, Get Avg Min Max, Send CLI Command, Send Anonymous Request, Send Control Data | | Flood Scope | Set Scope, Clear Scope, Get Default, Set Default | | Utility | Decode Packet, and Encode for every payload type: ACK, Advert, Anonymous Request, Channel Datagram, Channel Message, Control Data, Direct Datagram, Direct Message, Multipart, Path Return, Raw Custom, Raw Frame, Trace — all without a device |
Binary fields (public keys, secrets, payloads, signatures) are entered/returned as hex strings.
MeshCore Trigger node — events
New Direct Message, New Channel Message, New Channel Data, New Advert, New Advert (Manual Add), Message Delivery Confirmed, Path Updated, Status Response, Login Success/Failed, Telemetry Response, Trace Data, Raw Data (sniffer), Log RX Data (sniffer), Path Discovery Response, Control Data, Contact Deleted, Contacts Full.
The message events are driven by the MSG_WAITING push: on each signal the node drains
queued messages and emits one item per message, routed by type (direct/channel/channel
data). Draining always consumes the whole device queue, so select every message type you
care about on a single trigger node. Each emitted item is tagged with an event field.
Channel message fields
The firmware formats channel (group) messages as "<sender>: <text>". For the
channelMessage event the trigger splits that into separate fields so you don't have to
parse it in the workflow:
author— the sender's node name (empty if the message has no"<name>: "prefix)text— the message body with the prefix removedrawText— the original combined string, unchanged
The split is on the first ": ", so a ": " inside the message body stays in text.
Direct messages are emitted unchanged.
Routing fields on message events
Both directMessage and channelMessage carry the firmware's pathLen byte. It's
a packed value: 0xFF is the sentinel for "delivered along a known route" (direct);
otherwise the low 6 bits are the hop count and the high 2 bits encode the path-hash
size (1–4 bytes per hop). The trigger decodes this into friendlier fields and drops
the raw pathLen from the output:
via—"direct"if the message arrived along the stored route,"flood"otherwise.hops—0for direct, the actual hop count for flood.
The path itself is not available: the firmware's message frames carry only that byte, not the route's hash bytes, so there is no path (or per-hop hash size) to report.
Note on the New Advert event
The advert event (auto-add mode, push 0x80) carries only the sender's publicKey —
that is all the firmware emits in this push (the contact record itself is updated inside
the device). Subscribe to New Advert (Manual Add) if you need the full record
(advName, advLat, advLon, outPath, etc.); that push is only fired when the device
is in manual-add mode.
Common patterns
These combined operations turn the "send now, result arrives later" protocol flows into a single synchronous node, so you don't need a second trigger plus shared state:
- Reliable send — Message → Send Direct Message with the Reliable Delivery
toggle on. Mirrors the MeshCore app's retry policy: up to Path Retries attempts
along the stored route, then a forced
resetPathand up to Flood Retries attempts via flood routing. Each attempt has its own Ack Timeout (Ms) — retries fire immediately on timeout, no backoff. Returnsdelivered: true,phase: "path" | "flood",attempts,ackCode,roundTripon success. On final non-delivery the node throws so it shows as a red error (use n8n's Continue On Fail if you want to branch on the failure as data). The Force Flood toggle skips the path phase after an upfront route reset. Toggle off = fire-and-forget: one send, returnackCode, no waiting. - Request / reply — Message → Send Direct Message and Await Reply sends a message and waits for the contact's next reply (or times out). Its own Reliable Delivery toggle runs the same retry+ack pipeline before starting the reply wait — useful for ask-a-node chatbots where the question must land first. Toggle off = one send + wait for reply.
- Repeater admin — Repeater → Login (guest = empty password, or admin password), then Repeater → Send CLI Command sends a CLI command and returns the repeater's response.
- Path discovery — Diagnostics → Discover Path floods a discovery request and waits for the discovered route.
- Standalone Message → Await Delivery (by
ackCode) and Diagnostics → Await Event are building blocks if you need to wait separately from sending.
Message text type (Plain / CLI Data / Signed Plain) is selectable on the direct-send operations. Public keys, secrets and paths are hex strings on both input and output, so a received message's sender key pipes straight into a send node.
Utility resource — decoding and encoding packets
These operations never open a connection, so they work equally on frames from the trigger's Raw Data / Log RX Data sniffer events, from an MQTT bridge, or from a capture file. Credentials are optional on the node for exactly this reason; the other resources ask for the device only when they actually run.
Decode Packet takes the frame as hex and returns the header fields, route type,
payload type, hop path, payload, and the mesh's own packet hash. The input frame comes
back as packet (normalised hex, the same field the encoders output), so a workflow can
decode, filter on the fields, and forward the original bytes. On top of that:
| Given | You get | |---|---| | nothing | Advert contents (name, node type, location, public key), ACK codes, dest/src hashes, route and path | | Channel Secrets | Channel message author and text, channel datagram bytes | | Private Keys + Peer Public Keys | Direct message text, requests/responses, returned paths |
Packet Types filters the stream: non-matching frames produce no output item, so a busy sniffer feed can be narrowed without a downstream IF.
Keys are node parameters rather than credentials on purpose — a key is a decoding input here, and listing several is what lets one workflow read traffic for several identities on one radio.
Direct traffic needs both halves. A packet carries only a one-byte sender hash, so the sender's full public key has to be known in advance to derive the shared secret; supply the contacts you expect to hear from.
The Encode operations cover every payload type the firmware defines, and return the frame as hex plus its packet hash. Feed that to Diagnostics → Send Raw Packet to transmit, or to a bridge.
| Needs | Operations | |---|---| | nothing | ACK, Control Data, Multipart, Raw Custom, Raw Frame, Trace | | a channel secret | Channel Message, Channel Datagram | | an identity private key | Advert | | an identity key and the recipient's public key | Direct Message, Direct Datagram (REQ/RESPONSE), Anonymous Request, Path Return |
Encode Direct Message also returns expectedAck, the four bytes the recipient will send
back, so it pipes straight into Message → Await Delivery.
Set Path Hash Size to the mesh's own value (pathHashSize from Device → Get Device
Info): every repeater reads that byte to size the hash it appends.
Encode Advert signs with the private key you give it, so it can announce an identity the device does not own — the basis for running several identities on one radio. Two caveats the firmware imposes: an advert without a name is dropped, and a receiver that already knows the key ignores any advert whose timestamp is not strictly newer than the last one it stored. Keeping that timestamp moving is the workflow's job.
Receiving for an identity the device does not own works only through the sniffer:
Mesh::onRecvPacket decrypts direct traffic solely when the destination hash matches the
device's own key, so anything else never reaches the normal message path. Subscribe to
Log RX Data and decode the frames yourself. Acks, return paths and device-level
deduplication do not happen for such an identity either.
About the keys
Device → Export Private Key reads the device's identity key, behind an explicit confirmation toggle. It is the node's identity — whoever holds it can read every direct message addressed to that node and can send messages as it, it lands in the execution's data and in whatever the workflow logs, and it cannot be rotated without re-keying the node and re-adding it to every contact. Nothing else in this package needs it.
Only advert signatures are authenticated in MeshCore. A channel message's author is a
plain string inside the encrypted text — any member of the channel can claim any name, so
treat a decoded author as a label, not an identity. Decode reports signatureValid on
adverts when Verify Advert Signatures is on.
Asking a repeater for things
A repeater answers requests only from a node it knows, so the sequence is: have it in your contacts (catch its advert, or Contact → Add or Update), then Repeater → Login (an empty password is a guest login), then ask.
| Operation | Notes | |---|---| | Get Status | Uptime, packet and duplicate counters, airtime, battery, noise floor — decoded, with the raw bytes kept alongside | | Get Telemetry | Cayenne LPP readings. A guest gets base telemetry only; the firmware masks the rest by role | | Get Neighbours | Who the repeater hears, with SNR and how long ago. Returns nothing if its firmware was built without neighbour tracking | | Get Access List | Its client list and roles — admin only | | Get Owner Info | Firmware version, node name, owner text | | Get Avg Min Max | Sensor nodes only; repeaters do not implement it |
Get Self Telemetry is the exception: it reads this device's own battery and sensors with no radio traffic, no contact and no login.
All of these wait for a reply that travels over the air, so each carries Extra Timeout (Ms) — raise it for a distant node rather than reading the timeout as a failure.
How it works
The WiFi companion firmware accepts exactly one TCP client at a time and drops the
existing client when a new one connects. To avoid the action and trigger nodes kicking
each other off the radio, all nodes for a given device share one connection per
host:port, reference-counted, with a serialized command queue and push fan-out
(nodes/shared/ConnectionManager.ts).
meshcore.js is bundled into the build artifact (esbuild), so the published package
has no runtime dependencies and pulls in no native serialport. Commands missing from
meshcore.js are added by a small subclass (scripts/vendor/meshcore-extended.mjs),
grounded in the firmware's command/response layouts.
⚠️ The extended ("gap") commands and their response parsing were verified against the MeshCore firmware source (
MyMesh.cpp) and then exercised on real hardware (Heltec CT62, firmware v1.14.1 and v1.16.0). These remain source-verified only, because the firmware is too old or the state is hard to provoke: Set/Get Default Flood Scope (opcodes 63/64),LoginFail(0x86),ContactDeleted/ContactsFull(0x8F / 0x90), Set Radio Parameters and Send Raw Data.
Sending as an arbitrary name
Message → Send Channel Message With Custom Nickname assembles the GRP_TXT packet on the host and
transmits it with CMD_SEND_RAW_PACKET (firmware v1.16.0+ only). It exists because the
device writes the author from its own _prefs.node_name, so CMD_SEND_CHANNEL_TXT_MSG
cannot carry a per-message name.
The crypto is nodes/shared/channelHash.ts, the same pipeline whose packet hashes are
already matched against real retransmissions by the reliable-channel-send path — the packet
builder only adds the header and the packed path-length byte. That byte's hash size is read
from the device (path_hash_mode + 1), because every repeater sizes the hash it appends by
it.
A channel message's author is an unauthenticated string inside the encrypted text: anyone on the channel can claim any name, in this node or any other client. Do not treat a received author as identity.
Development
This repo bundles Node 22 under node-v22.22.3-linux-x64/. Put it on your PATH:
export PATH="$PWD/../node-v22.22.3-linux-x64/bin:$PATH"
npm install # eslint is pinned to 9.29.0 to match @n8n/node-cli's peer
npm run build # tsc (via n8n-node) + esbuild bundles meshcore.js into dist
npm run lint
npm test # node:test; pretest builds, tests run against dist/The quickest way to try it in a local n8n with a ready-to-use account is
scripts/dev-n8n.sh: it builds the plugin, launches n8n with the node loaded
(N8N_CUSTOM_EXTENSIONS), and provisions a known owner via env so there's no setup
screen — login [email protected] / Meshcore123 at http://localhost:5678 (Ctrl+C
to stop). N8N_USER_MANAGEMENT_DISABLED was removed from n8n, so a pre-provisioned owner
(N8N_INSTANCE_OWNER_MANAGED_BY_ENV + a bcrypt password hash) is the supported way to get
a fixed dev login.
n8n 2.37+ refuses to start on Node < 24, so the script runs on the bundled
node-v26.2.0-linux-x64/ by default (override with NODE_BIN=/path/to/node/bin). That
only affects the local build and the test n8n; the plugin's output targets es2020/node18.
If n8n fails with "IsolatePool failed to create any bridges", its isolated-vm was built
under a different Node: run npm rebuild isolated-vm in its ~/.npm/_npx/<hash> folder
with the same Node on PATH.
Manual alternative: npm run build && npm link, then in ~/.n8n/custom run
npm link n8n-nodes-meshcore, and restart n8n.
Releasing
Releases are published by .github/workflows/publish.yml with npm Trusted Publishing
(no npm token exists anywhere) and a provenance attestation, which n8n requires of
community nodes. Bump the version in a Release vX.Y.Z commit, then:
git tag vX.Y.Z
git push origin main vX.Y.Zand approve the npm deployment in the Actions tab. The workflow refuses a tag that does
not match package.json. Its header lists the one-time npm and GitHub settings it relies
on and what each of them protects against.
Manual device test checklist
Run once against real hardware to validate the device-dependent paths:
- Connectivity — add credentials, click Test (should report connected).
- Round-trip — Device → Get Self Info (exercises the AppStart handshake).
- Send — Message → Send Direct Message to a known contact (expects a SENT reply).
- Receive — MeshCore Trigger → New Message; send the device a message and confirm
the workflow fires. For channel messages,
scripts/device-listen-channel.mjs <host> <port>prints the parsedauthor/text/rawTextfields. - Lists — Contact → Get Contacts; Channel → Get Many.
- Diagnostics — Diagnostics → Get Status / Trace Path (binary-request path).
- Gap-command responses (least-verified) — confirm these parse correctly: Get Custom Variables, Get Tuning Parameters, Get Auto Add Config, Get Allowed Repeat Frequencies, Get Default Flood Scope, Get Advert Path, Get by Key.
- Reconnect — power-cycle/disconnect the device and confirm a running trigger reconnects and resumes.
Breaking changes (0.9.0)
Packed path_len bytes are no longer output. Read as a number they look like a hop
count — a direct flood packet with 2-byte hashes showed pathLen: 64 — when they are
really the hash size and the hop count packed together. Both are already output as their
own fields, so use those:
| Where | Removed | Use instead |
|---|---|---|
| Contact → Get Many / Get by Key / Find by Name / Find by Public Key Prefix, Trigger → New Advert | outPathLen (-1 meant "no route stored") | outPathHops, outPathHashSize (both null when no route is stored) |
| Utility → Decode Packet | pathLen (also inside a decrypted PATH packet) | hops, pathHashSize |
| Contact → Get Advert Path | pathLen | hops, hashSize |
| Trigger → Path Discovery Response, Diagnostics → Discover Path | outPathLen, inPathLen | outPathHops / outPathHashSize, inPathHops / inPathHashSize |
| Trigger → Control Data | pathLen | hops |
Also new: Decode Packet returns its input frame as packet, so a workflow can decode,
filter on the fields and forward the original bytes.
Breaking changes (0.8.0)
Three operations moved to the resource they actually belong to. A workflow using any of them needs its MeshCore node re-configured — the operation itself behaves identically.
| Was | Now | Why | |---|---|---| | Diagnostics → Get Status | Repeater → Get Status | Only a repeater, room server or sensor answers it; a plain companion does not | | Diagnostics → Get Neighbours | Repeater → Get Neighbours | Repeater only | | Repeater → Sign Data | Device → Sign Data | Signs with this device's key; it never involves a repeater |
Get Telemetry stays in Diagnostics because it is the one request a plain companion does
answer (MyMesh::onContactRequest handles that type and no other).
Breaking changes (0.3.0)
Field-name unification — workflows that read these specific keys from node output need to be updated:
- Output key
pubKeyPrefixis nowpublicKeyPrefixon every event/operation that emits it (direct message, telemetry response, status response, login success, path discovery, trace data, …). The 6-byte hex content is unchanged. - UI parameter
Public Key Prefix Lengthon Diagnostics → Get Neighbours — the parameter name was renamed frompubKeyPrefixLengthtopublicKeyPrefixLengthinternally; the displayed name is unchanged. Existing nodes need to be re-opened so n8n re-reads the default; node behavior is otherwise identical. - UI parameter
Extra Timeout (Ms)on Diagnostics → Trace Path and Send Binary Request — parameter name renamed fromextraTimeoutMillistoextraTimeoutMs, matching the suffix used by all other timeout fields. Same re-open caveat.
Reliable send defaults: the new pathRetries (2) and floodRetries (2) mean a
Send Direct Message and Await Delivery node that previously resolved delivered: false
on timeout will now retry up to four times and then throw on final non-delivery (red
status). Set both to 0 to restore single-attempt behavior, or use n8n's Continue On
Fail to keep the failure as data.
License
MIT
