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. - Auto-discover: a zero-hop node-discover request is sent automatically while connected so nodes in direct range reply with their ID. It backs off for 15 s whenever organic traffic is overheard (no point polling a busy channel) and is suspended while stationary.
- 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.
- 🏠 Home — a live monitor: session counters (distinct nodes / hex cells covered / total receptions), a status strip (GPS accuracy, pending uploads, upload health + last-upload age, capture rate), the last reception's SNR on a peak-hold meter, and the recently-heard list.
- 🗺️ Map — live per-cell coverage for this session.
- ⚙️ Settings — the Connect/Disconnect button (with connection progress), CoreScope broker status + a Push pending now button, companion info, and diagnostics (verbose toggle, debug log, share/mail the log). The app opens here until a companion is connected, then jumps to Home.
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 topics:
- Allow
publishtomeshcore/client/${clientid}/packets— receptions (always used) - Allow
publishtomeshcore/client/${clientid}/rf— RF samples (rfSampler, on by default) - Allow
publishtomeshcore/client/${clientid}/regions— region answers (regionDiscovery) - Deny everything else (publish
#, subscribe#) - Enable the WebSocket/TLS listener (default port
8084, path/ws).
All three subtopics belong in the ACL from the start, even if you intend to leave a feature off: the flags default to on, so a client will try. What a denied publish does depends on your EMQX
authorization.deny_action— with the defaultignorethe message is silently dropped and still PUBACKed (the app logspublished N record(s)while the server stores nothing, see the flag warnings below); withdisconnectthe broker kicks the client, which produces an endless connect→drop→reconnect loop and a queue that never drains. Adding the rules up front avoids both.
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",
"fullRfLog": true,
"rfSampler": true,
"regionDiscovery": true
}
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.fullRfLogis optional and defaults totruewhen the key is absent; when on, packets the direct-only rule can't attribute (a DIRECT-route path, or noise) are queued and published too, as diagnostic-only rows — never coverage. This is pure waste unless the CoreScope ingestor also hasclientRxObservations.enabled: true: with it off, the ingestor decodes and discards every one of these packets, writing no row and logging no warning, whilefullRfLogmultiplies your normal upload volume. Confirm the ingestor-side flag with your CoreScope sysop before turning this on.rfSampleris optional and defaults totruewhen the key is absent; when on, the app polls the companion's own radio counters (noise floor, RX/TX airtime, CRC errors) over local Bluetooth and publishes them onmeshcore/client/<pubkey>/rf. Enable it only after your broker's ACL permits that/rfsubtopic for this client — this app negotiates MQTT 3.1.1, whose PUBACK carries no reason code, so a publish denied by the ACL is still acknowledged: the app logspublished N record(s)and looks healthy while the server receives zero rows. Confirm the topic is allowed (and that the CoreScope ingestor hasclientRfSamples.enabled: true) with your CoreScope sysop before turning this on.regionDiscoveryis optional and defaults totruewhen the key is absent from an existingconfig.json(but never when no config loaded at all — see below); when on, this is the only part of the app that transmits: once connected it addresses one repeater at a time, round-robin, asking for its declared flood-allowed region list roughly every 60 seconds, and publishes the answer onmeshcore/client/<pubkey>/regions. It requires companion firmware v13+ — on older firmware the app logs why and never sends. Enable it only after your broker's ACL permits that/regionssubtopic for this client, for the same silent-PUBACK reason asrfSamplerabove. Confirm the topic is allowed (and that the CoreScope ingestor hasclientRegions.enabled: true) with your CoreScope sysop before turning this on — otherwise the ingestor decodes and discards every upload, same asfullRfLogoff.
Why an absent flag means ON — and the one place it does not. An absent flag used to mean off, which made two failures invisible: a
config.jsoncached before these keys existed reported the features off while the served file said on, and a config that failed to fetch stopped data collection as well as uploading. Data never collected is gone for good; data collected and discarded server-side costs only bandwidth. So in aconfig.jsonthat exists, silence on any of the three flags is read as "on", and an explicitfalsealways wins. Set them tofalseif your ingestor or broker ACL is not ready — per the warnings above the uploads are otherwise pure volume.When no config has loaded at all (a cold start with no connection —
config.jsonis deliberately never served from the offline cache), the two logging flags still apply, so the session keeps capturing and the queue publishes once config arrives.regionDiscoverydoes not: it is the only feature that TRANSMITS, and repeaters rate-limit anonymous requests to 4 per 180 s shared across every requester and type — one client asking once a minute already claims most of that budget. An existingconfig.jsonis a deployment whose operator owns the repeaters being asked; no config means the app knows nothing about whose mesh it is on. Collecting on an assumption spends your own bandwidth, transmitting on one spends a stranger's airtime, so only the former is assumed.
Changing any value later is just a config.json edit + page refresh — no rebuild.
config.jsonis never cached offline, so starting the app needs a live connection once. The service worker deliberately excludes it (both directions): a copy cached in an earlier era silently disables every flag added since, because an absent flag normalizes tofalse. That failure is invisible — it looks exactly like the feature being broken — whereas no config is a state the app detects, warns about on the Home screen, and retries (every minute and on theonlineevent) until it loads. Capture and buffering are unaffected by a cold start with no network; only uploading waits, and the queue drains as soon as config arrives.Because the effective flags are what matter, they are stamped into the header of every exported debug log (Share log in Settings), together with the app version, the companion firmware version, whether region discovery can transmit at all, and the queue depth. Compare that header against the served
config.jsonto spot a client running on something else.
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.