fix(wireless): attribute CAP radios to the access point, not the controller (v0.23.1-beta)
Third round of #94 field testing. The placement bug survived v0.23.0 because the previous fix addressed the wrong mechanism. Cause A CAPsMAN controller mirrors every CAP interface locally, so a CAP's radio MAC legitimately appears on two devices: the CAP that owns the hardware, and the controller that mirrors it. The MAC index was single-valued and first-wins over an unordered UNION, so each radio landed on whichever row the database happened to return first. On the reporter's fleet six radios attached to a router with no radios at all, while their siblings attached correctly — the same access point appearing in two places, and liable to move between polls. v0.23.0 excluded the controller from *prefix* matches only. These are exact matches, so the exclusion never applied. Consecutive MACs on one physical AP resolving to different devices is what gave it away. Fix The index is multi-valued: a MAC maps to every device carrying it. A radio the controller reports as local:false lives on a CAP by definition, so the controller is rejected as a candidate at exact strength as well as prefix — an exact match against it only ever means the mirror. Attribution now runs through matchRadiosToDevices, which was previously tested but unused while the collector kept its own copy of the logic. With no radios left attributed to it, a controller that has none of its own stops appearing in the access point list, which was the reporter's follow-up point. SSID on provisioned interfaces A CAP holds no configuration, so its interface rows carry a generic name and a null SSID. The controller knows it, on its mirror of the same interface. Radios now record the provisioned SSID from there, and the table is driven by the controller's radio list rather than the CAPs' own rows — the controller is the only side that knows the SSID, channel and client count together. Band and frequency columns removed Both were empty while the channel column beside them read 5680/ax/eCee/D. The channel string already carries the band and the extension layout; parsing it properly is its own task, and half-parsing it produced empty columns next to populated ones. Requested by the reporter. 75 tests across the CAPsMAN and topology modules, including fixtures built from the reporter's MAC set: both radios of one AP resolve to that AP, order-independently, with a genuinely local radio still landing on the controller and an unknown CAP left unattributed rather than absorbed. Reproduced the mirror scenario against the local fleet before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
82efcaa371
commit
0258ee47dc
@@ -9,8 +9,8 @@ import { buildServerArpMap } from '../../utils/serverArp';
|
||||
import { aggregateBridgeVlans, portVlanMembership, expandVlanIds } from '../../utils/vlan';
|
||||
import {
|
||||
classifyWifiRole, isCapsmanManaged, parseCapsmanStatus, parseCapStatus,
|
||||
normalizeRadios, lookupDeviceForMac, macIndexKeys, parseRadioMonitor, resolveDatapath,
|
||||
clientsPerRadio,
|
||||
normalizeRadios, macIndexKeys, parseRadioMonitor, resolveDatapath,
|
||||
clientsPerRadio, buildMacIndex, matchRadiosToDevices,
|
||||
type WifiRole,
|
||||
} from './capsman';
|
||||
import { BackupService } from '../BackupService';
|
||||
@@ -1472,12 +1472,7 @@ export class DeviceCollector {
|
||||
UNION
|
||||
SELECT device_id, mac_address FROM wireless_interfaces WHERE mac_address IS NOT NULL`
|
||||
).catch(() => []);
|
||||
const macIndex = new Map<string, number>();
|
||||
for (const r of macRows) {
|
||||
for (const key of macIndexKeys(r.mac_address)) {
|
||||
if (!macIndex.has(key)) macIndex.set(key, r.device_id);
|
||||
}
|
||||
}
|
||||
const macIndex = buildMacIndex(macRows);
|
||||
|
||||
if (radioRows !== null) {
|
||||
const radios = normalizeRadios(radioRows);
|
||||
@@ -1497,30 +1492,39 @@ export class DeviceCollector {
|
||||
live.set(iface, parseRadioMonitor(mon[0]));
|
||||
}));
|
||||
|
||||
// The controller mirrors each CAP interface locally, which is also where the
|
||||
// provisioned SSID is visible — the CAP's own row has none.
|
||||
const ssidByRadioMac = new Map<string, string>();
|
||||
for (const iface of ifaceRows) {
|
||||
const mac = (iface['radio-mac'] || '').toUpperCase();
|
||||
const ssid = iface['configuration.ssid'] || iface['ssid'];
|
||||
if (mac && ssid && !ssidByRadioMac.has(mac)) ssidByRadioMac.set(mac, ssid);
|
||||
}
|
||||
|
||||
const attributed = new Map(
|
||||
matchRadiosToDevices(radios, macIndex, this.device.id)
|
||||
.map((m) => [m.radio.radioMac ?? '', m.deviceId])
|
||||
);
|
||||
|
||||
const seen: string[] = [];
|
||||
for (const radio of radios) {
|
||||
if (!radio.radioMac) continue;
|
||||
seen.push(radio.radioMac);
|
||||
const capInfo = parseCapStatus(radio.raw);
|
||||
const st = radio.interfaceName ? live.get(radio.interfaceName) : undefined;
|
||||
// A remote radio belongs to a CAP, never to the controller — so a
|
||||
// near-miss prefix match against the controller's own MAC block must not
|
||||
// pull it back onto the controller.
|
||||
const matched = radio.local
|
||||
? this.device.id
|
||||
: lookupDeviceForMac(radio.radioMac, macIndex, this.device.id);
|
||||
const matched = attributed.get(radio.radioMac) ?? null;
|
||||
|
||||
await query(
|
||||
`INSERT INTO capsman_radios
|
||||
(controller_device_id, radio_mac, interface_name, local, hw_type,
|
||||
current_channel, remote_cap_name, matched_device_id, config_json,
|
||||
state, registered_peers, authorized_peers, tx_power, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NOW())
|
||||
state, registered_peers, authorized_peers, tx_power, ssid, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NOW())
|
||||
ON CONFLICT (controller_device_id, radio_mac) DO UPDATE SET
|
||||
interface_name=$3, local=$4, hw_type=$5, current_channel=$6,
|
||||
remote_cap_name=$7, matched_device_id=$8, config_json=$9,
|
||||
state=$10, registered_peers=$11, authorized_peers=$12, tx_power=$13,
|
||||
updated_at=NOW()`,
|
||||
ssid=$14, updated_at=NOW()`,
|
||||
[
|
||||
this.device.id, radio.radioMac, radio.interfaceName, radio.local,
|
||||
radio.hwType, st?.channel ?? null,
|
||||
@@ -1529,6 +1533,7 @@ export class DeviceCollector {
|
||||
st?.state ?? null,
|
||||
clientCounts.get(radio.radioMac) ?? st?.registeredPeers ?? null,
|
||||
st?.authorizedPeers ?? null, st?.txPower ?? null,
|
||||
ssidByRadioMac.get(radio.radioMac) ?? null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user