How txmesh works
A hosted bot service for MeshCore, and the wire contract that lets you build your own.
txmesh gives a MeshCore mesh bots: !commands that answer over the air, scheduled announcements, automated NWS weather and river-flood alerts, and route traces. It runs as a free hosted service. The radio side is an openHop repeater running the openhop-txmesh-plugin (MIT), and everything between that plugin and our backend crosses one MQTT broker as documented JSON. This paper is that contract, plus the design decisions behind our backend, so that anyone can point the same plugin at their own broker and write their own engine.
The backend itself is not open source. The plugin is, and the plugin is the only part that touches a radio. Everything below is what you need to replace us.
1. The pieces
your repeater host ours (or yours)
┌──────────────────┐ companion TCP ┌────────┐ MQTT/WSS 443 ┌───────────┐ ┌───────────┐
│ openHop repeater ├───────────────────►│ plugin ├───────────────►│ connector ├────►│ mosquitto │
└──────────────────┘ └────────┘ └───────────┘ └─────┬─────┘
meshcore/# ▲ │
│ ▼
send/<channel> │ ┌───────────┐
└─┤ collector │ archive · bot engine · alert pollers
└─────┬─────┘
▼
┌──────────┐ ┌───────────┐
│ postgres ├────►│ api + web │
└──────────┘ └───────────┘
| Piece | What it does | Ours or yours |
|---|---|---|
| openHop repeater | The LoRa node. Keeps repeating for everyone. | Yours |
| Plugin | Adds a companion identity to the repeater (a second node on the same board), mirrors what that node hears to MQTT, and transmits text it receives from MQTT. | Open source, MIT |
| Broker | mosquitto. Every login is confined to its own namespace. | Ours (or yours) |
| Connector | A WebSocket front in front of the broker that answers WS-level pings on the radio's behalf. See §10. | Ours (or yours) |
| Collector | Subscribes to everything, archives it, runs the bot engine and the alert pollers. | Ours (this is what you would replace) |
| API + web | The map, roster, topology, chat archive, route pages, admin. | Ours |
The topic schema below originated in our own earlier observer firmware, a MeshCore fork that is no longer offered and was never part of the MeshCore project. The plugin publishes the same topics, so a backend written against that firmware keeps working, but there is no upstream specification to defer to: this document is the reference.
2. Identity and namespace
Every gateway publishes under a prefix:
meshcore/<username>/<node_name>
username is the MQTT login (obs-alice on our broker), node_name is the companion identity's name. The plugin sanitises both: #, +, / and space become -. The plugin also knows a fleet prefix, meshcore/<username>/all, which exists only so that one message can be sent through every gateway a user runs.
On our broker each login holds an ACL for meshcore/<username>/# and nothing else. That is the whole security model for attribution: a gateway cannot publish under another user's prefix, so the topic is the proof of who reported something. A backend that runs its own broker should keep that property. A shared "observer" role with meshcore/# lets any tenant forge another's traffic.
The plugin supports two other layouts (base_topic verbatim, or meshcore/<IATA>/<node> via iata), which exist for compatibility with the community meshcore-mqtt-broker family. Under those there is no fleet topic.
The 8-hex-character node id used in topics below (<pk8>) is the first four bytes of the node's public key, lowercase.
3. Uplink: what the gateway publishes
All payloads are compact JSON with optional keys omitted, never null, except status, which is a bare string. Everything is QoS 0 unless stated. A publish attempted while the plugin is disconnected from the broker is dropped, not queued.
| Topic | Retained | Cadence | Payload |
|---|---|---|---|
<prefix>/status | yes, QoS 1 | connect, clean stop, last will | online / offline (bare string) |
<prefix>/telemetry | no | every 60 s | gateway health |
<prefix>/sensors | no | every 60 s | always {} (published so "no sensors" is distinguishable from "offline") |
<prefix>/contact/<pk8> | yes | every 300 s, 8 per 250 ms | one contact-table row |
<prefix>/heard/<pk8> | yes | every 180 s, 16 most recent | one heard advert with its hop chain |
<prefix>/msg/channel | no | per message | a decrypted channel message |
<prefix>/msg/dm | no | per message | a decrypted direct message to the companion |
<prefix>/advert | no | per verified advert | a decoded advert plus the raw frame |
<prefix>/packets | no | per RF frame | every raw frame the radio heard |
3.1 status
Bare string, not JSON. online is published retained with QoS 1 on every successful CONNACK, offline on a clean stop, and offline is the MQTT last-will. There is no heartbeat on this topic. Liveness between connect and disconnect is the 60 s telemetry tick.
3.2 telemetry
{"uptime_s": 86412, "rx": 1207, "relayed": 0, "snr": -3.2, "rssi": -101,
"heap": 41369600, "boot": "plugin-start", "boots": 14}
| key | type | meaning |
|---|---|---|
uptime_s | int | seconds since the plugin process started (not the radio) |
rx | int | raw frames heard this process |
relayed | int | always 0 in plugin 0.1.3 |
snr, rssi | float, int | of the last raw frame heard; absent until the first |
heap | int | plugin process RSS in bytes; absent on Windows |
boot | str | constant plugin-start |
boots | int | persistent start counter; absent if the state dir is unwritable |
batt_mv, batt_pct | int | only when battery_mv is configured; batt_pct is then a constant 100 |
dropped is in the schema but never emitted by the plugin.
3.3 contact/<pk8> (retained)
{"pubkey": "d1d4c4cd", "name": "Rocky Creek", "type": "repeater",
"heard": 1757550000, "lat": 30.286468, "lon": -97.736}
One row per entry in the companion's contact table. type is one of unknown, chat, repeater, room, sensor. heard is the advertiser's own clock from its last advert. lat/lon are omitted when the contact has no position. The plugin never clears these retained rows; a node that leaves the table stays on the broker until someone publishes an empty retained tombstone.
3.4 heard/<pk8> (retained)
{"pubkey": "8175b535", "name": "South-Gate", "ts": 1757550123, "snr": -3.2,
"hops_n": 2, "hops": "dd17c0fe"}
The 16 most recently heard adverts, from the plugin's in-memory cache. ts is the advertiser's clock; snr is what this gateway heard the (possibly relayed) advert at. hops and hops_n are the hop-hash chain the advert travelled, see §5. A direct hearing is {"hops_n": 0} with no hops key.
Caveat: a node seen only via a bare advert push (public key only, not yet decoded) also gets a row, with just pubkey and hops_n: 0. A backend cannot tell that from a verified direct hearing.
3.5 msg/channel
{"channel": "#bot", "text": "KJ5DHR: !path", "snr": -7.5,
"hops_n": 3, "hops": "8175b535fee7", "sender_ts": 1757550200, "rx_ts": 1757550202, "skew_s": 2}
| key | meaning |
|---|---|
channel | the name of the channel slot the message arrived on; ? if the slot is unnamed |
text | the decrypted on-air text, exactly as sent, which for MeshCore group text is "<sender name>: <message>". There is no separate from field. |
snr | omitted when exactly 0 |
hops_n, hops | best-effort, see §5.2. A miss gives hops_n only. |
sender_ts | the sender's clock (u32), omitted if 0 |
rx_ts | the gateway host's clock at publish |
skew_s | rx_ts - sender_ts, omitted when there is no sender_ts |
Only channels the companion holds a key for appear here: the built-in Public channel and whatever is in the plugin's channels list (§9). Text is decrypted by the repeater, not the plugin; the plugin never holds cipher code.
3.6 msg/dm
Same shape as msg/channel with from instead of channel: the sender's contact name looked up from the 6-byte sender prefix, absent if the sender is not in the contact table. No sender public key is published. These are direct messages to the companion identity. Our backend drops them at ingest: no bot answers a DM and no page shows one, so there is nothing to keep. A backend that does nothing with them should do the same rather than archive them by default.
3.7 advert
{"pubkey": "8175b535", "adv_ts": 1757550100, "rx_ts": 1757550103, "skew_s": 3,
"type": "repeater", "name": "South-Gate", "snr": -3.2, "hops_n": 1, "hops": "dd17",
"raw": "11...<whole frame as hex>"}
Only adverts whose Ed25519 signature verified are published. raw is the entire on-wire frame including header and path, lowercase hex. The advertised position is not a field here; decode it from raw (flag 0x10 in the app-data byte means lat i32, lon i32 follow, each 1e-6 degrees). hash is in the schema but never emitted in 0.1.3. On by default in the shipped config (advert_dump).
3.8 packets
{"raw": "1102dd17...", "SNR": -7.5, "RSSI": -112}
One publish per raw RF frame, including ones the gateway cannot decrypt. Note the upper-case SNR and RSSI, kept for compatibility with an existing community collector. On by default (packets).
4. Downlink: the send bridge
The gateway subscribes to two topics on connect:
<prefix>/send/+ e.g. meshcore/obs-alice/Rocky-Creek/send/bot
<fleet>/send/+ e.g. meshcore/obs-alice/all/send/bot
The last segment is a channel token: up to 31 characters, no /. All-digit tokens of up to three digits select a channel slot by index; anything else is matched case-insensitively against slot names with a leading # ignored, so send/bot reaches #bot. An unknown token is dropped silently.
The payload is raw UTF-8 text, not JSON. It is bounded to 640 bytes before decoding, stripped, cut to 160 characters, then clamped again to 160 bytes at transmit without splitting a code point. Keep replies ASCII if they contain anything that must not be clipped, like a CLI command you are asking the operator to run.
Rules a backend has to live with:
- Channel only. There is no DM send, no explicit route or flood choice. The plugin issues one companion command, "send channel text on slot N", and the repeater routes it.
- No acknowledgement. Nothing is published back on success, failure, rate limit, or unknown channel. The plugin only logs. If you need an audit trail, write it yourself at publish time (we do, in
mesh_sends). - Retained sends are refused. Never publish a send with the retain flag.
- Rate limit 6 per minute per gateway, fixed window, with a queue depth of 4. Overflow is dropped.
- Fire-and-forget both ways. Sends are QoS 0 to a client on a clean session. A send published while the gateway is disconnected is lost, and nothing tells you. Check the retained
status(and the age of the lasttelemetry) before deciding a reply went out. - Space consecutive frames. A gateway that has just transmitted drops the next frame if it lands immediately. We send follow-ups 5 s apart; 2 s was not enough.
5. Hop chains
MeshCore frames carry the path they took as a list of hop hashes: the leading 1, 2 or 3 bytes of each relaying repeater's public key, in travel order. The header's path-length byte encodes both: hash_size = (path_len >> 6) + 1, hop_count = path_len & 0x3F.
The plugin publishes the chain as hops (hex, full width, so a 2-byte mesh gives 4 hex characters per hop) and hops_n (hop count). A direct hearing is hops_n: 0 and no hops. A backend splits the chain by dividing the hex length by hops_n; consecutive pairs are the RF adjacencies that build a topology graph.
5.1 Adverts
The chain is read straight from the frame. This is exact.
5.2 Messages
The companion protocol hands the plugin the decrypted text with only the path length, not the path. To recover the chain the plugin caches recent raw text frames (64 frames, 30 s) and matches one structurally: same payload type, same path-length byte, a ciphertext length consistent with the text, and the frame's lead byte equal to the channel's 1-byte hash (sha256(key)[0]) or the sender's prefix byte for DMs. The oldest match wins. This is a heuristic, and the plugin's README says so. Treat hops on msg/* as best effort and hops_n as reliable.
5.3 Resolving hashes to nodes
A hash is a prefix, so it collides. At one byte every hop collides with something; at three bytes it still collides once the roster is large enough. Our resolver treats a chain as a Viterbi problem: for each hop, the candidate set is every known node whose key starts with that hash; the chain is anchored at whichever ends have known positions (the requester's advertised position, the gateway's); and the path chosen minimises total geographic length, with a penalty for stepping between candidates whose known meshes are disjoint. That last part matters more than it sounds: with no anchors, geography alone picks the tightest cluster, which can be on another mesh entirely.
The plugin sets the repeater's path-hash width from path_hash_bytes (default 2). Ask your community to run 2-byte hashes; 1-byte chains are barely resolvable.
6. What a backend has to do
The minimum that behaves correctly on a real mesh, in the order the problems will find you:
- Subscribe
meshcore/#and parse by kind. Split the topic on/, find the first segment in{status, telemetry, sensors, contact, heard, msg, advert, packets, send}; the segment before it is the node, everything before that is the prefix, anything after is the sub-key (<pk8>,channel,dm). Ignoresendon the way in. This grammar tolerates all three prefix layouts. - Treat retained replays as history, not events. On every reconnect the broker replays every retained message under
meshcore/#: each gateway's wholeheard/andcontact/table and thestatusof every gateway that ever existed, including theofflinelast-will of one unplugged months ago. If a replay stamps "last seen" or increments an edge count, every restart makes a dead node live and every link look fresh. Use the MQTT retain flag on delivery: update content, never timestamps or counts. - Split the sender out of channel text.
"Name: body"is the on-air form. A message with no": "has no sender. A group-text sender is a claim, not a signature: the 2-byte MAC is a filter, and any channel member can put any name in front of a colon. Archive it, never authorise on it. - Fire once per message, not once per gateway. Several of your gateways will hear the same
!command, seconds apart, over different paths. We claim a cooldown window per(bot, scope)in one atomic database statement before rendering; scope*gives one reply for the mesh. For location-aware bots we claim per site then per path, so each place that heard the request over a distinct chain answers from its own radio and co-located radios share one voice. - Reply through the gateway that heard it. Publish to
<that gateway's prefix>/send/<channel>. That puts the answer back on the air where the question came from. Fleet topics (<user>/all/send/) are for announcements. - Never answer on Public, and never react to your own gateway's transmissions (
sender == node). A bot that echoes on the shared channel is a nuisance for a whole region; one that answers itself is a loop. - A reply that renders empty transmits nothing and releases its claim. Otherwise the gateway with nothing to say (the one that heard the request direct, for a path bot) wins the claim and silences the ones with an answer.
- Keep an audit row for every send. The plugin gives you nothing back.
Everything else (templates, helpers, scheduling, alert pollers, a map) is product.
7. A backend you can build in an evening
Nothing in §6 needs a cluster. The smallest honest version is one always-on box (a Raspberry Pi is plenty), a broker, and one Python process with SQLite. No cloud, no container, no web page yet.
7.1 Parts
| Part | Choice | Why |
|---|---|---|
| Broker | mosquitto, on the same box | The plugin only needs MQTT 3.1.1 with a username. Password file plus ACL file is enough for a home mesh; dynamic security is for many tenants. |
| Engine | one Python process, paho-mqtt | Subscribes meshcore/#, archives, answers. Single process means the "fire once" claim can be a local table. |
| Storage | SQLite | One file. Every payload archived as-is is already a queryable history. |
| Radio | your existing openHop repeater with the plugin | Nothing else to buy. |
7.2 Broker
/etc/mosquitto/conf.d/mesh.conf:
listener 1883
allow_anonymous false
password_file /etc/mosquitto/passwd
acl_file /etc/mosquitto/acl
mosquitto_passwd -c /etc/mosquitto/passwd obs-home
mosquitto_passwd /etc/mosquitto/passwd engine
/etc/mosquitto/acl, one block per gateway login, one for the engine:
user obs-home
topic readwrite meshcore/obs-home/#
user engine
topic readwrite meshcore/#
That keeps the property from §2: a gateway can only write under its own prefix. On a LAN plain 1883 is fine; the plugin's config for it is "host": "<box ip>", "port": 1883, "transport": "tcp", "tls_enabled": false. If the repeater reaches the box over the internet, put TLS on 8883 with a real certificate and leave tls_enabled true. Do not use mosquitto's own WebSocket listener without reading §10 first.
7.3 The engine
The whole thing, minus the bots you will write. It archives every payload, ignores retained replays as events, splits the sender out of channel text, refuses Public and its own echoes, claims a cooldown per bot before answering, and replies through the gateway that heard the request.
#!/usr/bin/env python3
"""Minimal MeshCore bot engine for the openhop-txmesh-plugin topic schema."""
import json, sqlite3, time
import paho.mqtt.client as mqtt
BROKER, USER, PASS = "127.0.0.1", "engine", "change-me"
KINDS = {"status", "telemetry", "sensors", "contact", "heard", "msg", "advert", "packets", "send"}
PROTECTED = {"public"} # never answer here
COOLDOWN_S = 30 # per bot, mesh-wide
db = sqlite3.connect("mesh.db", isolation_level=None) # autocommit
db.executescript("""
CREATE TABLE IF NOT EXISTS archive (ts INTEGER, topic TEXT, retained INTEGER, payload TEXT);
CREATE TABLE IF NOT EXISTS fires (bot TEXT PRIMARY KEY, until INTEGER);
CREATE TABLE IF NOT EXISTS heard (node TEXT, pk8 TEXT, name TEXT, hops TEXT, hops_n INTEGER,
updated INTEGER, PRIMARY KEY (node, pk8));
""")
# --- bots: trigger prefix -> function(sender, args, ctx) -> reply text or "" ----------
def bot_ping(sender, args, ctx):
return f"pong {sender}, heard by {ctx['node']} at {ctx.get('snr', '?')} dB"
def bot_seen(sender, args, ctx):
row = db.execute("SELECT name, updated FROM heard WHERE name = ? COLLATE NOCASE"
" ORDER BY updated DESC LIMIT 1", (args,)).fetchone()
if not row:
return f"never heard {args}"
if row[1] is None:
return f"{row[0]} is known, but only from a replay; no live hearing yet"
return f"{row[0]} last heard {int(time.time() - row[1]) // 60} min ago"
BOTS = {"!ping": bot_ping, "!seen": bot_seen}
# --- topic grammar: meshcore/<...prefix...>/<node>/<kind>[/<sub>] -----------------
def parse(topic):
parts = topic.split("/")
if len(parts) < 3 or parts[0] != "meshcore":
return None
ki = next((i for i, x in enumerate(parts) if x in KINDS), -1)
if ki < 1:
return None
return {"node": parts[ki - 1], "kind": parts[ki], "sub": parts[ki + 1:],
"prefix": "/".join(parts[:ki])}
def claim(bot):
"""One reply per cooldown window, however many gateways heard the trigger."""
now = int(time.time())
cur = db.execute("INSERT INTO fires (bot, until) VALUES (?, ?)"
" ON CONFLICT(bot) DO UPDATE SET until = excluded.until"
" WHERE fires.until <= ?", (bot, now + COOLDOWN_S, now))
return cur.rowcount == 1
def on_message(client, _userdata, m):
t = parse(m.topic)
if not t or t["kind"] == "send":
return
payload = m.payload.decode("utf-8", "replace").strip()
db.execute("INSERT INTO archive VALUES (?,?,?,?)", (int(time.time()), m.topic, int(m.retain), payload))
if t["kind"] == "status" or not payload:
return
data = json.loads(payload)
if t["kind"] == "heard" and t["sub"]:
# content may update on a retained replay; the timestamp only on live traffic
stamp = None if m.retain else int(time.time())
db.execute("INSERT INTO heard VALUES (?,?,?,?,?,?) ON CONFLICT(node, pk8) DO UPDATE SET"
" name = excluded.name, hops = excluded.hops, hops_n = excluded.hops_n,"
" updated = COALESCE(excluded.updated, heard.updated)",
(t["node"], t["sub"][0], data.get("name"), data.get("hops"),
data.get("hops_n"), stamp))
return
if t["kind"] == "msg" and t["sub"] == ["channel"] and not m.retain:
channel, text = data.get("channel", ""), data.get("text", "")
if channel.lstrip("#").lower() in PROTECTED or ": " not in text:
return
sender, body = text.split(": ", 1)
if sender == t["node"]:
return # our own transmission
for trig, fn in BOTS.items():
if body.lower().startswith(trig) and claim(trig):
out = fn(sender, body[len(trig):].strip(), {**data, **t})
if out.strip():
topic = f"{t['prefix']}/send/{channel.lstrip('#')}"
client.publish(topic, out.encode()[:160], qos=0, retain=False)
db.execute("INSERT INTO archive VALUES (?,?,?,?)",
(int(time.time()), topic, 0, out)) # the audit row
else:
db.execute("DELETE FROM fires WHERE bot = ?", (trig,)) # give the claim back
c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
c.username_pw_set(USER, PASS)
c.on_connect = lambda cl, *_: cl.subscribe("meshcore/#", qos=0)
c.on_message = on_message
c.connect(BROKER, 1883, keepalive=45)
c.loop_forever()
Run it, send !ping on #bot from any companion app, and the repeater answers. The archive table is everything the gateway ever said, with the retain flag, so the map and the roster can be built later from data you already have.
Two deliberate simplifications. The claim table is mesh-wide per bot, which is the * scope from §6; per-site and per-path fan-out is a second key on that table. And a follow-up frame is not shown; if a bot needs two, publish the second on a timer at least 5 s later.
7.4 What to add next, in the order it pays off
- Announcements. A cron entry that publishes text to
meshcore/<user>/all/send/<channel>is a scheduled announcement. No engine change. - A weather poller. One HTTP GET a minute to the NWS alerts API for your zone, dedupe on alert id in SQLite, publish new ones to the fleet send topic. Cut the text to 160 bytes yourself; the plugin will otherwise do it mid-word.
- A roster.
contact/<pk8>rows are it: name, type, position, last advert. Upsert them likeheardabove. - Topology. Split each
hopschain into consecutive pairs (§5) and count them per gateway. That is the edge list a map draws. - A path bot. The requester's chain arrives on the
!pathmessage itself (hops,hops_n); resolve each hash against the roster prefixes and pick the geographically shortest assignment (§5.3). Reply with the resolved names, or a link to a page you render from the archive. - More than one gateway. Nothing above changes as long as one process does the claiming. When you want a second process, move
firesto a database both can reach and keep the claim a single atomic statement.
8. What our setup looks like
For contrast, and so nobody thinks the home build is a toy version of something exotic. Ours is the same three roles, made restartable, running on Kubernetes and spread across more than one region so that losing a site does not take the bots off the air:
- One Python package, two entry points. An
apiprocess serves the JSON API and the web pages and is stateless, so it runs as several replicas. Acollectorprocess holds themeshcore/#subscription and runs the bot engine, the alert pollers and the housekeeping jobs. Both ship in one container image; the command picks the role. - Active/standby, not a cluster of engines. Several collector replicas run, but only the one holding a postgres advisory lock ingests and answers. If it dies the lock drops and a standby takes over within seconds. The "fire once" claim from §6 lives in postgres so a handoff mid-message cannot double-reply.
- Postgres is the archive, the roster, the bot definitions, the claim ledger, the sessions and the site settings. Schema migrations are numbered SQL files applied on startup behind the same kind of advisory lock, by whichever process comes up first.
- mosquitto with the dynamic-security plugin. Enabling observer access for an account creates a broker login with a role confined to
meshcore/<username>/#, from the api, over mosquitto's control topic. Nobody edits an ACL file. - A WebSocket connector on 443 in front of the broker, for the reason in §10. Radios reach one well-known port; the broker's own listeners stay internal.
- Everything in git, deployed by kustomize. The manifests are the same for every region with a small overlay each; a deploy is a new image tag and an apply. The handful of settings an admin changes at runtime (site name, alert switches, the device endpoint) live in a settings table, so they take effect without a redeploy.
None of that changes the wire contract. A gateway pointed at our broker or at the box in §7 publishes the same bytes.
9. Channels and keys
The plugin pushes its channels list into the repeater on every companion connect: [{idx, name, secret?}], slots 1 to 39 (slot 0 is Public and would be overwritten).
- A
#hashtagchannel with nosecretuses the MeshCore convention: key =sha256(name)[:16]including the#. That is why#botworks with nothing else configured. - A private channel needs
secret, 32 hex characters (16 bytes). - The AES key is the first 16 bytes of the channel secret, but the HMAC key is the full 32-byte buffer, a 128-bit PSK zero-padded. This only matters if you decrypt raw packets yourself (we do, for feeds from foreign brokers that publish undecoded frames). Get either detail wrong and the symptom is "no traffic on that channel", not an error.
The plugin never sees cipher text as text: the repeater decrypts, the plugin publishes the result. A backend receives plaintext for every channel the gateway has joined.
10. Connection
The plugin connects with paho-mqtt, MQTT 3.1.1, clean session, client id observer_<node>_<epoch>, keepalive 45 s, TLS with system roots, over WebSockets on 443 by default or plain TCP. It owns its own reconnect ladder (10, 30, 60, 120, 300 s, then a breaker that probes every 30 min) and disables the library's.
Do not point the plugin at mosquitto's WebSocket listener directly. mosquitto's ws layer sends a WebSocket-level PING every 300 s and drops the TCP connection 10 s later if no PONG comes back. It is a bare FIN, no CLOSE frame, and neither side logs a reason. The MQTT keepalive sits above the ws framing and does not help, so the radio looks healthy right up to the cut. Every connection died at about 310 s, every bot reply published in the down window was silently lost, and the archive looked fine throughout because the reconnect landed a few seconds later.
Our collector endpoint, the one Bot setup shows, is a connector in front of the broker that answers those PINGs on the client's behalf. If you run your own broker, either front it the same way, or use the plain MQTT-over-TLS listener (transport: tcp, port 8883) and skip WebSockets.
11. Privacy decisions we made, which you get to make too
- Channel text arrives decrypted. Anyone with your broker credentials, and anyone you give a read view to, can read every joined channel. We show channel messages publicly and exclude any channel whose key came from private configuration.
- DMs are mirrored to
msg/dmby the plugin. We discard them at ingest; nothing is archived. - Positions. Advert and contact positions are precise. For anonymous readers we round companion (
chat) positions to about a kilometre and withhold position history; repeater positions stay precise because repeaters are infrastructure. - A public key is not a credential. Keys are broadcast in every advert. We accept a key as membership for path submission and coarsen coordinates from anyone we have not explicitly trusted.
- External feeds are analytics only. Data from foreign brokers or community map APIs goes into the archive and never into the bot engine. A
!commandheard on a mesh you are not on must not make your radios transmit.
12. Versioning
The plugin's payloads evolve additively: new keys may appear, existing keys keep their meaning, optional keys are omitted rather than nulled. A backend should ignore unknown keys and tolerate absent ones. This document describes plugin 0.1.3; the version is in openhop-plugin.json in the plugin repo, and each release is tagged.
Known gaps in 0.1.3, so nobody wastes an afternoon: advert.hash never emitted, telemetry.relayed always 0, telemetry.dropped never emitted, no DM send, no send acknowledgement.
txmesh is community-run and free. The plugin is MIT-licensed at github.com/zfouts/openhop-txmesh-plugin.