mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-08 05:17:48 +00:00
The ingestor already stores the noise floor that CoreDrive RX companions
report with each GPS fix (`client_rf_samples`, opt-in through
`clientRfSamples`, `cmd/ingestor/client_rf_sample.go:62`), but nothing
reads it back. An operator who enables it collects the data and cannot
see it. This adds the read side: `GET /api/rf-noise` and a Signal/Noise
toggle on the Mobile RX coverage page.
## What it does
- **`GET /api/rf-noise?bbox=&z=&days=`** returns a GeoJSON hex grid with
the median, quietest and noisiest noise floor per cell, in the same
shape as `/api/rx-coverage`. It is registered always and 404s unless
`clientRfSamples.enabled` is true, the same pattern as the coverage
routes.
- **Stationary samples are excluded.** A parked companion logs hundreds
of readings at one point, which would otherwise define its cell.
- **Coverage page:** a Signal/Noise toggle, rendered only when
`/api/config/client` reports `clientRfSamples: true`. The noise layer
reuses the coverage colour tokens with the axis inverted, because a
lower dBm is quieter. Tiers are at -115 and -108 dBm.
- **Deep link:** `#/rx-coverage?layer=noise` opens on the noise layer.
- **Empty and failed answers** are labelled on the map ("No RF samples
in this view yet", or a retry hint), so a blank map never reads as
"feature off".
No new configuration key: it reads the existing `clientRfSamples`
section that the ingestor already uses. Default off, so nothing changes
for an instance that has not opted in.
## Where it comes from
Ported from the efiten/CoreScope fork, where it has run on
analyzer.on8ar.eu since September (fork commits `42d09f0e`, `80581c43`,
`cde95078`). The cherry-picks conflicted with upstream's newer
`routes.go`, `types.go` and `rx-coverage.js`, so the final state was
ported by hand. The fork-only `/scopes` route that sat next to it in the
same hunk is deliberately left out.
## Performance
The query is bounded by `sampled_at` (indexed, `idx_crf_prune`) and the
bbox, aggregation is one pass plus a per-cell sort, and the response is
capped at 5000 cells. Measured on analyzer.on8ar.eu, all of Belgium
(`bbox=49.4,2.4,51.6,6.5&z=9`), from a client in Belgium, so network
time included:
| window | samples | cells | response time |
|---|---|---|---|
| 7 days | 10,835 | 241 | 0.37 s |
| 30 days | 43,303 | 429 | 0.39 s |
That table holds 45,270 rows in total. It is only read when someone
opens the noise layer, never on ingest or WebSocket paths.
## Tests
- `cmd/server/rf_noise_test.go`: 8 tests for the aggregation (median and
extremes, stationary exclusion, the cell cap, empty input) and the gate
(404 when off, even with data present). All pass, and the full
`cmd/server` suite passes locally.
- `tests/unit/test-rx-coverage-noise.js` (new, in `test-all.sh`): the
colour axis runs the right way, including both tier bounds and a string
median from the API. Slices the real function out of `rx-coverage.js`
and fails on master's copy, which has no thresholds.
- `tests/e2e/test-rx-coverage-noise-e2e.js` (new, wired in `deploy.yml`
and `scripts/non-unit-tests.json`): no toggle when the flag is off;
Noise fetches `/api/rf-noise`, draws the cells, swaps legend and
subtitle, puts `layer=noise` in the hash; a deep link opens on the noise
layer and an empty answer shows the message. 3/3 locally; against
master's `rx-coverage.js` and `roles.js`, 1/3 (only the "flag off" case
passes, as it should).
- `test-rx-coverage-viewport-e2e.js` still passes. ESLint 8 reports 0
errors on the changed files.
## Not done
- The thresholds (-115 / -108 dBm) are fitted to the fork's own data
(1,241 moving samples at the time) and are constants in
`rx-coverage.js`. Per AGENTS.md rule 8 they belong in the customizer
eventually; not in this PR.
- The E2E suite mocks the API. The real endpoint is covered by the Go
tests and by the measurements above, not by a fixture with seeded
samples.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
92 lines
5.2 KiB
JavaScript
92 lines
5.2 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
// RF noise-floor layer on the Mobile RX coverage page: the Signal/Noise toggle
|
|
// exists only when clientRfSamples is on, switching draws /api/rf-noise cells
|
|
// with the noise legend, the layer is deep-linkable, and an empty answer says
|
|
// so instead of leaving a blank map.
|
|
const assert = require('assert');
|
|
const { chromium } = require('playwright');
|
|
const BASE = process.env.BASE_URL || 'http://localhost:3000';
|
|
|
|
const cell = (lon, lat, median) => ({
|
|
type: 'Feature',
|
|
geometry: { type: 'Polygon', coordinates: [[[lon, lat], [lon + 0.01, lat], [lon + 0.01, lat + 0.01], [lon, lat + 0.01], [lon, lat]]] },
|
|
properties: { cell: 'c' + median, count: 3, median_noise_floor: median, quietest_noise_floor: median - 2, noisiest_noise_floor: median + 2 },
|
|
});
|
|
|
|
let passed = 0, failed = 0;
|
|
async function step(name, fn) {
|
|
try { await fn(); passed++; console.log(' ✓ ' + name); }
|
|
catch (e) { failed++; console.log(' ✗ ' + name + ': ' + e.message); }
|
|
}
|
|
|
|
async function openPage(browser, { rfSamples, noiseFeatures, hash }) {
|
|
const page = await browser.newPage();
|
|
const errors = [];
|
|
page.on('pageerror', e => errors.push(e.message));
|
|
const noiseRequests = [];
|
|
await page.route('**/api/config/client', async route => {
|
|
const response = await route.fetch();
|
|
await route.fulfill({ json: { ...await response.json(), clientRxCoverage: true, clientRfSamples: rfSamples } });
|
|
});
|
|
await page.route('**/api/config/map', route => route.fulfill({ json: { center: [51, 4], zoom: 9 } }));
|
|
await page.route('**/api/rx-leaderboard?*', route => route.fulfill({ json: { observers: [] } }));
|
|
await page.route('**/api/rx-coverage?*', route => route.fulfill({ json: { type: 'FeatureCollection', features: [] } }));
|
|
await page.route('**/api/rf-noise?*', route => {
|
|
noiseRequests.push(route.request().url());
|
|
return route.fulfill({ json: { type: 'FeatureCollection', features: noiseFeatures, truncated: false } });
|
|
});
|
|
await page.goto(BASE + '/#/rx-coverage' + (hash || ''));
|
|
await page.waitForSelector('#rxMap.leaflet-container', { timeout: 15000 });
|
|
return { page, errors, noiseRequests };
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH || undefined });
|
|
console.log('\n=== RF noise layer E2E against ' + BASE + ' ===');
|
|
try {
|
|
await step('no Signal/Noise toggle when clientRfSamples is off', async () => {
|
|
const { page, errors } = await openPage(browser, { rfSamples: false, noiseFeatures: [] });
|
|
assert.strictEqual(await page.locator('#rxLayerBar').count(), 0, 'toggle must not render when the feature is off');
|
|
assert.deepStrictEqual(errors, []);
|
|
await page.close();
|
|
});
|
|
|
|
await step('Noise draws /api/rf-noise cells with the noise legend and deep link', async () => {
|
|
const features = [cell(4.40, 51.20, -118), cell(4.50, 51.20, -111), cell(4.60, 51.20, -100)];
|
|
const { page, errors, noiseRequests } = await openPage(browser, { rfSamples: true, noiseFeatures: features });
|
|
await page.waitForSelector('#rxLayerBar button[data-layer="noise"]');
|
|
await page.click('#rxLayerBar button[data-layer="noise"]');
|
|
await page.waitForFunction(() => document.querySelectorAll('#rxMap path.leaflet-interactive').length === 3, null, { timeout: 10000 });
|
|
assert.ok(noiseRequests.length >= 1, 'the noise layer must fetch /api/rf-noise');
|
|
const q = new URL(noiseRequests[noiseRequests.length - 1]).searchParams;
|
|
assert.ok(q.get('bbox') && q.get('z') && q.get('days'), 'bbox, z and days are sent: ' + q.toString());
|
|
const legend = await page.locator('#rxLegend').innerText();
|
|
assert.ok(/quiet/.test(legend) && /busy/.test(legend), 'legend switches to the noise tiers: ' + legend);
|
|
assert.ok(/noise floor/i.test(await page.locator('#rxSubtitle').innerText()), 'subtitle names the noise floor');
|
|
assert.ok(/[?&]layer=noise/.test(await page.evaluate(() => location.hash)), 'layer is in the URL hash');
|
|
const pressed = await page.locator('#rxLayerBar button[data-layer="noise"]').getAttribute('class');
|
|
assert.ok(/active/.test(pressed || ''), 'Noise button is marked active');
|
|
assert.ok(!(await page.locator('#rxNoiseEmpty').isVisible()), 'no empty-state message when cells exist');
|
|
assert.deepStrictEqual(errors, []);
|
|
await page.close();
|
|
});
|
|
|
|
await step('a deep link opens on the noise layer, and an empty answer is labelled', async () => {
|
|
const { page, errors, noiseRequests } = await openPage(browser, { rfSamples: true, noiseFeatures: [], hash: '?layer=noise' });
|
|
await page.waitForFunction(() => {
|
|
const el = document.getElementById('rxNoiseEmpty');
|
|
return el && getComputedStyle(el).display !== 'none';
|
|
}, null, { timeout: 10000 });
|
|
assert.ok(noiseRequests.length >= 1, 'a ?layer=noise link must fetch the noise layer without a click');
|
|
assert.ok(/No RF samples/.test(await page.locator('#rxNoiseEmpty').innerText()));
|
|
assert.deepStrictEqual(errors, []);
|
|
await page.close();
|
|
});
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
console.log(`\ntest-rx-coverage-noise-e2e: ${passed} passed, ${failed} failed`);
|
|
process.exit(failed ? 1 : 0);
|
|
})();
|