Mobile RX-coverage capture for CoreScope. A mobile PWA
that connects over BLE to a MeshCore companion radio, captures which nodes it hears (SNR/RSSI),
tags each reception with the phone's GPS, and publishes to MQTT so a CoreScope ingestor stores it in
client_receptions and renders per-node hex coverage on the Reach page.
The app needs Web Bluetooth, which not every browser has:
- Android: Chrome.
- iOS / iPadOS: the Bluefy browser — Safari (and every other normal iOS browser) has no Web Bluetooth, so the app cannot connect there. Opening the app in plain iOS Safari shows an in-app notice pointing to Bluefy.
- Desktop (for testing): Chrome or Edge.
The screen is kept awake while capturing (native Screen Wake Lock where available, with a video fallback for Bluefy), so the phone won't dim/lock mid-drive.
companion ──BLE 0x88 (snr+rssi+raw)──▶ frames.js ──▶ meshpacket.js (path[last] / advert pubkey)
│
phone GPS (gps.js) ───────────────────────────────────────┤
▼
queue.js (IndexedDB, offline) ──▶ publisher.js (MQTT/WSS)
│
meshcore/client/{PUBLIC_KEY}/packets ──▶ CoreScope ingestor
- Capture source: the companion's
PUSH_CODE_LOG_RX_DATA(0x88) frame — emitted for every received packet on stock firmware, carrying SNR + RSSI + the raw packet. - Direct-only rule: records only
path[last](last forwarder, FLOOD routes) or a 0-hop advert's full pubkey. Upstream hops are discarded. - GPS: the phone's (
navigator.geolocation), not the companion's. - Trust: the companion pubkey is the identity; the EMQX ACL binds each client to its own topic.
You host this app for your own CoreScope environment so your users can contribute RX coverage. There is no central server — you point the app at your own MQTT broker and CoreScope.
- A running CoreScope deployment with its ingestor.
- An MQTT broker (EMQX) reachable over WSS with a valid TLS certificate — Web Bluetooth and PWA install both require a secure (HTTPS) context. Connect via the hostname (not an IP).
Create a dedicated account and an ACL so a client can only publish to its own topic:
- Allow
publishtomeshcore/client/${clientid}/packets - Deny everything else (publish
#, subscribe#) - Enable the WebSocket/TLS listener (default port
8084, path/ws).
The app sets clientId = the companion's pubkey, so the ACL binds each user to their own topic.
- Enable the coverage screen via its config flag (see CoreScope docs).
- Ensure the ingestor subscribes to the client topic (
meshcore/#ormeshcore/client/#) so receptions land inclient_receptions.
Choose (A) a prebuilt release (no Node/npm) or (B) build from source:
(A) Download a release — recommended, no build:
Grab coredrive-rx-<version>.zip from
Releases and unzip it into your web root.
(B) Build from source:
npm install
npm run build # outputs static files to dist/Copy the contents of dist/ to your web root.
Either way, serve the files over HTTPS on a subdomain (e.g. rx.yourdomain). Requirements:
- SPA fallback: unknown paths serve
/index.html(e.g. nginxtry_files $uri /index.html;). - Cache headers:
index.html,sw.js, the web-app manifest, andconfig.json=no-cache;/assets/*= immutable. Without this, a cachedindex.htmlpins old assets after an update.
Put a config.json in the served directory (next to index.html). Start from the example:
{
"mqttUrl": "wss://broker.yourdomain:8084/ws",
"mqttUsername": "coredrive-rx",
"mqttPassword": "<your publish-only EMQX account password>",
"resolveUrl": "https://corescope.yourdomain/api/nodes/resolve"
}
mqttPasswordis a publish-only, ACL-constrained account — it is shipped to browsers, so treat it as shared, not a secret.resolveUrlis optional (see CORS below); omit it and the app shows heard-key prefixes instead of node names.
Changing any value later is just a config.json edit + page refresh — no rebuild.
The app calls CoreScope's GET /api/nodes/resolve?prefix=… cross-origin. Set resolveUrl to either:
- a CORS-enabled reverse-proxy location in front of the CoreScope API (adds
Access-Control-Allow-Originfor the app's origin), or - the CoreScope API directly, if it already sends CORS headers for your app's origin.
Leave resolveUrl empty to disable name resolution entirely.
npm install
cp public/config.example.json public/config.json # fill in your dev broker; gitignored
npm run dev # Vite dev server (Android Chrome; Web Bluetooth needs HTTPS or localhost)
npm test # node --testWeb Bluetooth requires a secure context (HTTPS or localhost). For phone testing over LAN, serve via
HTTPS (e.g. a dev tunnel) — Chrome blocks Web Bluetooth on plain HTTP origins.
Two optional SSH helpers — both leave the server's config.json intact:
From a prebuilt release — no Node/npm (deploy-release.sh, needs only curl + unzip):
RX_DEPLOY_HOST=user@host RX_DEPLOY_DEST=/var/www/rx.yourdomain/ bash deploy-release.sh
# defaults to the latest release; pin one with RX_VERSION=v0.9.0Downloads the latest release zip and scps the static files to the host. The release zip
contains no config.json, so your server config is never overwritten.
From source (deploy.sh, builds locally then uploads):
RX_DEPLOY_HOST=user@host RX_DEPLOY_DEST=/var/www/rx.yourdomain/ npm run deployBuilds, drops dist/config.json, and uploads dist/ — never touching the server's config.json.