From 4a00b675d925c11e9764e27a3c39b135304eead8 Mon Sep 17 00:00:00 2001 From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:05:49 -0700 Subject: [PATCH] fix(wireless): read CAPsMAN radio channels from the controller (v0.23.7-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on #97 and #106, both reported against the releases they shipped in. #97 — managed radios were missing from Channel Usage entirely The query required wireless_interfaces.frequency > 0. A CAPsMAN-managed access point stores no frequency of its own: the controller owns the channel, and the AP's own rows carry nulls. Every managed radio was therefore filtered out before analysis ran, which is why the reporter saw all access points collapse into "Standalone" and a controller vanish from the selector — the group had no surviving radios to name it. The channel now comes from capsman_radios.current_channel where present, parsed from RouterOS's `//` form. The control letters give a precise operating width — Ce is 40 MHz, Ceee is 80 — rather than the capability list a width field reports. Client counts prefer the controller's per-radio figure for the same reason. Verified only by unit test here, since we run no CAPsMAN. The local fleet still reports its two standalone radios unchanged. #106 — the setting had no way to change it maps_enabled shipped without a control, which made it a setting in name only. Added under Settings, with the reasoning stated: tiles and geocoding are outbound requests to a third party, and the address entered is sent to them. Also removed the "map display is off" placeholder. The reporter pointed out it can never appear in the environment it was written for — with maps disabled from the start no coordinates are ever geocoded, so the block was dead code. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- backend/package.json | 2 +- backend/src/routes/wireless.ts | 40 +++++++++++++++---- .../src/utils/__tests__/rfSpectrum.test.ts | 22 +++++++++- backend/src/utils/rfSpectrum.ts | 22 ++++++++++ frontend/package.json | 2 +- .../device-detail/DeviceLocationSection.tsx | 11 ----- frontend/src/pages/SettingsPage.tsx | 29 ++++++++++++++ frontend/src/version.ts | 2 +- 9 files changed, 108 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 44b58e2..e243fe6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

- Version + Version License Docker TypeScript diff --git a/backend/package.json b/backend/package.json index 1d9a5ab..39b78a9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.23.6-beta", + "version": "0.23.7-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/routes/wireless.ts b/backend/src/routes/wireless.ts index c81e98b..0800fdd 100644 --- a/backend/src/routes/wireless.ts +++ b/backend/src/routes/wireless.ts @@ -3,7 +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'; +import { analyzeSpectrum, summarize, BAND_RANGES, parseChannelSpec } from '../utils/rfSpectrum'; const router = Router(); router.use(requireAuth); @@ -874,16 +874,24 @@ function deviceScope(req: Request): number | null { */ router.get('/rf/channels', async (req: Request, res: Response) => { const deviceId = deviceScope(req); + + // Two sources, because a CAPsMAN-managed AP stores no frequency of its own — the + // controller owns the channel. Reading only wireless_interfaces silently dropped + // every managed radio, which left them out of the analysis entirely and pushed + // whatever remained into "standalone" (issue #97). const rows = await query<{ device_id: number; device_name: string; name: string; ssid: string | null; - band: string | null; frequency: number; channel_width: string | null; + frequency: number | null; channel_width: string | null; registered_clients: number | null; controller_device_id: number | null; controller_name: string | null; + capsman_channel: string | null; capsman_peers: number | 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.frequency, wi.channel_width, wi.registered_clients, cr.controller_device_id, - cd.name AS controller_name + cd.name AS controller_name, + cr.current_channel AS capsman_channel, + cr.registered_peers AS capsman_peers 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 @@ -891,17 +899,33 @@ router.get('/rf/channels', async (req: Request, res: Response) => { 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 AND (wi.config_json->>'master-interface') IS NULL + AND ( + (wi.frequency IS NOT NULL AND wi.frequency > 0) + OR cr.current_channel IS NOT NULL + ) ${deviceId ? 'AND wi.device_id = $1' : ''} - ORDER BY wi.frequency ASC, d.name ASC + ORDER BY d.name ASC, wi.name ASC `, deviceId ? [deviceId] : []); + // Resolve each radio's operating channel, preferring what the controller reports. + const resolved = rows.map((r) => { + const fromController = parseChannelSpec(r.capsman_channel); + const frequency = fromController?.frequency ?? r.frequency ?? 0; + const channel_width = fromController ? `${fromController.widthMhz}mhz` : r.channel_width; + return { + ...r, + frequency, + channel_width, + registered_clients: r.capsman_peers ?? r.registered_clients ?? 0, + }; + }).filter((r) => r.frequency > 0); + // 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(); - for (const r of rows) { + const groups = new Map(); + for (const r of resolved) { const key = r.controller_device_id != null ? `c:${r.controller_device_id}` : 'standalone'; groups.set(key, [...(groups.get(key) ?? []), r]); } diff --git a/backend/src/utils/__tests__/rfSpectrum.test.ts b/backend/src/utils/__tests__/rfSpectrum.test.ts index 16905d0..f0651d3 100644 --- a/backend/src/utils/__tests__/rfSpectrum.test.ts +++ b/backend/src/utils/__tests__/rfSpectrum.test.ts @@ -1,6 +1,6 @@ import { spectrumFor, classifyPair, overlapMhz, analyzeSpectrum, summarize, freeSpans, - widthMhz, channelForFreq, bandForFreq, + widthMhz, channelForFreq, bandForFreq, parseChannelSpec, } from '../rfSpectrum'; const ch = (n: number) => 2407 + n * 5; // 2.4 GHz channel → centre MHz @@ -190,3 +190,23 @@ describe('freeSpans', () => { expect(freeSpans([], '2.4')).toEqual([{ startMhz: 2400, endMhz: 2500 }]); }); }); + +describe('parseChannelSpec — a CAPsMAN AP has no frequency of its own (#97)', () => { + it('reads frequency and derives width from the control positions', () => { + // Real strings from a CAPsMAN controller. + expect(parseChannelSpec('2412/ax/Ce')).toEqual({ frequency: 2412, widthMhz: 40 }); + expect(parseChannelSpec('5680/ax/eCee/D')).toEqual({ frequency: 5680, widthMhz: 80 }); + expect(parseChannelSpec('5500/ax/Ceeeeeee/D')).toEqual({ frequency: 5500, widthMhz: 160 }); + }); + + it('defaults to 20 MHz when no control positions are given', () => { + expect(parseChannelSpec('2437/ax')).toEqual({ frequency: 2437, widthMhz: 20 }); + expect(parseChannelSpec('2437')).toEqual({ frequency: 2437, widthMhz: 20 }); + }); + + it('returns null for anything unparseable', () => { + expect(parseChannelSpec(null)).toBeNull(); + expect(parseChannelSpec('')).toBeNull(); + expect(parseChannelSpec('auto')).toBeNull(); + }); +}); diff --git a/backend/src/utils/rfSpectrum.ts b/backend/src/utils/rfSpectrum.ts index 550e187..7d7dd45 100644 --- a/backend/src/utils/rfSpectrum.ts +++ b/backend/src/utils/rfSpectrum.ts @@ -203,3 +203,25 @@ export function freeSpans( if (range.endMhz - cursor >= minWidth) spans.push({ startMhz: cursor, endMhz: range.endMhz }); return spans; } + +/** + * Parse a RouterOS channel specification into frequency and width. + * + * Format is `/[/]`, e.g. `2412/ax/Ce` or + * `5680/ax/eCee/D`. The control-position letters count the 20 MHz subchannels the + * radio is bonding, so `Ce` is 40 MHz and `Ceee` is 80 MHz — a precise operating + * width rather than the capability list a width field reports. + * + * Needed because a CAPsMAN-managed AP stores no frequency of its own: the + * controller owns the channel, and reports it only in this form (issue #97). + */ +export function parseChannelSpec(spec: string | null | undefined): { frequency: number; widthMhz: number } | null { + if (!spec) return null; + const segs = spec.split('/'); + const frequency = parseInt(segs[0], 10); + if (!frequency || isNaN(frequency)) return null; + + const letters = (segs[2] || '').replace(/[^a-zA-Z]/g, '').length; + const widthMhz = letters >= 8 ? 160 : letters >= 4 ? 80 : letters >= 2 ? 40 : 20; + return { frequency, widthMhz }; +} diff --git a/frontend/package.json b/frontend/package.json index 87caaf7..b61276f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.23.6-beta", + "version": "0.23.7-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/device-detail/DeviceLocationSection.tsx b/frontend/src/components/device-detail/DeviceLocationSection.tsx index e115ed5..5381ec1 100644 --- a/frontend/src/components/device-detail/DeviceLocationSection.tsx +++ b/frontend/src/components/device-detail/DeviceLocationSection.tsx @@ -281,17 +281,6 @@ export default function DeviceLocationSection({ device }: Props) { address={device.location_address!} /> )} - {hasMap && !editing && !mapsEnabled && ( -

- -
- Map display is off, so no request is made to a third-party tile or geocoding - service. Stored position:{' '} - {Number(device.location_lat).toFixed(5)}, {Number(device.location_lng).toFixed(5)}. - Re-enable under Settings. -
-
- )} ); } diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 8d2519f..aea0edb 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -750,6 +750,35 @@ export default function SettingsPage() { + {/* Maps & geocoding (issue #106) */} +
+

Maps & Geocoding

+

+ Device location maps use OpenStreetMap for tiles and Nominatim to turn an address + into coordinates. Both are outbound requests to a third party, and the address you + enter is sent to them. Turn this off on isolated networks, or wherever device + locations should not leave your infrastructure — everything else keeps working, and + addresses are still recorded as text. +

+
+
Enable maps and address lookup
+ +
+
+

Data Retention

diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 73d2e4a..f8c30ed 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.23.6 Beta'; +export const APP_VERSION = 'v0.23.7 Beta';