potato-mesh

From public sources

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

ApplicationCategory: Web Apps · Automation · Web analyticsOpen sourcedata checked Oct 16, 2026

Overview

  • dashboard
  • meshcore
  • meshtastic
  • qos
  • reticulum
  • webapp

Installation

Commands are shown, never run by the marketplace. Check them against the official documentation.

Compatibility

Unknown means no evidence was found — not that the product is incompatible.
PlatformStatusArchitectureEvidenceConfidence
Web browserUnknown———
Server / self-hostedUnknown———
Container (OCI)Unknown———
AndroidCompatible—official build in release v0.5.6: potatomesh-reader-android-v0.5.6.apk0.60

Releases

checked Oct 30, 2026
v1.0.0LatestOct 17, 2026Official source ↗

Files in 1.0.0

This release has no attached files.

Release history

Quality profile

algorithm quality-v1.0 · assessed Oct 16, 2026
Quality Score92
Risk Score0
Confidence 1.00
Quality Score and Risk Score are different measures. A high score does not guarantee safety.

Score components

Maintenance · 2020
last activity 1 days ago
Documentation · 1515
README 37118 chars, description present
Ready to use · 1515
install instructions: yes; releases: yes
Metadata · 1010
4/4 of description, homepage, license, topics
Releases · 1010
10 release(s), latest 52 days ago
Category confidence · 105
classifier confidence 0.5
Adoption · 107
523 stars (log-dampened, 10k = max)
License clarity · 55
SPDX Apache-2.0
Community · 55
issues enabled, 64 forks

License

Apache-2.0
Known licensefrom the repository host

This is the catalog policy status, not legal advice.

Sources

github.com/l5yth/potato-meshPrimary★ 523 starslast activity Oct 14, 2026Primary repository · Confidence 1.00

Reviews

Sign in to write a review

No reviews yet.

Alternatives

README

From l5yth/potato-mesh · license Apache-2.0 · fetched Oct 30, 2026
  • Web dashboard: map + chat, nodes, positions, neighbors, trace routes, telemetry, messages.
    • API to POST (authenticated) and GET nodes, messages, and telemetry.
    • New-node and telemetry notifications in chat.
    • Search and filter nodes in map and table view.
    • Federates automatically with other communities running PotatoMesh.
    • Supports Meshtastic, Meshcore, and Reticulum (experimental, announce-only).
  • Python ingestor feeds the web app's POST APIs remotely.
    • Supports multiple ingestors per instance.
    • Supports Meshtastic, Meshcore, and Reticulum (experimental, announce-only).
  • Matrix bridge posts Meshtastic messages to a Matrix channel (no radio required).

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 and SQLite3.

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

Run

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

Open 127.0.0.1:41447 for the dashboard. API_TOKEN is required to authorize the API's POST endpoints.

Production

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
  • Set RACK_ENV=production and APP_ENV=production.
  • Bind to a production port and all interfaces: -p 41447 -o 0.0.0.0.
  • Set a strong API_TOKEN to authorize POST requests.
  • Set INSTANCE_DOMAIN to your deployment's public URL.
  • Set MAP_CENTER to your region's coordinates.

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

VariableDefaultPurpose
API_TOKENrequiredShared secret that authorizes ingestors and API clients making POST requests.
INSTANCE_DOMAINauto-detectedPublic hostname (optionally with port) used for metadata, federation, and generated API links. With a scheme (https://mesh.example.org), generated URLs keep that scheme whatever a proxy forwards.
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_PRESETunsetMeshcore radio preset for the join strip; the Meshcore line is hidden until both MESHCORE_* values are set.
MESHCORE_FREQunsetMeshcore frequency for the join strip.
CONTACT_LINK"#potatomesh:dod.ngo"Chat link or Matrix alias rendered in the footer, the mobile menu, and overlays.
ANNOUNCEMENTunsetOptional announcement banner text rendered above the header on every page. http(s):// URLs become links; write [label](https://example.org) to link a label.
MAP_CENTER38.761944,-27.090833Latitude and longitude that centre the map on load.
MAP_ZOOMunsetFixed Leaflet zoom applied on first load; disables auto-fit when provided.
MAX_DISTANCE42Maximum distance (km) before node relationships are hidden on the map.
DEBUG0Set to 1 for verbose logging in the web services.
FEDERATION1Set to 1 to announce your instance and crawl peers, or 0 to disable federation. Private mode overrides this.
PRIVATE0Set to 1 to hide the chat UI, disable message APIs, and exclude hidden clients and their data from the API and pages.
EVENTS1Set to 0 to disable the live-update SSE stream (GET /api/events); clients then fall back to polling at the refresh interval.
MIN_THREADS16Minimum Puma worker threads kept warm.
MAX_THREADS96Maximum 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_URLunsetAbsolute http(s):// URL for the social preview image; other schemes are ignored. Replaces the generated /og-image.png. Use HTTPS - most platforms won't render an HTTP preview. The linux-armv7 web image has no Chromium and serves the fallback image unless this is set.
OG_IMAGE_NO_SANDBOX0Set to 1 to run the Chromium that renders /og-image.png without its sandbox. The Docker images set 1. With 0 where Chromium cannot start its sandbox, no preview is captured and /og-image.png serves its fallback image.
PAGES_DIR./pagesThe directory for static, custom-content pages.
PROM_REPORT_IDSunsetComma-separated node ids to expose as per-node Prometheus gauges. Empty exports none. Opted-out nodes, and hidden clients under PRIVATE=1, are never exported.

/robots.txt and /sitemap.xml are generated automatically and respect PRIVATE/FEDERATION. Markdown files in pages/ may set YAML frontmatter (title, description, image, noindex); image must be an absolute http(s):// URL - other schemes are dropped.

If INSTANCE_DOMAIN is unset in production, the app warns once at startup and falls back to the inbound Host header for canonical URLs - vulnerable to cache poisoning behind a misconfigured proxy. Set INSTANCE_DOMAIN to avoid this.

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

Runtime assets follow the XDG base directory spec, falling back to the repository root when unset:

  • Key: $XDG_CONFIG_HOME/potato-mesh/keyfile
  • Well-known document: $XDG_CONFIG_HOME/potato-mesh/well-known/potato-mesh
  • Database: $XDG_DATA_HOME/potato-mesh

While the web app runs, its newest writes sit in mesh.db-wal beside mesh.db. Copy the database with:

sqlite3 ~/.local/share/potato-mesh/mesh.db ".backup mesh-copy.db"

In Docker, prefix it with docker compose exec web and use /app/.local/share/potato-mesh/ for both files. After a graceful stop of the web app, mesh.db alone holds every write.

If the web app exits at boot with Instance private key file cannot be parsed, restore the file it names from a backup, or delete that file to start with a new key. A new key gives the instance a new id; peers replace its old record when it next announces.

Outbound requests. The map loads basemap tiles from two third-party CDNs on every viewport: OpenStreetMap HOT (tile.openstreetmap.fr) and CARTO (basemaps.cartocdn.com). Only z/x/y tile coordinates are sent - no key, cookie, or analytics parameter. Tiles are the only third-party request the dashboard makes. Tile URLs must use https://; http:// tiles do not load.

Privacy

  • Hide a node: put U+1F6D1 (stop sign) anywhere in its short or long name. The API, pages, /metrics and federation counts then omit the node and its positions, telemetry, neighbors, traces, messages and waypoints. Ingest continues and the rows stay stored.
  • Reticulum: put the marker in the node's headline name, the name the dashboard shows. A marker in another destination's name hides nothing.
  • Retention: rows not updated for 365 days are deleted daily. No /api/ read returns data older than 28 days.

Custom Pages

Add Markdown files to web/pages/ to publish static content pages (contact info, rules, legal notices). Each .md file becomes a footer link, a mobile menu entry, and a route at /pages/<slug>.

Filename format: <sort-prefix>-<slug>.md. The prefix sets nav order; the slug sets the URL and nav label:

FilenameNav LabelURL
1-about.mdAbout/pages/about
5-rules.mdRules/pages/rules
9-contact.mdContact/pages/contact
20-impressum.mdImpressum/pages/impressum
  • Ships with a default 1-about.md.
  • Docker: the directory is the potatomesh_pages volume (/app/pages) - edit pages without rebuilding.
  • Override the directory with PAGES_DIR.
  • Images in a page must use https:// URLs; http:// images do not load.

Federation

  • FEDERATION=1 (default): announce this instance and crawl peers after boot and every 8 hours, and respond to crawlers. An instance on an IP address does not announce itself, and peers refuse it; it still crawls peers and answers their announcements. Set INSTANCE_DOMAIN to a hostname such as mesh.example.org to announce it.
  • FEDERATION=0: fully isolated, no federation.
  • PRIVATE=1 disables federation regardless of FEDERATION.
  • Federation requests send the User-Agent PotatoMesh/<version> (+https://<domain>); deploy/nginx.example.conf has an optional rate limit for them.
  • Serve https with a valid certificate, or let port 443 refuse connections: peers fall back to plain http only when https is refused or INSTANCE_DOMAIN names a port.

API

All GET routes accept ?limit= and ?since=; bulk collections also accept ?before= and ?protocol=. All POST routes require Authorization: Bearer <API_TOKEN>.

Collections - GET list, GET /:id for one node's rows, POST to ingest:

PathNotes
/api/nodesGET, GET /:id, POST
/api/positionsGET, GET /:id, POST
/api/telemetryGET, GET /:id, POST; /api/telemetry/aggregated for rollups
/api/messagesGET, GET /:id, POST; all 404 when PRIVATE=1
/api/neighborsGET, GET /:id, POST
/api/tracesGET, GET /:id, POST
/api/waypointsGET, GET /:id, POST; all 404 when PRIVATE=1
/api/destinationsGET; ?node_id= filters. Reticulum destinations per node
/api/ingestorsGET, POST. Active ingestors feeding this instance
/api/instancesGET, POST. Known federated instances

Other

PathReturns
GET /api/statsNode/message/telemetry counts per protocol and window
GET /api/stats/activityPackets/hour time series per protocol
GET /api/eventsSSE stream of collection-change events
GET /versionInstance name, version, and public config
GET /metricsPrometheus exporter - see PROMETHEUS.md
GET /.well-known/potato-meshSigned federation record for this instance
GET /og-image.pngOpen Graph preview image
GET /robots.txt, GET /sitemap.xmlCrawler directives

Pages - GET / dashboard, GET /nodes/:id node detail, GET /pages/:slug custom pages. Static assets: GET /favicon.ico, GET /potatomesh-logo.svg.

There is no health endpoint; use GET /version.

Advanced tuning

Internal tuning knobs, all optional; set only to change the defaults shown. Not pre-declared in .env.example, Compose, or the NixOS module - set them directly in the web process's environment.

VariableDefaultPurpose
MIN_THREADS16Puma minimum thread count.
MAX_THREADS96Puma maximum thread count.
PUMA_FORCE_SHUTDOWN3Seconds Puma waits before forcing shutdown.
STATS_CACHE_TTL_SECONDS60Cache lifetime for /api/stats responses.
OG_IMAGE_TTL_SECONDS3600Cache lifetime for the generated /og-image.png.
LIVE_SAFETY_POLL_SECONDS300Interval of the fallback poll that backstops the SSE stream.
SSE_HEARTBEAT_SECONDS15Comment-frame keepalive interval on /api/events.
SSE_MAX_LIFETIME_SECONDS600Maximum lifetime of one SSE connection before the client reconnects.
SSE_PUBLISH_COOLDOWN1.0Minimum seconds between SSE publishes, coalescing bursts.
SSE_THREAD_RESERVE32Threads held back from SSE so ordinary requests keep being served.
FEDERATION_WORKERS4Federation crawl worker-pool size.
FEDERATION_WORK_QUEUE128Queued federation tasks; an announcement or crawl beyond them is skipped until the next cycle.
FEDERATION_TASK_TIMEOUT120Seconds before one federation task, such as a crawl, is abandoned.
FEDERATION_SHUTDOWN_TIMEOUT3Seconds to drain federation workers on shutdown.
FEDERATION_PEER_FETCH_COOLDOWN900Minimum seconds between two fetches of one peer host.
FEDERATION_MAX_DOMAINS_PER_CRAWL256Most peer domains one crawl fetches.
FEDERATION_MAX_REQUESTS_PER_CRAWL1024Most requests one crawl sends.
FEDERATION_MAX_INSTANCES_PER_RESPONSE64Entries read from one peer's instance list.
INITIAL_FEDERATION_DELAY_SECONDS2Seconds before the first announcement and crawl after boot.
REMOTE_INSTANCE_CONNECT_TIMEOUT15Connect timeout when fetching a peer.
REMOTE_INSTANCE_READ_TIMEOUT60Read timeout when fetching a peer.
REMOTE_INSTANCE_REQUEST_TIMEOUT30Overall request timeout when fetching a peer.
REMOTE_INSTANCE_MAX_RESPONSE_BYTES8388608Response ceiling (8 MiB) when fetching a peer.
MESHTASTIC_PSK_B64AQ==Base64 PSK used to decrypt Meshtastic messages that ingestors forward still encrypted (default = Meshtastic default key).

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 never connects to a radio; it only ingests via authenticated POST. Run one or more Python ingestors from ./data to feed it - each connects to a LoRa node over serial, TCP, or Bluetooth (BLE), and multiple ingestors can feed one instance without duplicating data.

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

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

Configure with the environment variables below.

VariableDefaultPurpose
API_TOKENrequiredShared secret that authorizes ingestors and API clients making POST requests.
INSTANCE_DOMAINrequiredPublic hostname (optionally with port) used for feeding the API with data. To feed several instances, list them comma-separated and set API_TOKEN to one token for all of them or to a comma-separated token per instance, in the same order.
PROTOCOLmeshtasticWhich protocol are we ingesting? One of meshtastic, meshcore, or reticulum.
CONNECTION/dev/ttyACM0Where do we talk to the node? Accepts serial ports, TCP host:port (e.g. 192.168.1.20:4403), and Bluetooth addresses: MAC format (e.g. ED:4D:9E:95:CF:60) or, on macOS, UUID format (e.g. C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E). Ignored under PROTOCOL=reticulum, which has no single endpoint - see Reticulum.
DEBUG0Set to 1 for verbose logging in the ingestor services. The ingestor then writes dropped-packet metadata to ignored-meshtastic.txt and unhandled MeshCore frames to ignored-meshcore.txt in the repository root (/app in the container), each rotated at 10 MB with one previous file kept.
CHANNEL_INDEX0Channel index the activity announcement is sent on (see TX_ANNOUNCE). It does not filter what is ingested.
ENERGY_SAVING0Set to 1 to duty-cycle the radio connection instead of holding it open.
FREQUENCYunsetDeprecated alias for MESHTASTIC_FREQ; overrides the auto-detected LoRa frequency.
CHANNELunsetDeprecated alias for MESHTASTIC_PRESET.
ALLOWED_CHANNELSunsetComma-separated channel names the ingestor accepts (e.g. Chat,Ops); when set, messages, positions, telemetry and node info heard on any other channel are dropped, before HIDDEN_CHANNELS applies. The node list sent at connect can still carry a node's last position, metrics and signal details (last heard, SNR, hop limit) heard on a filtered channel. Ignored under PROTOCOL=reticulum.
HIDDEN_CHANNELSunsetComma-separated channel names the ingestor drops: messages, positions, telemetry and node info heard on them are not forwarded. The node list sent at connect can still carry a node's last position, metrics and signal details (last heard, SNR, hop limit) heard on a filtered channel. Ignored under PROTOCOL=reticulum.
DROP_VIA_MQTT0Set to 1 to drop Meshtastic packets and nodes the radio marks as relayed via MQTT.
TRANSPORTapiIngestor transport: api (Meshtastic library over serial/TCP/BLE) or udp (passive LAN multicast; see Passive UDP transport).
PRIMARY_CHANNEL_ONLY0Set 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). The node list sent at connect can still carry a node's last position, metrics and signal details (last heard, SNR, hop limit) heard on a filtered channel. Ignored under PROTOCOL=reticulum.
PRIMARY_CHANNEL_KEYAQ==Base64 PSK used to decrypt the primary channel in UDP transport (default = Meshtastic default key).
PRIMARY_CHANNEL_NAMEunsetName of channel 0 (e.g. MediumFast); find it with meshtastic --info if blank on the radio. Required by UDP PRIMARY_CHANNEL_ONLY=1.
MESH_UDP_GROUP239.0.0.69,224.0.0.69Comma-separated multicast groups joined in UDP transport. The default listens on both; set one address to listen on that group only.
MESH_UDP_PORT4403Multicast port joined in UDP transport.
MESH_UDP_ALLOWED_SOURCESunsetComma-separated IPv4 addresses or CIDRs (e.g. 192.168.1.20,10.0.0.0/24) allowed to send in UDP transport; packets from any other address are dropped. Unset accepts every host. An invalid entry stops the ingestor at startup.
INGESTOR_NODE_IDunset!xxxxxxxx id used for the ingestor heartbeat. Required for the UDP transport, which cannot auto-detect "self". Required for PROTOCOL=reticulum in Docker, when nothing on the ingestor's RNS stack announces, or when two local identities tie; otherwise the id is derived from your primary identity and this overrides it. For PROTOCOL=reticulum, the full 32-hex identity hash also pins the host identity.
RETICULUM_CONFIG_DIR~/.reticulumWhich RNS config the ingestor uses, and so which interfaces it can see. Point it at the directory your rnsd uses. See Reticulum.
RETICULUM_FREQfrom RNS configFrequency shown for Reticulum nodes. Overrides the value read from your RNodeInterface section. Also shown in the footer join strip when both are set.
RETICULUM_PRESETfrom RNS configRadio preset shown for Reticulum nodes. Overrides the value derived from your bandwidth/spreading-factor/coding-rate. Also shown in the footer join strip when both are set.
RETICULUM_INTERFACESunsetWhich RNS interfaces to ingest from. Unset: RNode interfaces only. *: every interface. Otherwise comma-separated, case-insensitive substrings of the names in rnstatus, e.g. RNode. Your own nodes are always ingested. See Reticulum.
MESHCORE_TELEMETRY_POLL_SECONDS300Requires 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_TELEMETRY_POLL_24H_EXEMPTunsetComma-separated node ids exempt from the once-per-24 h cap; listed nodes are polled every round-robin rotation (still one request per interval).
MESHCORE_SELF_TELEMETRY_SECONDS3600Seconds between Meshcore host self-telemetry reads (battery/sensors over the companion link, no airtime). Set 0 to disable.
TX_ENABLED0Master switch for all ingestor transmissions. 0 = listen only. 1 = allow transmit - enables Meshcore on-air telemetry polling; does not by itself enable announcements. See Transmitting on the mesh.
TX_ANNOUNCE0Requires TX_ENABLED=1. Broadcasts a one-line activity summary at most once per 24 h, never in the first 24 h after start. See Transmitting on the mesh.

Before upgrading, delete ignored-*.txt* files: they may hold message text. ignored-meshcore.txt can hold hidden-channel content; treat it as private.

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. Under PROTOCOL=reticulum the RNS stack can transmit on its own; see Reticulum.

VariableDefaultWhat turning it on means
TX_ENABLED0The 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_ANNOUNCE0Requires 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

Numbers come from the instance's public API. Suppressed when the instance is PRIVATE=1; fails closed (no transmit) if the check can't reach the instance.

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

TX_ENABLEDTX_ANNOUNCEResult
00Receive only. The default.
01Receive only: TX_ENABLED=0 wins.
10Telemetry polling on, no announcements.
11Telemetry polling on, one announcement per 24 h.

The ingestor logs the resolved policy at startup, without 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 a flag seems to have no effect, that line shows 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 (serial, TCP, or BLE via the same CONNECTION formats). Captures RF metrics alongside contacts and messages: per-message SNR/hop counts, per-channel-message RSSI, repeater path and flood scope, and per-advert SNR/RSSI/hops for every node heard - including nodes with no room in the radio's contact roster.

The chat shows each message's hop count and scope. To show your region's name instead of ??, set the radio's default flood scope in the MeshCore app (firmware 1.15 or later).

The ingestor writes one radio setting at startup: enables the firmware's overwrite-oldest-contact flag (autoadd_config bit 0x01, firmware ≥ 1.16) when not already set.

  • Never evicts favourites; other auto-add settings are preserved.
  • Older firmware (no command support) is left untouched.
  • Evicted contacts stay in the dashboard's database - only the radio's local roster rotates.

Reticulum

Reticulum support is experimental and announce-only. Set PROTOCOL=reticulum to file each announce on a Reticulum (RNS) network as a node. Messages, telemetry and other nodes' positions are not ingested. The ingestor publishes only its own host's position, from your RNS config (below).

The ingestor sends no announces, messages or polls, so TX_ENABLED=0 (the default) is fine. TX_ENABLED does not gate the RNS stack, which transmits whatever its config enables, such as AutoInterface discovery or relaying with enable_transport. With no rnsd running, the ingestor runs that stack itself, from RETICULUM_CONFIG_DIR.

CONNECTION does not apply; if set, the ingestor ignores it and logs that it did.

It reads your existing ~/.reticulum and ingests what your RNode interfaces hear:

API_TOKEN=... INSTANCE_DOMAIN=https://your.instance PROTOCOL=reticulum ./data/mesh.sh

To also ingest TCP, Backbone and AutoInterface peers, set RETICULUM_INTERFACES=*.

To pick interfaces by name, set RETICULUM_INTERFACES to part of each interface name from rnstatus - matching is case-insensitive substring:

RETICULUM_INTERFACES="RNode Reticulum Berlin"

Your own nodes are always ingested regardless of this setting. Everything further away is filtered by it. With no RNode on the stack and the variable unset, only your own nodes are ingested and the ingestor logs a warning.

To use a different RNS config, set RETICULUM_CONFIG_DIR, pointing at the same directory your rnsd uses - interface filtering fails across mismatched directories.

In Docker, the config dir is the potatomesh_reticulum volume (/app/.config/potato-mesh/reticulum). RNS seeds a stock config there with only a link-local AutoInterface and no RNode, so nothing is ingested from it:

  • Add your RNode interface to the volume's config file, then restart: docker compose exec ingestor sh -c 'cat >> /app/.config/potato-mesh/reticulum/config'
  • Or point RETICULUM_CONFIG_DIR at a bind mount of the host's ~/.reticulum and run with host networking.

In Docker, also set INGESTOR_NODE_ID. With the potatomesh_reticulum volume nothing announces, so without it the ingestor heartbeat never registers.

The ingestor derives its node id from your primary identity - the one announcing the most destinations on this machine. INGESTOR_NODE_ID overrides it. It reports the id it resolved at startup:

context=reticulum.connect ... node_id='!27716218' Reticulum announce listener registered

node_id='pending' means nothing on this machine has announced yet, or two identities tie. The ingestor retries every minute and registers the id once something announces. Set INGESTOR_NODE_ID to pin it in a tie, or if nothing on the ingestor's RNS stack announces. The id is neither the transport identity nor the hash rnstatus prints as "Transport Instance".

To pin the host identity too, set INGESTOR_NODE_ID to the full 32-hex identity hash. Its destinations are then found without discovery, and rns.transport stays listed when nothing on this machine announces. If that identity announces no lxmf or nomadnetwork destination here, the ingestor switches to the one announcing the most here, unless two tie, and logs Reticulum host identity switched with both ids; it switches back when that identity announces one.

To keep the host's own destinations listed, have each local app announce at least once a week: set announce_interval (minutes) in nomadnet's [node] and [client] sections and in lxmd's [propagation] and [lxmf] sections. lxmd's [lxmf] section sets none by default.

Changing the id strands the old row. Setting, changing, or removing INGESTOR_NODE_ID moves the ingestor's node id; the previous row stays in /api/ingestors without heartbeats until it ages out. Leaving the variable as-is across upgrades is inert.

After each connect the ingestor logs Reticulum stack state: role='client' means it is attached to rnsd, and received counts the announces that reached it. Every hour it logs Reticulum announce summary with the announces delivered, admitted and dropped per reason. That line is a warning when no announce reached the ingestor or the interface scope dropped all of them; follow its hints.

Frequency and preset are read from the first RNodeInterface in your RNS config; RETICULUM_FREQ and RETICULUM_PRESET override them. A matching preset name describes radio settings only, not interoperability with a Meshtastic mesh; otherwise it shows SF8/BW125/CR5. Values are read once at startup - change them in the RNS config and restart the ingestor, or the dashboard keeps showing the old ones.

The ingestor publishes the latitude, longitude and height (metres) keys of the first RNodeInterface in your RNS config as this host's position, at startup and every hour, and the host appears on the map. If that block already sets them, for example for RNS interface discovery, they are published after the upgrade. Add them to that block and restart the ingestor:

    latitude = 52.5029
    longitude = 13.4042
    height = 34

Remove the keys and restart the ingestor to stop the updates. The dashboard keeps the last published position until you clear it; replace 27716218 with the node id from the startup line, without its !:

sqlite3 ~/.local/share/potato-mesh/mesh.db "UPDATE nodes SET latitude = NULL, longitude = NULL, altitude = NULL, position_time = NULL, location_source = NULL WHERE node_id = char(33) || '27716218'; DELETE FROM positions WHERE node_id = char(33) || '27716218';"

In Docker, prefix it with docker compose exec web and use the database at /app/.local/share/potato-mesh/mesh.db.

A node is one row per identity - a peer running both LXMF and a nomadnet node is still one entry. Its destinations (addresses) are listed via GET /api/destinations and shown as sub-rows grouped under the identity in the dashboard table.

Reticulum nodes show dashes for battery and have no messages. Other than this host, they also show dashes for position and stay off the map.

Passive UDP transport

Meshtastic's node API accepts only one client at a time. Set TRANSPORT=udp to run the ingestor as a passive listener on the node's LAN multicast groups (port 4403) instead of connecting to the API - leaving the API slot free for the phone app or CLI.

Enable "Mesh via UDP" on the node first: meshtastic --set network.enabled_protocols 1.

  • Listens on 239.0.0.69 (firmware 2.8 and later) and 224.0.0.69 (earlier firmware). Set MESH_UDP_GROUP to one of them to listen on that group only.
  • Accepts packets from every host that can reach the multicast group. Set MESH_UDP_ALLOWED_SOURCES to your node's IP address (or a CIDR) to accept packets from those addresses only.
  • Decrypts the primary channel with PRIMARY_CHANNEL_KEY (default AQ==). Channels with other keys are dropped undecrypted.
  • Set PRIMARY_CHANNEL_ONLY=1 and PRIMARY_CHANNEL_NAME to ingest only channel 0. Without PRIMARY_CHANNEL_NAME set, PRIMARY_CHANNEL_ONLY=1 drops every packet (fail closed).
  • The node list rebuilds from observed packets - the node's own database is not read. Decoded payloads (position, telemetry, traceroute, …) match the API/serial transport shape.

TRANSPORT=udp requires host networking (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";
  };
};

Every variable in the tables above has a module option, named in camelCase (SITE_NAME → siteName, OG_IMAGE_URL → ogImageUrl):

  • Web options sit at the top level; ingestor options sit under ingestor.
  • PROTOCOL, TRANSPORT, INGESTOR_NODE_ID → ingestor.protocol, ingestor.transport, ingestor.nodeId.
  • RETICULUM_CONFIG_DIR, RETICULUM_INTERFACES → ingestor.reticulumConfigDir, ingestor.reticulumInterfaces.
  • Options typed nullOr emit no variable when left null, so the app keeps its own default.

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:v1.0.0
docker pull ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:v1.0.0
docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-amd64:v1.0.0

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

Work in progress. Forwards messages from a PotatoMesh instance to a Matrix channel (no radio required). See matrix/README.md.

[matrix bridge]

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

Open the repository ↗