mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-12 00:17:59 +00:00
Refs #1268. #2209 is merged as `cab4f71c`, and master is merged into this branch (`47ce8264`), so the diff against master is this PR's own change: `ea4d1130` plus the fix in `5cbbe5d5`. ## What it does Two nodes hear each other only when SF and bandwidth match and their frequencies are close enough. `/api/rf-params` now also returns `deviations`: nodes whose setting differs from a majority of the nodes around them on the same band. - **Peers:** nodes with GPS and RF params within `rfParams.compareRadiusKm` (default 50) whose frequency is within 30 MHz. - **Same channel:** frequencies within a quarter of the bandwidth count as one channel (15.625 kHz at BW 62.5). The SX1262 tolerates a transmitter/receiver offset of up to 25% of BW without sensitivity loss, for SF5 to SF12 and all bandwidths. Source: the `FERR_L` row in the [S62F module datasheet](https://lora-alliance.org/wp-content/uploads/2020/07/S62F_SPEC_Ver.B.pdf), an SX1262 module; I did not find the figure in a Semtech document itself. That table also lists -50/-100/-200 ppm for SF12/SF11/SF10, which at 869 MHz is wider than BW/4 at 62.5 and 125 kHz, so BW/4 is the tighter bound there. - **Flagged when:** a setting the node cannot hear is shared by more than half of the group, the node itself counted, with at least 3 peers and no other incompatible setting at the same support. So 5 against 5 flags nobody, and 2 of 4 does not count as a majority. Each deviation carries the majority, how many peers are compatible with it, the peer count, and which of freq/sf/bw differ. - **Coding rate is not compared.** RadioLib starts SX126x radios with an explicit LoRa header (`SX126x::begin` sets `RADIOLIB_SX126X_LORA_HEADER_EXPLICIT` at `6d89348`, the commit the firmware pins in `platformio.ini`), and nothing in `firmware/src` switches to implicit. The header carries the CR, so a CR mismatch still decodes. I checked SX126x only, not SX127x or LR11x0. UI: - Nodes list: a badge such as `LoRa SF7` next to the name of a deviating node (the majority in its tooltip), and a `LoRa: differs from nearby (N)` filter kept in the URL as `?lora=differs`. - Node detail: one line, for example "Differs from 12 of 14 nearby nodes on this band, which use 869.618 MHz · SF8 · BW 62.5 kHz". - Badge and filter only exist while the server has map data. ## Why same-band only On our instance (1,110 nodes with params and GPS, measured with the first version of the rule), a plain neighbourhood majority flagged exactly 15 nodes, all of them a deliberate 433 MHz network in Limburg next to the 868 MHz one (names like `NL-ZAY-RP01 | 433 X 868`). The same-band comparison flags none there. On our network this PR shows nothing today. The flags are for instances where a node really is set to a different SF, bandwidth or channel than the nodes around it. ## Cost - Points are bucketed on a radius-sized grid (rows of radius/110.57 degrees, longitude columns widened by 1/cos(lat)) and compared with a squared equirectangular distance. - `BenchmarkRfDeviations1k` (1,100 nodes over Belgium and the southern Netherlands): 4.5 ms. `BenchmarkRfDeviations10k` (10,000 nodes in the same box): 388 ms, 1.3 MB. Both on the first version. - After `5cbbe5d5`, on a slower machine, 3 runs each: 1k 4.4 to 9.5 ms, 10k 821 to 852 ms, 1.8 MB. On that machine the first version measured 7.6 to 9.3 ms and 819 to 861 ms, so the majority step adds no measurable time. It is one pass over the distinct settings around a node, and those are few (one per preset in use). - Inside the radius the work is close to O(n²): 10,000 nodes in one dense box means about 2,000 neighbours per node, and a request after the 5 minute TTL waits for that computation. - The result is cached per map snapshot for 5 minutes; concurrent requests wait for one computation. - On staging `/api/perf` reports `/api/rf-params` at 9.9 ms average, 26 ms max (8 requests). ## Tests - Go: deviation cases (SF, freq within band, BW, CR ignored, other band ignored, no GPS, no params, far away), split vote and too few peers, radius, a peer in another longitude column at 60°N, the cache (same snapshot within TTL, new snapshot, after TTL), the route with four inserted nodes, plus the two benchmarks. - Go, added in `5cbbe5d5`: a 5/5 split and 2 of 4 flag nobody; a 1 and a 15 kHz offset are not deviations while 32 kHz is, and offset peers add up into one majority; the BW/4 boundary; peers two grid columns away at 60°N; a peer at the north edge of the radius. Each fails on the first version. The two-column test also fails with a ±1 column search, which the existing longitude test did not catch. - Full `cmd/server` suite and `test-all.sh` pass locally on `5cbbe5d5`. - Frontend unit: 6 new assertions for `deviationBadge` and the detail line (13 in the file). - E2E: 4 new checks: badge only on the deviating node, filter count and result, `?lora=differs` written and restored, detail line. The disabled check now also asserts no filter and no badges. 8/8 locally. ## Validation On staging with live map data, with the first version: 1,115 nodes served, `deviations: {}`, and the filter shows `LoRa: differs from nearby (0)` with no badges. Because no real deviation exists there, the badge, filter and detail line were checked by serving one simulated deviation through Playwright route interception against the staging page. ## Not done - No map marker for deviating nodes. - `compareRadiusKm` is config-only; the customizer is a later step. The other values are hardcoded and belong in that step too: at least 3 peers, the 30 MHz band, the more-than-half rule, BW/4 and the 5 minute TTL. - Not re-run on staging after `5cbbe5d5`, and the E2E suite was not re-run locally. The fix changes only the server's choice of nodes, not the UI. - The detail line says the peers "use" the majority setting. With the BW/4 tolerance, some of them may be a few kHz off it. - Companions and room servers are compared too, not only repeaters. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
7.5 KiB
JavaScript
154 lines
7.5 KiB
JavaScript
/**
|
|
* E2E test (#1268): LoRa RF parameters in the node detail views.
|
|
*
|
|
* The fixture server runs without rfParams configured, so the first check
|
|
* is the real disabled state (no LoRa row). The enabled checks serve
|
|
* /api/rf-params through page.route with values for the fixture's own
|
|
* node keys. The Nodes table deliberately has no LoRa column.
|
|
*
|
|
* Usage: BASE_URL=http://localhost:13581 node test-issue-1268-rf-params-e2e.js
|
|
*/
|
|
const { chromium } = require('playwright');
|
|
|
|
const BASE = process.env.BASE_URL || 'http://localhost:3000';
|
|
|
|
async function test(name, fn) {
|
|
try {
|
|
await fn();
|
|
console.log(` ✅ ${name}`);
|
|
} catch (err) {
|
|
console.log(` ❌ ${name}: ${err.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
function assert(cond, msg) {
|
|
if (!cond) throw new Error(msg || 'Assertion failed');
|
|
}
|
|
|
|
async function gotoNodes(page) {
|
|
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('#nodesBody tr[data-key]');
|
|
}
|
|
|
|
async function run() {
|
|
const browser = await chromium.launch({
|
|
headless: true,
|
|
executablePath: process.env.CHROMIUM_PATH || undefined,
|
|
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
|
|
});
|
|
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
|
|
const page = await ctx.newPage();
|
|
page.setDefaultTimeout(15000);
|
|
|
|
console.log(`\nRunning #1268 RF params E2E tests against ${BASE}\n`);
|
|
|
|
await test('feature off: /api/rf-params says disabled, no LoRa row and no LoRa filter', async () => {
|
|
const r = await page.request.get(`${BASE}/api/rf-params`);
|
|
const body = await r.json();
|
|
assert(body.enabled === false && Array.isArray(body.presets) && body.nodes && typeof body.nodes === 'object',
|
|
`disabled body: ${JSON.stringify(body)}`);
|
|
await gotoNodes(page);
|
|
const k = await page.$eval('#nodesBody tr[data-key]', (tr) => tr.getAttribute('data-key'));
|
|
await page.goto(`${BASE}/#/nodes/${encodeURIComponent(k)}`, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('#node-stats');
|
|
assert(!(await page.$('[data-testid="node-detail-lora"]')), 'no LoRa row when disabled');
|
|
await gotoNodes(page);
|
|
assert(await page.$eval('#nodeLoraFilter', (s) => s.hidden), 'LoRa filter hidden when disabled');
|
|
assert(!(await page.$('#nodesBody .badge-lora-diff')), 'no badges when disabled');
|
|
});
|
|
|
|
await gotoNodes(page);
|
|
const keys = await page.$$eval('#nodesBody tr[data-key]', (trs) => trs.map((tr) => tr.getAttribute('data-key')));
|
|
assert(keys.length >= 3, `fixture needs >=3 nodes, got ${keys.length}`);
|
|
const [presetKey, rawKey] = keys;
|
|
const now = new Date().toISOString();
|
|
await page.route('**/api/rf-params*', (route) => route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
enabled: true,
|
|
fetched_at: now,
|
|
presets: [{ title: 'EU/UK (Narrow)', freq: 869.618, sf: 8, bw: 62.5, cr: 8 }],
|
|
nodes: {
|
|
[presetKey.toLowerCase()]: { freq: 869.618, sf: 8, bw: 62.5, cr: 8, last_advert: now },
|
|
[rawKey.toLowerCase()]: { freq: 869.618, sf: 7, bw: 62.5, cr: 5, last_advert: now },
|
|
},
|
|
deviations: {
|
|
[rawKey.toLowerCase()]: { majority: { freq: 869.618, sf: 8, bw: 62.5 }, agree: 12, peers: 14, fields: ['sf'] },
|
|
},
|
|
}),
|
|
}));
|
|
|
|
await test('feature on: the Nodes table still has no LoRa column', async () => {
|
|
// Reload: api() cached the disabled response from the first check.
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await gotoNodes(page);
|
|
await page.waitForTimeout(500);
|
|
const headers = await page.$$eval('#nodesTable thead th', (ths) => ths.map((th) => th.textContent.trim()));
|
|
assert(!headers.includes('LoRa'), `headers: ${JSON.stringify(headers)}`);
|
|
});
|
|
|
|
await test('full detail page shows the values and the preset', async () => {
|
|
await page.goto(`${BASE}/#/nodes/${encodeURIComponent(presetKey)}`, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('[data-testid="node-detail-lora"]');
|
|
const text = await page.$eval('[data-testid="node-detail-lora"]', (el) => el.textContent);
|
|
assert(text.includes('869.618 MHz') && text.includes('SF8') && text.includes('EU/UK (Narrow)'), `detail: ${text}`);
|
|
});
|
|
|
|
await test('side panel shows raw values without a preset, and a hint for unknown nodes', async () => {
|
|
await gotoNodes(page);
|
|
await page.click(`#nodesBody tr[data-key="${rawKey}"]`);
|
|
await page.waitForSelector('#nodesRight [data-testid="node-detail-lora"]');
|
|
const raw = await page.$eval('#nodesRight [data-testid="node-detail-lora"]', (el) => el.textContent);
|
|
assert(raw.includes('SF7') && raw.includes('CR 4/5') && !raw.includes('EU/UK'), `raw: ${raw}`);
|
|
await page.click(`#nodesBody tr[data-key="${keys[2]}"]`);
|
|
await page.waitForFunction(() => {
|
|
const el = document.querySelector('#nodesRight [data-testid="node-detail-lora"]');
|
|
return el && el.textContent.includes('Not on the MeshCore map');
|
|
});
|
|
});
|
|
|
|
await test('deviating node gets a badge in the list, others do not', async () => {
|
|
await gotoNodes(page);
|
|
await page.waitForSelector(`#nodesBody tr[data-key="${rawKey}"] .badge-lora-diff`);
|
|
const badge = await page.$eval(`#nodesBody tr[data-key="${rawKey}"] .badge-lora-diff`, (b) => ({ text: b.textContent.trim(), title: b.title }));
|
|
assert(badge.text === 'LoRa SF7' && badge.title.includes('12 of 14 nearby nodes'), `badge: ${JSON.stringify(badge)}`);
|
|
const others = await page.$$eval('#nodesBody .badge-lora-diff', (bs) => bs.length);
|
|
assert(others === 1, `badges in list: ${others}`);
|
|
});
|
|
|
|
await test('LoRa filter shows only deviating nodes and goes into the URL', async () => {
|
|
assert(await page.isVisible('#nodeLoraFilter'), 'filter visible when the feature is on');
|
|
const label = await page.$eval('#nodeLoraFilter option[value="differs"]', (o) => o.textContent);
|
|
assert(label.includes('(1)'), `option label: ${label}`);
|
|
await page.selectOption('#nodeLoraFilter', 'differs');
|
|
await page.waitForFunction(() => document.querySelectorAll('#nodesBody tr[data-key]').length === 1);
|
|
const key = await page.$eval('#nodesBody tr[data-key]', (tr) => tr.getAttribute('data-key'));
|
|
assert(key === rawKey, `filtered row: ${key}`);
|
|
assert(page.url().includes('lora=differs'), `url: ${page.url()}`);
|
|
await page.selectOption('#nodeLoraFilter', '');
|
|
await page.waitForFunction(() => document.querySelectorAll('#nodesBody tr[data-key]').length > 1);
|
|
assert(!page.url().includes('lora='), `url after reset: ${page.url()}`);
|
|
});
|
|
|
|
await test('the filter is restored from the URL', async () => {
|
|
await page.goto(`${BASE}/#/nodes?lora=differs`, { waitUntil: 'domcontentloaded' });
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await page.waitForFunction(() => document.querySelectorAll('#nodesBody tr[data-key]').length === 1);
|
|
assert(await page.$eval('#nodeLoraFilter', (s) => s.value) === 'differs', 'select restored');
|
|
});
|
|
|
|
await test('detail of a deviating node explains the difference', async () => {
|
|
await page.goto(`${BASE}/#/nodes/${encodeURIComponent(rawKey)}`, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('[data-testid="lora-deviation"]');
|
|
const text = await page.$eval('[data-testid="lora-deviation"]', (el) => el.textContent);
|
|
assert(text.includes('Differs from 12 of 14 nearby nodes on this band') && text.includes('SF8'), `deviation: ${text}`);
|
|
});
|
|
|
|
await browser.close();
|
|
console.log('\nAll #1268 RF params E2E tests passed.');
|
|
}
|
|
|
|
run().catch((e) => { console.error(e); process.exit(1); });
|