A two-process Meshtastic coverage service. A dedicated MQTT ingester writes SQLite; a read-only Flask application serves a Leaflet heatmap through Apache/mod_wsgi (or Gunicorn during development).
python3 -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
cp config.ini.example config.ini
# Edit config.ini, especially [repeaters].nodes
.venv/bin/python ingest.py --config config.iniIn another terminal:
COVERAGE_CONFIG=$PWD/config.ini .venv/bin/python app.pyOpen http://localhost:5000. The web process never connects to MQTT and can safely run with multiple WSGI workers.
Configuration lives in config.ini. Relative database paths are resolved relative to the INI file. The ingester and WSGI app must point to the same file, either with --config, the COVERAGE_CONFIG environment variable, or the system path /etc/coverage-analyzer/config.ini.
[repeaters] nodesis a comma-separated allowlist of MQTTgateway_idvalues, optionally followed by a label. Leave it blank to collect every gateway.[mqtt] topicshould normally be an encrypted packet topic such asmsh/US/2/e/#.[mqtt] channel_keyaccepts a base64 or hex PSK.AQ==is public LongFast.[channel_keys]optionally maps MQTT channel names to their own base64 or hex PSKs. Names are case-insensitive. A channel not listed there uses the fallback[mqtt] channel_key.[database] retention_dayscontrols pruning.- Set
tls = trueand normallyport = 8883for TLS.
Replace the sample placeholders with broker-specific credentials, and never commit a private channel key or production config.ini.
For a multi-channel subscription such as msh/US/MI/2/e/#, configure keys like:
[mqtt]
channel_key = AQ==
[channel_keys]
LongFast = AQ==
Michigan = BASE64_PSK_HERE
BodenRD = 00112233445566778899aabbccddeeffThe channel name is taken from the topic segment immediately after /e/.
PKI-encrypted direct messages do not use a channel PSK and remain metadata-only.
Raw packets expire after seven days (or sooner if the 5,000-row cap is reached).
Set [database] raw_retention_days to 1–7 to shorten retention; values above seven
are capped. The ingester prunes every 15 seconds, including when MQTT is idle.
Coverage observations and deduplication records use retention_days (default 30).
Lifetime repeater/node summaries are preserved. The dashboard's received-message
counter is cumulative for the process session, not a count of database rows.
To prune and reclaim unused SQLite space immediately:
sudo runuser -u coverageanalyzer -g www-data -- .venv/bin/python maintain.py --config /etc/coverage-analyzer/config.ini --vacuumInstall nightly maintenance (03:30 server local time):
sudo install -o root -g root -m 0644 deploy/coverage-analyzer-maintenance.cron /etc/cron.d/coverage-analyzer-maintenance
sudo journalctl -t coverage-analyzer-maintenanceThe job serializes maintenance with a lock file, runs with the ingester's user/group, and logs deletion counts and database size before/after. SQLite VACUUM may briefly block writes; no service stop is required. Expired records are permanently deleted.
Validate without connecting:
.venv/bin/python ingest.py --config config.ini --check-configRun interactively with per-packet diagnostics:
.venv/bin/python ingest.py --config config.ini --debugNormal mode logs connections and saved observations. Debug mode also logs decoded packets, filtered gateway IDs, and messages that cannot be decrypted. The ingester writes a heartbeat to SQLite every 15 seconds; /api/status reports it stale after 45 seconds.
The /raw diagnostic console shows recent parsed packets for configured repeaters
and refreshes every three seconds. It includes envelope routing, packet IDs, port
type, radio metrics, hop fields, timestamps, position state, and decode errors. It
does not expose message bodies, channel keys, or encrypted payload bytes. The
[database] raw_packet_limit setting bounds the log and defaults to 5,000 entries.
Deployment templates are in deploy/. A typical installation is:
sudo useradd --system --home /var/lib/coverage-analyzer --shell /usr/sbin/nologin coverageanalyzer
sudo install -d -o coverageanalyzer -g www-data -m 0770 /var/lib/coverage-analyzer
sudo install -d -o root -g www-data -m 0750 /etc/coverage-analyzer
sudo install -o root -g www-data -m 0640 config.ini /etc/coverage-analyzer/config.iniChange the installed INI database setting to:
[database]
path = /var/lib/coverage-analyzer/coverage.sqlite3Then install and start the service:
sudo install -m 0644 deploy/coverage-analyzer-ingest.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now coverage-analyzer-ingest
sudo journalctl -u coverage-analyzer-ingest -fInstall libapache2-mod-wsgi-py3, copy deploy/apache-coverage-analyzer.conf into /etc/apache2/sites-available/, adjust ServerName, then enable it:
sudo a2enmod wsgi
sudo a2ensite apache-coverage-analyzer
sudo systemctl reload apache2The systemd unit writes as coverageanalyzer:www-data; Apache reads as www-data. Its sandbox only permits database writes under /var/lib/coverage-analyzer.
If an existing database was created by another user, repair ownership of the database and all SQLite sidecar files before restarting:
sudo systemctl stop coverage-analyzer-ingest
sudo chown -R coverageanalyzer:www-data /var/lib/coverage-analyzer
sudo chmod 0770 /var/lib/coverage-analyzer
sudo find /var/lib/coverage-analyzer -type f -exec chmod 0660 {} +
sudo systemctl start coverage-analyzer-ingestThe parent directory must be writable as well as the .sqlite3 file because WAL mode creates -wal and -shm sidecars. On startup the ingester now performs a real write check and exits with the resolved database path if permissions are wrong.
Meshtastic MQTT does not expose a complete RF relay route. This app therefore treats ServiceEnvelope.gateway_id as the receiver/repeater: when that gateway publishes a packet, the packet's sender is counted as heard by it. A client must have sent a position packet at least once; that last known position is then attached to later packets from the same client. Self-originated gateway packets are excluded.
Heat weight combines point density with reported SNR. Markers show the latest known reception per client, while the heat layer includes every observation in the selected window.
For current Meshtastic packets, mesh distance is derived as hop_start - hop_limit.
The dashboard can restrict the map to direct receptions or a maximum of 1–7 hops.
Packets from older firmware without hop_start have unknown distance and only appear
when “All hops, including unknown” is selected. Existing observations collected before
this schema was added also remain unknown; newly ingested packets populate the field.
GET /api/observations?hours=24&repeater=!a1b2c3d4&max_hops=1GET /api/statusGET /api/configGET /api/raw?limit=200&repeater=!a1b2c3d4&errors=0GET /api/repeaters/!a1b2c3d4/nodes
Selecting one repeater on the map exposes a “View repeater node list” link. Its detail page includes all deduplicated RF clients heard by that site, even when a client has never supplied a position. It shows lifetime packet count, first/last reception, last radio metrics and hop distance, application type, and whether a location has since been resolved. Per-site history survives normal observation retention; only the temporary packet-ID deduplication records are pruned.
The map tiles and Leaflet assets are loaded from public CDNs. MQTT payloads using another channel key are ignored. SQLite WAL mode allows Apache readers while the ingester writes.