-
+
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.
-
+ 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.
+