Edwin Knuth dc0679234f data: derive the Reticulum ingestor's own node id; scope CONNECTION out (#888) (#896)
Answers the two questions left open on #888 after #893.

INGESTOR_NODE_ID was required for PROTOCOL=reticulum because a passive
announce listener has no handshake revealing "our" node id. The premise
holds; the conclusion did not follow. The ingestor now owns an RNS
identity, generated on first run and stored 0600 as potato_mesh_identity
inside the app-owned RETICULUM_CONFIG_DIR, and derives its !xxxxxxxx
through the same identity-hash mapping applied to every peer, so its own
row is shaped like any other Reticulum row. A plain PROTOCOL=reticulum
deployment brings up the heartbeat, and so the reticulum packets/hour
scope, unaided; the variable still overrides, which also means no key is
generated for a deployment that does not need one. The identity is local
and never announced, so nothing new transmits. Persist-or-nothing is
deliberate: an unstorable identity would be regenerated per start and
each generation is a different node id, filing one orphan ingestor row
per restart, so the provider reports no id and warns instead. An existing
but unreadable key file is never overwritten. The startup warning #890
emitted for an unset INGESTOR_NODE_ID is removed.

CONNECTION and RETICULUM_CONFIG_DIR/RETICULUM_INTERFACES are disjoint
rather than in conflict: CONNECTION names one serial, TCP, or BLE
endpoint and an RNS stack has no such thing, which is why connect() has
always ignored it. What was wrong was the silence, since the image sets a
serial default for every protocol. connect() now says once at info that
the value is dropped and names the two variables that do apply. The image
default stays: removing it would change how meshtastic and meshcore
containers resolve their port, and a Reticulum follow-up is the wrong
change to carry that.

SPEC RN9/RN10, ACCEPTANCE RN-A9/RN-A10; CONTRACTS, README and
.env.example reconciled. 100% line and branch coverage on the provider;
pytest 1442 passed, rspec 1717 examples 0 failures.
2026-08-28 10:16:56 +02:00
2026-08-24 19:48:29 +02:00
2026-08-24 19:48:29 +02:00
2026-08-24 19:48:29 +02:00
2026-06-19 16:37:41 +02:00
2026-06-19 16:37:41 +02:00
2025-12-31 12:00:11 +01:00
2025-09-15 08:28:55 +02:00
2025-09-17 10:23:36 +02:00
2025-09-27 18:52:41 +02:00
2025-10-06 08:36:13 +02:00
2026-01-08 16:20:14 +01:00

🥔 PotatoMesh

GitHub Workflow Status GitHub release codecov Open-Source License Contributions Welcome Matrix Chat

Meshtastic Meshcore Reticulum

A federated Meshtastic, Meshcore, and Reticulum node dashboard for your local community. No MQTT clutter, just local LoRa aether.

  • Web dashboard with chat window and map view showing nodes, positions, neighbors, trace routes, telemetry, and messages.
    • API to POST (authenticated) and to GET nodes, messages, and telemetry.
    • Shows new node notifications (first seen) and telemetry logs in chat.
    • Allows searching and filtering for nodes in map and table view.
    • Federated: automatically froms a federation with other communities running Potato Mesh!
    • Supports Meshtastic, Meshcore, and Reticulum.
  • Supplemental Python ingestor to feed the POST APIs of the Web app with data remotely.
    • Supports multiple ingestors per instance.
    • Supports Meshtastic, Meshcore, and Reticulum protocols.
  • Matrix bridge that posts Meshtastic messages to a defined matrix channel (no radio required).
  • Mobile app to read messages on your local aether (no radio required).

Live demo for Berlin: potatomesh.net

screenshot of the sixth version

Jump to Contents:

  • Web App - configuration, running, deploying, monitoring, etc.
  • Ingestor - configuration, running, transmitting, etc.
  • Nix - nix deployment
  • Docker - docker deployment
  • Matrix Bridge - configuration and deployment

Web App

Requires Ruby for the Sinatra web app and SQLite3 for the app's database.

pacman -S ruby sqlite3
gem install sinatra sqlite3 rackup puma rspec rack-test rufo prometheus-client
cd ./web
bundle install

Run

Check out the app.sh run script in ./web directory.

API_TOKEN="1eb140fd-cab4-40be-b862-41c607762246" ./app.sh
== Sinatra (v4.1.1) has taken the stage on 41447 for development with backup from Puma
Puma starting in single mode...
[...]
*  Environment: development
*          PID: 188487
* Listening on http://127.0.0.1:41447

Check 127.0.0.1:41447 for the development preview of the node map. Set API_TOKEN required for authorizations on the API's POST endpoints.

Production

When promoting the app to production, run the server with the minimum required configuration to ensure secure access and proper routing:

RACK_ENV="production" \
APP_ENV="production" \
API_TOKEN="SuperSecureTokenReally" \
INSTANCE_DOMAIN="https://potatomesh.net" \
MAP_CENTER="53.55,13.42" \
exec ruby app.rb -p 41447 -o 0.0.0.0
  • RACK_ENV and APP_ENV must be set to production to enable optimized settings suited for live deployments.
  • Bind the server to a production port and all interfaces (-p 41447 -o 0.0.0.0) so that clients can reach the dashboard over the network.
  • Provide a strong API_TOKEN value to authorize POST requests against the API.
  • Configure INSTANCE_DOMAIN with the public URL of your deployment so vanity links and generated metadata resolve correctly.
  • Don't forget to set a MAP_CENTER to point to your local region.

The web app can be configured with environment variables (defaults shown):

Variable Default Purpose
API_TOKEN required Shared secret that authorizes ingestors and API clients making POST requests.
INSTANCE_DOMAIN auto-detected Public hostname (optionally with port) used for metadata, federation, and generated API links.
SITE_NAME "PotatoMesh Demo" Title and header displayed in the UI.
MESHTASTIC_PRESET "#LongFast" Meshtastic radio preset shown in the join strip, meta description, and federation directory (e.g. MediumFast).
MESHTASTIC_FREQ "915MHz" Meshtastic frequency shown alongside the preset.
MESHCORE_PRESET unset Meshcore radio preset for the join strip; the Meshcore line is hidden until both MESHCORE_* values are set.
MESHCORE_FREQ unset Meshcore frequency for the join strip.
CONTACT_LINK "#potatomesh:dod.ngo" Chat link or Matrix alias rendered in the footer and overlays.
ANNOUNCEMENT unset Optional announcement banner text rendered above the header on every page.
MAP_CENTER 38.761944,-27.090833 Latitude and longitude that centre the map on load.
MAP_ZOOM unset Fixed Leaflet zoom applied on first load; disables auto-fit when provided.
MAX_DISTANCE 42 Maximum distance (km) before node relationships are hidden on the map.
DEBUG 0 Set to 1 for verbose logging in the web services.
FEDERATION 1 Set to 1 to announce your instance and crawl peers, or 0 to disable federation. Private mode overrides this.
PRIVATE 0 Set to 1 to hide the chat UI, disable message APIs, and exclude hidden clients from public listings.
EVENTS 1 Set to 0 to disable the live-update SSE stream (GET /api/events); clients then fall back to polling at the refresh interval.
MIN_THREADS 16 Minimum Puma worker threads kept warm.
MAX_THREADS 96 Maximum Puma worker threads. Each active /api/events SSE stream pins one thread, so keep this above your peak concurrent SSE clients plus API/ingest headroom.
OG_IMAGE_URL unset Optional absolute URL for the social preview image. Must use an http:// or https:// scheme; values with other schemes are ignored. Most social platforms (Facebook, LinkedIn, Slack, iMessage) require HTTPS to render the card. When set, replaces the runtime-generated /og-image.png so deployments without Chromium (or with size-conscious images) can point at a CDN.
PAGES_DIR ./pages The directory for static, custom-content pages.

The application derives SEO-friendly document titles, descriptions, and social preview tags from these existing configuration values. /robots.txt and /sitemap.xml are generated automatically and respect PRIVATE/FEDERATION toggles; markdown files in pages/ may declare optional YAML frontmatter (title, description, image, noindex) for per-page overrides. The image: frontmatter must be an absolute http(s):// URL; other schemes are silently dropped to keep operators from accidentally leaking data: or javascript: URIs into Open Graph tags.

If INSTANCE_DOMAIN is unset in production the app emits a one-time WARN at startup; canonical URLs and sitemap entries fall back to the inbound Host header, which can be cache-poisoned by a misconfigured proxy. Set INSTANCE_DOMAIN to your public hostname to silence the warning.

Example:

SITE_NAME="PotatoMesh Demo" INSTANCE_DOMAIN="https://potatomesh.net" MAP_CENTER=38.761944,-27.090833 MAP_ZOOM=11 MAX_DISTANCE=42 CONTACT_LINK="#potatomesh:dod.ngo" ./app.sh

Configuration & Storage

PotatoMesh stores its runtime assets using the XDG base directory specification. When XDG directories are not provided the application falls back to the repository root.

The key is written to $XDG_CONFIG_HOME/potato-mesh/keyfile and the well-known document is staged in $XDG_CONFIG_HOME/potato-mesh/well-known/potato-mesh.

The database can be found in $XDG_DATA_HOME/potato-mesh.

Custom Pages

Instance operators can publish static content pages (contact details, mesh protocol information, legal notices, etc.) by placing Markdown files in the pages/ directory inside web/. Each .md file automatically becomes a nav entry and a route under /pages/<slug>.

Files are named <sort-prefix>-<slug>.md: the numeric prefix controls navigation order and the slug becomes the URL path and nav label:

Filename Nav Label URL
1-about.md About /pages/about
5-rules.md Rules /pages/rules
9-contact.md Contact /pages/contact
20-impressum.md Impressum /pages/impressum

A default 1-about.md ships with the app. In Docker deployments the directory is exposed as the potatomesh_pages volume (mounted at /app/pages) so you can add or edit pages without rebuilding the image. The pages directory can also be overridden with the PAGES_DIR environment variable.

Federation

PotatoMesh instances can optionally federate by publishing signed metadata and discovering peers. Federation is enabled by default and controlled with the FEDERATION environment variable. Set FEDERATION=1 (default) to announce your instance, respond to remote crawlers, and crawl the wider network. Set FEDERATION=0 to keep your deployment isolated. Private mode still takes precedence; when PRIVATE=1, federation features remain disabled regardless of the FEDERATION value.

When federation is enabled, PotatoMesh automatically refreshes entries from known peers every eight hours to keep the directory current. Instances that stop responding are considered stale and are removed from the web frontend after 72 hours, ensuring visitors only see active deployments in the public directory.

API

The web app contains an API:

  • GET /api/nodes?limit=100 - returns the latest 100 nodes reported to the app
  • GET /api/positions?limit=100 - returns the latest 100 position data
  • GET /api/messages?limit=100&encrypted=false&since=0 - returns the latest 100 messages newer than the provided unix timestamp (defaults to since=0 to return full history; disabled when PRIVATE=1)
  • GET /api/telemetry?limit=100 - returns the latest 100 telemetry data
  • GET /api/neighbors?limit=100 - returns the latest 100 neighbor tuples
  • GET /api/traces?limit=100 - returns the latest 100 trace-routes caught
  • GET /api/instances - returns known potato-mesh instances in other locations
  • GET /api/ingestors - returns active potato-mesh python ingestors that feed data
  • GET /metrics- metrics for the prometheus endpoint
  • GET /version- information about the potato-mesh instance
  • POST /api/nodes - upserts nodes provided as JSON object mapping node ids to node data (requires Authorization: Bearer <API_TOKEN>)
  • POST /api/positions - appends positions provided as a JSON object or array (requires Authorization: Bearer <API_TOKEN>)
  • POST /api/messages - appends messages provided as a JSON object or array (requires Authorization: Bearer <API_TOKEN>; disabled when PRIVATE=1)
  • POST /api/telemetry - appends telemetry provided as a JSON object or array (requires Authorization: Bearer <API_TOKEN>)
  • POST /api/neighbors - appends neighbor tuples provided as a JSON object or array (requires Authorization: Bearer <API_TOKEN>)
  • POST /api/traces - appends caught traces routes provided as a JSON object or array (requires Authorization: Bearer <API_TOKEN>)

The API_TOKEN environment variable must be set to a non-empty value and match the token supplied in the Authorization header for POST requests.

Monitoring

PotatoMesh ships with a Prometheus exporter mounted at /metrics. Consult PROMETHEUS.md for deployment guidance, metric details, and scrape configuration examples.

Ingestor

The web app is not meant to be run locally connected to a LoRa node but rather on a remote host without access to a physical LoRa device. Therefore, it only accepts data through the API POST endpoints. Benefit is, here multiple nodes across the community can feed the dashboard with data. The web app handles messages and nodes by ID and there will be no duplication.

For convenience, the directory ./data contains a Python ingestor. It connects to a LoRa node via serial port or to a remote device that exposes the LoRa TCP or Bluetooth (BLE) interfaces to gather nodes and messages seen by the node.

pacman -S python
cd ./data
python -m venv .venv
source .venv/bin/activate
pip install -r "requirements.txt

It uses the respective Python library to ingest mesh data and post nodes and messages to the configured potato-mesh instance.

Check out mesh.sh ingestor script in the ./data directory.

INSTANCE_DOMAIN=http://127.0.0.1:41447 API_TOKEN=1eb140fd-cab4-40be-b862-41c607762246 CONNECTION=/dev/ttyACM0 DEBUG=1 ./mesh.sh
[2025-02-20T12:34:56.789012Z] [potato-mesh] [info] channel=0 context=daemon.main port='41447' target='http://127.0.0.1' Mesh daemon starting
[...]
[2025-02-20T12:34:57.012345Z] [potato-mesh] [debug] context=handlers.upsert_node node_id=!849b7154 short_name='7154' long_name='7154' Queued node upsert payload
[2025-02-20T12:34:57.456789Z] [potato-mesh] [debug] context=handlers.upsert_node node_id=!ba653ae8 short_name='3ae8' long_name='3ae8' Queued node upsert payload
[2025-02-20T12:34:58.001122Z] [potato-mesh] [debug] context=handlers.store_packet_dict channel=0 from_id='!9ee71c38' payload='Guten Morgen!' to_id='^all' Queued message payload

Run the script with INSTANCE_DOMAIN and API_TOKEN to keep updating node records and parsing new incoming messages. Enable debug output with DEBUG=1, specify the connection target with CONNECTION (default /dev/ttyACM0) or set it to an IP address (for example 192.168.1.20:4403) to use the Meshtastic TCP interface. CONNECTION also accepts Bluetooth device addresses in MAC format (e.g., ED:4D:9E:95:CF:60) or UUID format for macOS (e.g., C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E) and the script attempts a BLE connection if available. To keep ingestion limited, set ALLOWED_CHANNELS to a comma-separated whitelist (for example ALLOWED_CHANNELS="Chat,Ops"); packets on other channels are discarded. Use HIDDEN_CHANNELS to block specific channels from the web UI even when they appear in the allowlist.

Variable Default Purpose
API_TOKEN required Shared secret that authorizes ingestors and API clients making POST requests.
INSTANCE_DOMAIN required Public hostname (optionally with port) used for feeding the API with data.
PROTOCOL meshtastic Which protocol are we ingesting? One of meshtastic, meshcore, or reticulum.
CONNECTION /dev/ttyACM0 Where do we talk to the node? Accepts serial ports, TCP connections, and bluetooth addresses (BLE mac). Ignored under PROTOCOL=reticulum, which has no single endpoint — see Reticulum.
DEBUG 0 Set to 1 for verbose logging in the ingestor services.
ALLOWED_CHANNELS unset Comma-separated channel names the ingestor accepts; when set, all other channels are skipped before hidden filters.
HIDDEN_CHANNELS unset Comma-separated channel names the ingestor will ignore when forwarding packets.
TRANSPORT api Ingestor transport: api (Meshtastic library over serial/TCP/BLE) or udp (passive LAN multicast; see Passive UDP transport).
PRIMARY_CHANNEL_ONLY 0 Set to 1 to ingest only the primary channel (index 0) and drop all other channels. In UDP transport this requires PRIMARY_CHANNEL_NAME; without it, every packet is dropped (fail closed).
PRIMARY_CHANNEL_KEY AQ== Base64 PSK used to decrypt the primary channel in UDP transport (default = Meshtastic default key).
PRIMARY_CHANNEL_NAME unset Name of channel 0 (e.g. MediumFast/LongFast: the preset name the firmware uses when the channel name is blank, as shown by meshtastic --info). Used to compute the channel hash that identifies primary traffic on the UDP multicast. Required by UDP PRIMARY_CHANNEL_ONLY=1, because a secondary channel can share the default AQ== key: only the per-channel hash of (name, key) distinguishes them.
MESH_UDP_GROUP 224.0.0.69 Multicast group joined in UDP transport.
MESH_UDP_PORT 4403 Multicast port joined in UDP transport.
INGESTOR_NODE_ID unset !xxxxxxxx id used for the ingestor heartbeat. Required for the UDP transport, which cannot auto-detect "self" — without it the heartbeat never registers and its packets/hour stats stay at zero. Optional for PROTOCOL=reticulum, which derives its own id from the identity it owns; set it there only to override that. See Reticulum.
RETICULUM_CONFIG_DIR $XDG_CONFIG_HOME/potato-mesh/reticulum, else ~/.config/potato-mesh/reticulum Which Reticulum config the ingestor uses, and so where it looks for available interfaces. App-owned by default: it never adopts the operator's own ~/.reticulum. Set it only to deliberately share an existing RNS stack. See Reticulum.
RETICULUM_INTERFACES unset Which RNS interfaces to ingest announces from: name your local LoRa interface, e.g. RNode or BerlinMesh. Comma-separated and matched as a case-insensitive substring of the interface name. Empty ingests every interface. See Reticulum.
MESHCORE_TELEMETRY_POLL_SECONDS 300 Requires TX_ENABLED=1 (polling other nodes is a transmission). Seconds between Meshcore contact telemetry polls (one on-air request per interval, round-robin over the roster; each contact is additionally polled at most once per 24 h: when every contact is fresh the tick sends nothing). Set 0 to disable on-air polling.
MESHCORE_SELF_TELEMETRY_SECONDS 3600 Seconds between Meshcore host self-telemetry reads (battery/sensors over the companion link, no airtime). Set 0 to disable.
TX_ENABLED 0 Master switch for everything the ingestor puts on the air Off by default: the ingestor is a listener. Set to 1 to allow transmitting; this is what enables Meshcore on-air contact telemetry polling. Does not by itself enable announcements. See Transmitting on the mesh.
TX_ANNOUNCE 0 Set to 1 (with TX_ENABLED=1) to broadcast a one-line activity summary on the default channel: at most once per 24 h, never in the first 24 h after start. Off by default: it is unsolicited automated traffic on a channel people read. See Transmitting on the mesh.

Transmitting on the mesh

By default a PotatoMesh ingestor never transmits. It listens, and forwards what it hears to your dashboard. Nothing below happens unless you turn it on.

That default is deliberate. Airtime on a LoRa mesh is a shared, scarce resource, and whether automated traffic belongs on a community's channels is that community's decision: not a default we get to make for you.

Variable Default What turning it on means
TX_ENABLED 0 The ingestor may transmit. On its own this enables Meshcore on-air telemetry polling: round-robin requests to other nodes for their battery/sensor readings, at most one request per MESHCORE_TELEMETRY_POLL_SECONDS (default 300 s) and at most once per 24 h per node. This is how other nodes' telemetry reaches your dashboard. Meshtastic ingestion needs no transmission at all.
TX_ANNOUNCE 0 Requires TX_ENABLED=1. Broadcasts one line on your default channel, at most once every 24 h, and never in the first 24 h after the ingestor starts.

The announcement looks like this:

Meshtastic activity in the last 24h: 42 active nodes, 118 packets/hour. https://mesh.example.org

The numbers are read back from your own instance's public API, so they reflect the whole mesh your dashboard sees rather than the one radio doing the talking. It is suppressed automatically when your instance runs with PRIVATE=1, and that check fails closed: if the ingestor cannot reach your instance to ask, it does not transmit.

Both flags must be on for an announcement to go out:

TX_ENABLED TX_ANNOUNCE Result
0 0 Receive only. The default.
0 1 Receive only: TX_ENABLED=0 wins.
1 0 Telemetry polling on, no announcements.
1 1 Telemetry polling on, one announcement per 24 h.

The ingestor states the resolved policy in its log at startup, without needing DEBUG=1:

[2026-08-24T09:12:44.117Z] [potato-mesh] [info] context=tx.policy announcements_permitted=False rx_only=False transmit_permitted=False tx_announce=False tx_enabled=False Transmit policy resolved

If you set a flag and nothing happens, that line tells you what the ingestor actually resolved: including a flag that never reached the container.

Meshcore

Set PROTOCOL=meshcore to ingest from a Meshcore companion-firmware node instead (serial, TCP, or BLE via the same CONNECTION formats). Alongside contacts and messages, the ingestor captures RF metrics: per-message SNR and hop counts, per-channel-message RSSI and repeater path (decoded from the radio's RX log), and per-advert SNR/RSSI/hops for every node heard on air: including nodes the radio's contact roster has no room for.

Note: the ingestor writes one radio setting. At startup it enables the firmware's overwrite-oldest-contact flag (autoadd_config bit 0x01, firmware ≥ 1.16) so a full contact roster evicts its oldest non-favourite entry instead of rejecting new nodes. The write happens only when the bit is not already set, preserves all other auto-add settings, persists on the device, and never evicts favourites. Older firmware without the command is left untouched. Evicted contacts remain in the dashboard's database: only the radio's local roster rotates.

Reticulum

Set PROTOCOL=reticulum to ingest from a Reticulum (RNS) network. This is a passive announce listener: it registers announce handlers for the lxmf.delivery, nomadnetwork.node and lxmf.propagation destination aspects and files every announce it hears as a node. Which aspects a peer announces on is the only signal Reticulum gives about what it is, so the node's role is derived from them and ranked: PROPAGATION > NODE > PEER: rather than taken from whichever announce arrived last.

Announces carry no SNR, battery, or position, so those columns render as dashes and Reticulum nodes stay off the map. One peer is one node row: the canonical !xxxxxxxx id derives from the peer's identity hash, so a peer announcing on several destination aspects merges into a single row rather than appearing once per aspect.

You do not need to set INGESTOR_NODE_ID. Reticulum has no handshake revealing "our" node id, so the ingestor owns an RNS identity of its own, generated on first run and stored as potato_mesh_identity inside RETICULUM_CONFIG_DIR. Its !xxxxxxxx comes from that identity's hash through the same mapping applied to every peer, so it is stable across restarts and the ingestor's own row is shaped like any other. Set INGESTOR_NODE_ID only to override it. The identity is local and never announced, so this adds nothing the transmit switches gate.

If the identity can neither be read nor written — an unwritable config dir, or a key file left unreadable — the ingestor says so and carries on ingesting announces without a node id, which is the one case where the heartbeat stays unregistered and the packets/hour figure stays at zero.

CONNECTION does not apply. It names one serial, TCP, or BLE endpoint, and an RNS stack of many interfaces has no such thing. The two variables below cover the same ground: RETICULUM_CONFIG_DIR says which stack, RETICULUM_INTERFACES says which of its interfaces to ingest from. A set CONNECTION is ignored, and the ingestor logs that it was — the shipped container image carries a serial default for every protocol, so switching to PROTOCOL=reticulum inherits one you never chose.

Two things worth configuring:

RETICULUM_CONFIG_DIR defaults to an app-owned directory ($XDG_CONFIG_HOME/potato-mesh/reticulum, else ~/.config/potato-mesh/reticulum) rather than RNS's user default ~/.reticulum. Installing a dashboard ingestor should not silently adopt the transport-node configuration you run for yourself — including whatever enable_transport and interfaces it defines. Point the variable at your own directory if sharing a stack is what you want.

What the config dir starts with, and it differs by whether you scoped it.

Without RETICULUM_INTERFACES, RNS writes its own stock config, whose only interface is a link-local IPv6 AutoInterface. That hears the Reticulum peers on your LAN — the "ingests everything" default above — and the packaged ingestor service runs network_mode: host, so it behaves the same in Docker as on bare metal. (The opt-in ingestor-bridge profile is not on the host network and hears nothing there.)

With RETICULUM_INTERFACES set, the ingestor seeds the file itself, with share_instance = No and no interface enabled. Both parts are deliberate. Sharing is off because an allowlist can only be honoured on its own RNS stack: attached to a shared rnsd, every announce arrives over a single LocalInterface and the allowlist cannot tell them apart. And no interface is enabled because a stock AutoInterface is named AutoInterface[Default Interface], which matches no sensible allowlist — the listener would connect and ingest nothing. So a freshly scoped ingestor hears nothing until you add the interfaces you named; it warns about exactly that at startup, and the seeded file carries a commented RNodeInterface example to copy.

Two consequences worth stating plainly. A scoped ingestor does not inherit your rnsd's interfaces, so any radio you want it to hear has to be configured in its own file. And an AutoInterface never reaches a LoRa radio in any network mode — that always needs an RNodeInterface pointing at the device, or a TCPClientInterface pointing at a hub.

Edit the config file on the potatomesh_reticulum volume (Linux images; --no-deps stops it starting the web stack too):

docker compose run --rm --no-deps --entrypoint sh ingestor \
  -c 'vi /app/.config/potato-mesh/reticulum/config'

An existing config is never overwritten, so if you would rather share the stack, write the file yourself and the ingestor leaves it alone. The allowlist then cannot be honoured, so it admits every announce rather than none — and warns once that it is doing so.

RETICULUM_INTERFACES bounds what gets ingested. An RNS stack can carry LoRa and IP interfaces at once, and RNS's stock config enables an AutoInterface (IPv6 link-local multicast): so an unscoped listener files every announce on your LAN, not just your LoRa peers. Setting e.g. RETICULUM_INTERFACES=rnode restricts ingestion to announces whose path arrived on a matching interface. Leaving it empty ingests everything, which is the default. Reticulum exposes no "this peer is on LoRa" marker, so the interface a path arrived on is the only signal available.

Note that the RNS stack itself is not silent at the interface layer even though the ingestor is: an AutoInterface multicasts peer discovery, and a config with enable_transport set relays other nodes' traffic.

Passive UDP transport

The Meshtastic node radio accepts only one API client at a time (serial or TCP), so an ingestor connected over CONNECTION monopolizes the node: the phone app, CLI, and message sending fight it for the single slot. Setting TRANSPORT=udp switches the ingestor to a passive listener that never connects to the node's API at all: it joins the node's LAN multicast group (Meshtastic "Mesh via UDP", 224.0.0.69:4403) and decodes packets off the wire, leaving the node's API slot completely free.

Enable "Mesh via UDP" on the node first (meshtastic --set network.enabled_protocols 1). The ingestor decrypts the primary channel with PRIMARY_CHANNEL_KEY (the Meshtastic default key AQ== by default). Private channels with their own secret keys are cryptographically unreadable and dropped. To guarantee only channel 0 is ingested, set PRIMARY_CHANNEL_ONLY=1 and PRIMARY_CHANNEL_NAME (e.g. MediumFast): each packet advertises the hash of its channel (name + key), and only packets whose hash matches the primary channel's are accepted. This is stricter than decrypting with the primary key: a secondary channel created with the default AQ== key would decrypt too, but has a different name and therefore a different hash, so it is dropped. If PRIMARY_CHANNEL_NAME is not set while PRIMARY_CHANNEL_ONLY=1, the ingestor fails closed and drops everything. Because there is no API connection, the node's bulk node database is not read: the node list rebuilds over the air from observed packets, and payloads (position, telemetry, traceroute, …) are decoded into the exact same shape the API/serial transport produces, so the collector receives identical records.

TRANSPORT=udp requires host networking so the container can receive LAN multicast (network_mode: host). A ready-to-use Raspberry Pi (arm64) deployment is provided in data/tools/compose.udp.pi.yml. Capture live packets for testing with data/tools/capture_udp_fixtures.py.

Nix

For the dev shell, run:

nix develop

The shell provides Ruby plus the Python ingestor dependencies (including meshtastic and protobuf). To sanity-check that the ingestor starts, run python -m data.mesh with the usual environment variables (INSTANCE_DOMAIN, API_TOKEN, CONNECTION).

To run the packaged apps directly:

nix run .#web
nix run .#ingestor

Minimal NixOS module snippet:

services.potato-mesh = {
  enable = true;
  apiTokenFile = config.sops.secrets.potato-mesh-api-token.path;
  dataDir = "/var/lib/potato-mesh";
  port = 41447;
  instanceDomain = "https://mesh.me";
  siteName = "Nix Mesh";
  contactLink = "homeserver.mx";
  mapCenter = "28.96,-13.56";
  frequency = "868MHz";
  ingestor = {
    enable = true;
    connection = "192.168.X.Y:4403";
  };
};

Docker

Docker images are published on GitHub Container Registry for each release. Image names and tags follow the workflow format: ${IMAGE_PREFIX}-${service}-${architecture}:${tag} (see .github/workflows/docker.yml).

docker pull ghcr.io/l5yth/potato-mesh-web-linux-amd64:latest
docker pull ghcr.io/l5yth/potato-mesh-web-linux-arm64:latest
docker pull ghcr.io/l5yth/potato-mesh-web-linux-armv7:latest

docker pull ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:latest
docker pull ghcr.io/l5yth/potato-mesh-ingestor-linux-arm64:latest
docker pull ghcr.io/l5yth/potato-mesh-ingestor-linux-armv7:latest

docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-amd64:latest
docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-arm64:latest
docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-armv7:latest

# version-pinned examples
docker pull ghcr.io/l5yth/potato-mesh-web-linux-amd64:v0.7.5
docker pull ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:v0.7.5
docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-amd64:v0.7.5

Note: latest is only published for non-prerelease versions. Pre-release tags such as -rc, -beta, -alpha, or -dev are version-tagged only.

When using Compose, set POTATOMESH_IMAGE_ARCH in docker-compose.yml (or via environment) so service images resolve to the correct architecture variant and you avoid manual tag mistakes.

Feel free to run the configure.sh script to set up your environment. See the Docker guide for more details and custom deployment instructions.

Matrix Bridge

A matrix bridge is currently being worked on. It requests messages from a configured potato-mesh instance and forwards it to a specified matrix channel; see matrix/README.md.

matrix bridge

Mobile App

A mobile reader app is currently being worked on. Stay tuned for releases and updates.

Demos

Post your nodes and screenshots here:

License

Apache v2.0, Contact COM0@l5y.tech

Join our community chat to discuss the dashboard or ask for technical support: #potatomesh:dod.ngo

S
Description
A federated Meshtastic, Meshcore, and Reticulum node dashboard for your local community. No MQTT clutter, just local LoRa aether.
https://potatomesh.net Readme Apache-2.0
51 MiB
0 Stars 1 Watchers 0 Forks
Languages
JavaScript 36.5%
Ruby 32.4%
Python 26.5%
Rust 2.1%
CSS 1%
Other 1.5%