Files
potato-mesh/README.md
T

645 lines
36 KiB
Markdown

<!-- Copyright © 2025-26 l5yth & contributors -->
<!-- Licensed under the Apache License, Version 2.0 (see LICENSE) -->
<p align="center">
<a href="https://potatomesh.net"><img src="./web/public/potatomesh-logo.svg" alt="" width="112" height="112"></a>
</p>
<h1 align="center">PotatoMesh</h1>
<p align="center">
A federated Meshtastic, Meshcore, and Reticulum node dashboard for your local community.<br>
<em>No MQTT clutter, just local LoRa aether.</em>
</p>
<p align="center">
<a href="https://github.com/l5yth/potato-mesh/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/l5yth/potato-mesh/ruby.yml?branch=main"></a>
<a href="https://github.com/l5yth/potato-mesh/releases"><img alt="GitHub release" src="https://img.shields.io/github/v/release/l5yth/potato-mesh"></a>
<a href="https://codecov.io/gh/l5yth/potato-mesh"><img alt="codecov" src="https://codecov.io/gh/l5yth/potato-mesh/branch/main/graph/badge.svg?token=FS7252JVZT"></a>
<a href="LICENSE"><img alt="Open-Source License" src="https://img.shields.io/github/license/l5yth/potato-mesh"></a>
<a href="https://github.com/l5yth/potato-mesh/issues"><img alt="Contributions Welcome" src="https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat"></a>
<a href="https://matrix.to/#/#potatomesh:dod.ngo"><img alt="Matrix Chat" src="https://img.shields.io/badge/matrix-%23potatomesh:dod.ngo-blue"></a>
</p>
<p align="center">
<a href="https://meshtastic.org"><img alt="Meshtastic" src="https://img.shields.io/badge/Meshtastic-supported-67ea94"></a>
<a href="https://meshcore.io"><img alt="Meshcore" src="https://img.shields.io/badge/Meshcore-supported-1f2937"></a>
<a href="https://reticulum.network"><img alt="Reticulum" src="https://img.shields.io/badge/Reticulum-experimental-7b61ff"></a>
</p>
<p align="center">
<a href="https://potatomesh.net"><img src="./scrot-1.0.png" alt="PotatoMesh 1.0 dashboard: chat log, live node map and nodes table"></a>
</p>
<p align="center">Live demo for Berlin: <a href="https://potatomesh.net">potatomesh.net</a></p>
* 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](#web-app) - configuration, running, deploying, monitoring, etc.
- [Ingestor](#ingestor) - configuration, running, transmitting, etc.
- [Nix](#nix) - nix deployment
- [Docker](#docker) - docker deployment
- [Matrix Bridge](#matrix-bridge) - configuration and deployment
## Web App
Requires Ruby and SQLite3.
```bash
pacman -S ruby sqlite3
gem install sinatra sqlite3 rackup puma rspec rack-test rufo prometheus-client
cd ./web
bundle install
```
### Run
```bash
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](http://127.0.0.1:41447/) for the dashboard. `API_TOKEN` is required to authorize the API's `POST` endpoints.
### Production
```bash
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):
| 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. 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_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, the mobile menu, and overlays. |
| `ANNOUNCEMENT` | _unset_ | Optional 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_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 and their data from the API and pages. |
| `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_ | Absolute `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. |
| `OG_IMAGE_NO_SANDBOX` | `0` | Set 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` | `./pages` | The directory for static, custom-content pages. |
| `PROM_REPORT_IDS` | _unset_ | Comma-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:
```bash
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:
```bash
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:
| 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` |
- 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.
### 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:
| Path | Notes |
| --- | --- |
| `/api/nodes` | `GET`, `GET /:id`, `POST` |
| `/api/positions` | `GET`, `GET /:id`, `POST` |
| `/api/telemetry` | `GET`, `GET /:id`, `POST`; `/api/telemetry/aggregated` for rollups |
| `/api/messages` | `GET`, `GET /:id`, `POST`; all 404 when `PRIVATE=1` |
| `/api/neighbors` | `GET`, `GET /:id`, `POST` |
| `/api/traces` | `GET`, `GET /:id`, `POST` |
| `/api/waypoints` | `GET`, `GET /:id`, `POST`; all 404 when `PRIVATE=1` |
| `/api/destinations` | `GET`; `?node_id=` filters. Reticulum destinations per node |
| `/api/ingestors` | `GET`, `POST`. Active ingestors feeding this instance |
| `/api/instances` | `GET`, `POST`. Known federated instances |
Other
| Path | Returns |
| --- | --- |
| `GET /api/stats` | Node/message/telemetry counts per protocol and window |
| `GET /api/stats/activity` | Packets/hour time series per protocol |
| `GET /api/events` | SSE stream of collection-change events |
| `GET /version` | Instance name, version, and public config |
| `GET /metrics` | Prometheus exporter - see [`PROMETHEUS.md`](./PROMETHEUS.md) |
| `GET /.well-known/potato-mesh` | Signed federation record for this instance |
| `GET /og-image.png` | Open Graph preview image |
| `GET /robots.txt`, `GET /sitemap.xml` | Crawler 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.
| Variable | Default | Purpose |
| --- | --- | --- |
| `MIN_THREADS` | `16` | Puma minimum thread count. |
| `MAX_THREADS` | `96` | Puma maximum thread count. |
| `PUMA_FORCE_SHUTDOWN` | `3` | Seconds Puma waits before forcing shutdown. |
| `STATS_CACHE_TTL_SECONDS` | `60` | Cache lifetime for `/api/stats` responses. |
| `OG_IMAGE_TTL_SECONDS` | `3600` | Cache lifetime for the generated `/og-image.png`. |
| `LIVE_SAFETY_POLL_SECONDS` | `300` | Interval of the fallback poll that backstops the SSE stream. |
| `SSE_HEARTBEAT_SECONDS` | `15` | Comment-frame keepalive interval on `/api/events`. |
| `SSE_MAX_LIFETIME_SECONDS` | `600` | Maximum lifetime of one SSE connection before the client reconnects. |
| `SSE_PUBLISH_COOLDOWN` | `1.0` | Minimum seconds between SSE publishes, coalescing bursts. |
| `SSE_THREAD_RESERVE` | `32` | Threads held back from SSE so ordinary requests keep being served. |
| `FEDERATION_WORKERS` | `4` | Federation crawl worker-pool size. |
| `FEDERATION_WORK_QUEUE` | `128` | Queued federation tasks; an announcement or crawl beyond them is skipped until the next cycle. |
| `FEDERATION_TASK_TIMEOUT` | `120` | Seconds before one federation task, such as a crawl, is abandoned. |
| `FEDERATION_SHUTDOWN_TIMEOUT` | `3` | Seconds to drain federation workers on shutdown. |
| `FEDERATION_PEER_FETCH_COOLDOWN` | `900` | Minimum seconds between two fetches of one peer host. |
| `FEDERATION_MAX_DOMAINS_PER_CRAWL` | `256` | Most peer domains one crawl fetches. |
| `FEDERATION_MAX_REQUESTS_PER_CRAWL` | `1024` | Most requests one crawl sends. |
| `FEDERATION_MAX_INSTANCES_PER_RESPONSE` | `64` | Entries read from one peer's instance list. |
| `INITIAL_FEDERATION_DELAY_SECONDS` | `2` | Seconds before the first announcement and crawl after boot. |
| `REMOTE_INSTANCE_CONNECT_TIMEOUT` | `15` | Connect timeout when fetching a peer. |
| `REMOTE_INSTANCE_READ_TIMEOUT` | `60` | Read timeout when fetching a peer. |
| `REMOTE_INSTANCE_REQUEST_TIMEOUT` | `30` | Overall request timeout when fetching a peer. |
| `REMOTE_INSTANCE_MAX_RESPONSE_BYTES` | `8388608` | Response ceiling (8 MiB) when fetching a peer. |
| `MESHTASTIC_PSK_B64` | `AQ==` | 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`](./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.
```bash
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.
```bash
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.
| 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. 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. |
| `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 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](#reticulum). |
| `DEBUG` | `0` | Set 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_INDEX` | `0` | Channel index the activity announcement is sent on (see `TX_ANNOUNCE`). It does not filter what is ingested. |
| `ENERGY_SAVING` | `0` | Set to `1` to duty-cycle the radio connection instead of holding it open. |
| `FREQUENCY` | _unset_ | Deprecated alias for `MESHTASTIC_FREQ`; overrides the auto-detected LoRa frequency. |
| `CHANNEL` | _unset_ | Deprecated alias for `MESHTASTIC_PRESET`. |
| `ALLOWED_CHANNELS` | _unset_ | Comma-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_CHANNELS` | _unset_ | Comma-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_MQTT` | `0` | Set to `1` to drop Meshtastic packets and nodes the radio marks as relayed via MQTT. |
| `TRANSPORT` | `api` | Ingestor transport: `api` (Meshtastic library over serial/TCP/BLE) or `udp` (passive LAN multicast; see [Passive UDP transport](#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). 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_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`); find it with `meshtastic --info` if blank on the radio. Required by UDP `PRIMARY_CHANNEL_ONLY=1`. |
| `MESH_UDP_GROUP` | `239.0.0.69,224.0.0.69` | Comma-separated multicast groups joined in UDP transport. The default listens on both; set one address to listen on that group only. |
| `MESH_UDP_PORT` | `4403` | Multicast port joined in UDP transport. |
| `MESH_UDP_ALLOWED_SOURCES` | _unset_ | Comma-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_ID` | _unset_ | `!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. |
| `RETICULUM_CONFIG_DIR` | `~/.reticulum` | Which RNS config the ingestor uses, and so which interfaces it can see. Point it at the directory your `rnsd` uses. See [Reticulum](#reticulum). |
| `RETICULUM_FREQ` | _from RNS config_ | Frequency shown for Reticulum nodes. Overrides the value read from your `RNodeInterface` section. Also shown in the footer join strip when both are set. |
| `RETICULUM_PRESET` | _from RNS config_ | Radio 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_INTERFACES` | _unset_ | Which 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](#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_TELEMETRY_POLL_24H_EXEMPT` | _unset_ | Comma-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_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 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](#transmitting-on-the-mesh). |
| `TX_ANNOUNCE` | `0` | Requires `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](#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](#reticulum).
| 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
```
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_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 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:
```bash
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:
```bash
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".
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 `!`:
```bash
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`](data/tools/compose.udp.pi.yml). Capture live
packets for testing with
[`data/tools/capture_udp_fixtures.py`](data/tools/capture_udp_fixtures.py).
## Nix
For the dev shell, run:
```bash
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:
```bash
nix run .#web
nix run .#ingestor
```
Minimal NixOS module snippet:
```nix
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`).
```bash
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](./configure.sh) script to set up your
environment. See the [Docker guide](DOCKER.md) 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/README.md).
![matrix bridge](./scrot-0.6.png)
## Demos
Post your nodes and screenshots here:
* <https://github.com/l5yth/potato-mesh/discussions/258>
## License
Apache v2.0, Contact <COM0@l5y.tech>
Join our community chat to discuss the dashboard or ask for technical support:
[#potatomesh:dod.ngo](https://matrix.to/#/#potatomesh:dod.ngo)