fix(wireless): model channel usage as spectrum, not channel numbers (v0.23.5-beta)

Closes #98, #97.

The Channel Usage panel treated a radio as occupying a channel *number* and
coloured a channel amber when two radios shared it. Both halves are wrong, and the
result is not merely incomplete — it gives actively bad planning advice.

Channels are not slots. In 2.4 GHz the centres are 5 MHz apart while a channel is
20 MHz wide, so an AP on channel 1 occupies about 2402-2422 MHz and pollutes
channels 1 through 5. Showing 2 through 5 as free is why 1/6/11 exists as the only
three-way non-overlapping plan, and a model built on channel indices cannot express
that.

Sharing a channel is also not the problem. Two radios on exactly the same channel
hear each other and take turns; CSMA/CA does its job and the cost is airtime. Two
radios partially overlapping cannot decode each other, transmit over one another,
and the cost is corruption. Partial overlap has to rank above co-channel, and it
was not represented at all.

Occupancy is now modelled in megahertz and overlap classified by how two ranges
relate. On the local fleet, a 40 MHz radio on channel 1 is correctly shown bleeding
5 MHz into two channel-6 radios — which are themselves correctly identified as
co-channel with each other rather than as the worst problem in the band.

The model lives in the backend (utils/rfSpectrum.ts) with 26 tests rather than in
the browser, because it is correctness-critical and the frontend has no test runner.
Tests pin the arithmetic that is easy to get wrong: 1/6/11 is clear, 1/3/5 is three
partial overlaps, channels 4 and 8 abut exactly rather than overlapping, a 20 MHz
radio nested inside an 80 MHz one is partial rather than co-channel, and a lone
radio is clear rather than co-channel with itself.

The panel now draws real spectrum to scale, stacking overlapping radios so each
stays visible, with the clash list on hover naming the other radio and how many
megahertz they share.

Width is taken as the widest entry of a RouterOS capability list such as
20/40/80mhz, since that is the spectrum the radio may occupy and therefore what a
planner must treat as unavailable.

Per-location scoping (#97): analysis runs per CAPsMAN controller, with a selector,
because two APs on channel 6 in different buildings are not a conflict. Radios with
no controller are analysed together as standalone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-08-22 14:06:57 -07:00
co-authored by Claude Opus 5
parent e10f7ba568
commit 4cc8c84ea6
10 changed files with 632 additions and 58 deletions
+67 -4
View File
@@ -3,6 +3,7 @@ import { query } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
import { lookupVendor } from '../utils/oui';
import { analyzeSpectrum, summarize, BAND_RANGES } from '../utils/rfSpectrum';
const router = Router();
router.use(requireAuth);
@@ -859,14 +860,35 @@ function deviceScope(req: Request): number | null {
return Number.isFinite(id) ? id : null;
}
// GET /api/wireless/rf/channels — active physical radios with band/frequency/width
/**
* GET /api/wireless/rf/channels — radios with their actual spectrum occupancy.
*
* Returns the interference analysis rather than raw channel numbers. Overlap is a
* property of megahertz, not of channel indices, and getting it wrong gives
* actively bad planning advice — so the model is computed and tested server-side
* (see utils/rfSpectrum.ts) instead of being re-derived in the browser.
*
* Radios also carry the CAPsMAN controller that provisions them, because channel
* planning is per physical location: two APs on channel 6 in different buildings
* are not a conflict (issue #97).
*/
router.get('/rf/channels', async (req: Request, res: Response) => {
const deviceId = deviceScope(req);
const rows = await query(`
const rows = await query<{
device_id: number; device_name: string; name: string; ssid: string | null;
band: string | null; frequency: number; channel_width: string | null;
registered_clients: number | null;
controller_device_id: number | null; controller_name: string | null;
}>(`
SELECT wi.device_id, d.name AS device_name, wi.name, wi.ssid,
wi.band, wi.frequency, wi.channel_width, wi.registered_clients
wi.band, wi.frequency, wi.channel_width, wi.registered_clients,
cr.controller_device_id,
cd.name AS controller_name
FROM wireless_interfaces wi
JOIN devices d ON d.id = wi.device_id
LEFT JOIN capsman_radios cr ON cr.matched_device_id = wi.device_id
AND cr.radio_mac = wi.radio_mac
LEFT JOIN devices cd ON cd.id = cr.controller_device_id
WHERE (d.device_type = 'wireless_ap' OR d.wifi_role IN ('cap','controller','controller_cap'))
AND wi.disabled = FALSE
AND wi.frequency IS NOT NULL AND wi.frequency > 0
@@ -874,7 +896,48 @@ router.get('/rf/channels', async (req: Request, res: Response) => {
${deviceId ? 'AND wi.device_id = $1' : ''}
ORDER BY wi.frequency ASC, d.name ASC
`, deviceId ? [deviceId] : []);
res.json(rows);
// Interference is only meaningful between radios that can hear each other, so
// the analysis runs per location: each controller separately, and everything
// unmanaged as one group.
const groups = new Map<string, typeof rows>();
for (const r of rows) {
const key = r.controller_device_id != null ? `c:${r.controller_device_id}` : 'standalone';
groups.set(key, [...(groups.get(key) ?? []), r]);
}
const analyzed: Record<string, unknown>[] = [];
for (const group of groups.values()) {
const verdicts = analyzeSpectrum(group);
group.forEach((r, i) => {
const v = verdicts[i];
analyzed.push({
...r,
low_mhz: v.spectrum.lowMhz,
high_mhz: v.spectrum.highMhz,
width_mhz: v.spectrum.widthMhz,
channel: v.spectrum.channel,
overlap: v.kind,
clashes: v.clashes.map((c) => ({
kind: c.kind,
overlap_mhz: c.overlapMhz,
device_name: group[c.with]?.device_name ?? null,
interface_name: group[c.with]?.name ?? null,
channel: analyzeSpectrum([group[c.with]])[0].spectrum.channel,
})),
});
});
}
const locations = [...groups.entries()].map(([key, group]) => ({
key,
name: group[0]?.controller_name ?? 'Standalone access points',
controller_device_id: group[0]?.controller_device_id ?? null,
radios: group.length,
summary: summarize(analyzeSpectrum(group)),
}));
res.json({ radios: analyzed, locations, bands: BAND_RANGES });
});
// GET /api/wireless/rf/signals — active wireless clients' RSSI (for density view)