Skip to content

About

Make a heatmap showing OTA coverage of nodes connected via MQTT

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Mesh Coverage Analyzer

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).

Quick start

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.ini

In another terminal:

COVERAGE_CONFIG=$PWD/config.ini .venv/bin/python app.py

Open http://localhost:5000. The web process never connects to MQTT and can safely run with multiple WSGI workers.

Configuration

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] nodes is a comma-separated allowlist of MQTT gateway_id values, optionally followed by a label. Leave it blank to collect every gateway.
  • [mqtt] topic should normally be an encrypted packet topic such as msh/US/2/e/#.
  • [mqtt] channel_key accepts 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_days controls pruning.
  • Set tls = true and normally port = 8883 for 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 = 00112233445566778899aabbccddeeff

The 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.

Ingester diagnostics

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 --vacuum

Install 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-maintenance

The 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-config

Run interactively with per-packet diagnostics:

.venv/bin/python ingest.py --config config.ini --debug

Normal 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.

systemd and Apache

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.ini

Change the installed INI database setting to:

[database]
path = /var/lib/coverage-analyzer/coverage.sqlite3

Then 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 -f

Install 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 apache2

The 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-ingest

The 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.

How observations work

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.

HTTP API

  • GET /api/observations?hours=24&repeater=!a1b2c3d4&max_hops=1
  • GET /api/status
  • GET /api/config
  • GET /api/raw?limit=200&repeater=!a1b2c3d4&errors=0
  • GET /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.

Notes

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.

About

Make a heatmap showing OTA coverage of nodes connected via MQTT

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages