From 5a082418a8dac4b7b695a419b654daf3d9403d3d Mon Sep 17 00:00:00 2001 From: Stepan Mazurov Date: Fri, 31 Jul 2026 15:01:38 -0600 Subject: [PATCH 1/2] luci-base: support wifi-iface sections spanning multiple radios netifd allows a single wifi-iface section to be instantiated on several radios at once (list device + option mlo), creating one BSS per radio. The network model assumed option device is a single string: netid resolution returned null for such sections, and section-name lookups collapsed all runtime instances to the first radio. Instantiate one WifiNetwork per (section, radio) pair with per-radio netids and runtime state, and treat every device comparison as a membership test. Sections whose device is a plain string keep taking the previous code paths unchanged. Signed-off-by: Stepan Mazurov --- .../htdocs/luci-static/resources/network.js | 91 ++++++++++++------- 1 file changed, 56 insertions(+), 35 deletions(-) diff --git a/modules/luci-base/htdocs/luci-static/resources/network.js b/modules/luci-base/htdocs/luci-static/resources/network.js index b5e4c2b13da7..e795bfcc7abd 100644 --- a/modules/luci-base/htdocs/luci-static/resources/network.js +++ b/modules/luci-base/htdocs/luci-static/resources/network.js @@ -129,12 +129,15 @@ function getProtocolHandlers() { }); } -function getWifiStateBySid(sid) { +function getWifiStateBySid(sid, radioname) { const s = uci.get('wireless', sid); if (s != null && s['.type'] == 'wifi-iface') { - for (let radioname in _state.radios) { - for (let netstate of _state.radios[radioname].interfaces) { + for (let rname in _state.radios) { + if (radioname != null && rname != radioname) + continue; + + for (let netstate of _state.radios[rname].interfaces) { if (typeof(netstate.section) != 'string') continue; @@ -145,7 +148,7 @@ function getWifiStateBySid(sid) { if (s2['.anonymous'] == false && netstate.section.charAt(0) == '@') return null; - return [ radioname, _state.radios[radioname], netstate ]; + return [ rname, _state.radios[rname], netstate ]; } } } @@ -183,7 +186,7 @@ function getWifiSidByNetid(netid) { const sections = uci.sections('wireless', 'wifi-iface'); let n = 0; for (let s of sections) { - if (s.device != m[1]) + if (L.toArray(s.device).indexOf(m[1]) == -1) continue; if (++n == +m[2]) @@ -208,15 +211,19 @@ function getWifiSidByIfname(ifname) { return null; } -function getWifiNetidBySid(sid) { +function getWifiNetidBySid(sid, radioname) { const s = uci.get('wireless', sid); if (s != null && s['.type'] == 'wifi-iface') { - const radioname = s.device; - if (typeof(radioname) == 'string') { + const radionames = L.toArray(s.device); + + if (radioname == null) + radioname = radionames[0]; + + if (typeof(radioname) == 'string' && radionames.indexOf(radioname) != -1) { const sections = uci.sections('wireless', 'wifi-iface'); let n = 0; for (let sec of sections) { - if (sec.device != radioname) + if (L.toArray(sec.device).indexOf(radioname) == -1) continue; n++; @@ -224,7 +231,7 @@ function getWifiNetidBySid(sid) { if (sec['.name'] != s['.name']) continue; - return [ '%s.network%d'.format(s.device, n), s.device ]; + return [ '%s.network%d'.format(radioname, n), radioname ]; } } @@ -1292,14 +1299,13 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ { const networkCount = {}; for (let wf_if of uciWifiIfaces) { - if (typeof(wf_if.device) != 'string') - continue; - - networkCount[wf_if.device] = (networkCount[wf_if.device] || 0) + 1; + for (let radioname of L.toArray(wf_if.device)) { + networkCount[radioname] = (networkCount[radioname] || 0) + 1; - const netid = '%s.network%d'.format(wf_if.device, networkCount[wf_if.device]); + const netid = '%s.network%d'.format(radioname, networkCount[radioname]); - devices[netid] = this.instantiateDevice(netid); + devices[netid] = this.instantiateDevice(netid); + } } /* find uci declared devices */ @@ -1430,8 +1436,17 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ { const wifiIfaces = uci.sections('wireless', 'wifi-iface'); const rv = []; - for (let wf_if of wifiIfaces) - rv.push(this.lookupWifiNetwork(wf_if['.name'])); + for (let wf_if of wifiIfaces) { + const radionames = L.toArray(wf_if.device); + + if (radionames.length < 2) { + rv.push(this.lookupWifiNetwork(wf_if['.name'])); + continue; + } + + for (let radioname of radionames) + rv.push(this.lookupWifiNetwork(wf_if['.name'], radioname)); + } rv.sort(function(a, b) { return L.naturalCompare(a.getID(), b.getID()); @@ -1687,15 +1702,21 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ { }, /* private */ - lookupWifiNetwork(netname) { - let sid, res, netid, radioname, radiostate, netstate; + lookupWifiNetwork(netname, radioname) { + let sid, res, netid, radiostate, netstate; + + if (typeof(radioname) != 'string') + radioname = null; sid = getWifiSidByNetid(netname); if (sid != null) { - res = getWifiStateBySid(sid); + const m = /^(\w+)\.network\d+$/.exec(netname); + const multiradio = (L.toArray(uci.get('wireless', sid, 'device')).length > 1); + + res = getWifiStateBySid(sid, (multiradio && m) ? m[1] : null); netid = netname; - radioname = res ? res[0] : null; + radioname = res ? res[0] : (m ? m[1] : null); radiostate = res ? res[1] : null; netstate = res ? res[2] : null; } @@ -1707,20 +1728,20 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ { radiostate = res[1]; netstate = res[2]; sid = netstate.section; - netid = L.toArray(getWifiNetidBySid(sid))[0]; + netid = L.toArray(getWifiNetidBySid(sid, radioname))[0]; } else { - res = getWifiStateBySid(netname); + res = getWifiStateBySid(netname, radioname); if (res != null) { radioname = res[0]; radiostate = res[1]; netstate = res[2]; sid = netname; - netid = L.toArray(getWifiNetidBySid(sid))[0]; + netid = L.toArray(getWifiNetidBySid(sid, radioname))[0]; } else { - res = getWifiNetidBySid(netname); + res = getWifiNetidBySid(netname, radioname); if (res != null) { netid = res[0]; @@ -2779,19 +2800,19 @@ Protocol = baseclass.extend(/** @lends LuCI.network.Protocol.prototype */ { const uciWifiIfaces = uci.sections('wireless', 'wifi-iface'); for (let wf_if of uciWifiIfaces) { - if (typeof(wf_if.device) != 'string') - continue; - + const radionames = L.toArray(wf_if.device); const networks = L.toArray(wf_if.network); for (let n of networks) { if (n != this.sid) continue; - const netid = getWifiNetidBySid(wf_if['.name']); + for (let radioname of radionames) { + const netid = getWifiNetidBySid(wf_if['.name'], radioname); - if (netid != null) - rv.push(Network.prototype.instantiateDevice(netid[0], this)); + if (netid != null) + rv.push(Network.prototype.instantiateDevice(netid[0], this)); + } } } @@ -3602,7 +3623,7 @@ WifiDevice = baseclass.extend(/** @lends LuCI.network.WifiDevice.prototype */ { return Network.prototype.getWifiNetwork(network).then(L.bind(function(networkInstance) { const uciWifiIface = (networkInstance.sid ? uci.get('wireless', networkInstance.sid) : null); - if (uciWifiIface == null || uciWifiIface['.type'] != 'wifi-iface' || uciWifiIface.device != this.sid) + if (uciWifiIface == null || uciWifiIface['.type'] != 'wifi-iface' || L.toArray(uciWifiIface.device).indexOf(this.sid) == -1) return Promise.reject(); return networkInstance; @@ -3679,7 +3700,7 @@ WifiDevice = baseclass.extend(/** @lends LuCI.network.WifiDevice.prototype */ { sid = getWifiSidByIfname(network); } - if (sid == null || uci.get('wireless', sid, 'device') != this.sid) + if (sid == null || L.toArray(uci.get('wireless', sid, 'device')).indexOf(this.sid) == -1) return Promise.resolve(false); uci.remove('wireless', sid); @@ -3895,7 +3916,7 @@ WifiNetwork = baseclass.extend(/** @lends LuCI.network.WifiNetwork.prototype */ * or `null` if it cannot be determined. */ getWifiDeviceName() { - return this.ubus('radio') || this.get('device'); + return this.ubus('radio') || L.toArray(this.get('device'))[0]; }, /** From 71a55f334dde6d0b74cefd535924300541c7f22c Mon Sep 17 00:00:00 2001 From: Stepan Mazurov Date: Fri, 31 Jul 2026 15:01:46 -0600 Subject: [PATCH 2/2] luci-mod-network: render multi-radio wifi-iface sections per radio A wifi-iface section spanning several radios renders one row per radio, keyed by netid so the rows stay unique, showing that radio's BSSID, signal and assoclist. Sections configured with a single radio keep their UCI section name as the row key and are rendered exactly as before. Edit opens the modal bound to the clicked radio: the Device Configuration tab and modal title follow the row's radio while UCI reads and writes still target the shared wifi-iface section. Remove on a per-radio row drops only that radio from the section's device list and deletes the section when the last radio is removed. The modal status badge is keyed by netid so that polling keeps it on the radio whose row was opened. Signed-off-by: Stepan Mazurov --- .../resources/view/network/wireless.js | 84 ++++++++++++++++--- 1 file changed, 71 insertions(+), 13 deletions(-) diff --git a/modules/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js b/modules/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js index f0ac294d1198..d87ad01c2330 100644 --- a/modules/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js +++ b/modules/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js @@ -180,7 +180,7 @@ function render_modal_status(node, radioNet) { const is_assoc = (bssid && bssid != '00:00:00:00:00:00' && channel && mode != 'Unknown' && !disabled); if (node == null) - node = E('span', { 'class': 'ifacebadge large', 'data-network': radioNet.getName() }, [ E('small'), E('span') ]); + node = E('span', { 'class': 'ifacebadge large', 'data-network': radioNet.getID() ?? radioNet.getName() }, [ E('small'), E('span') ]); dom.content(node.firstElementChild, render_signal_badge( disabled ? -1 : radioNet.getSignalPercent(), @@ -261,7 +261,7 @@ function network_updown(id, map, ev) { const wifi_ifaces = uci.sections('wireless', 'wifi-iface'); wifi_ifaces.forEach(wifi_iface => { - if (wifi_iface.device == radio && wifi_iface.disabled != '1') + if (L.toArray(wifi_iface.device).indexOf(radio) != -1 && wifi_iface.disabled != '1') all_networks_disabled = false; }); @@ -699,7 +699,7 @@ return view.extend({ rows.forEach(row => { const section_id = row.getAttribute('data-sid'); const radioDev = data[1].filter(function(d) { return d.getName() == section_id; })[0]; - const radioNet = data[2].filter(function(n) { return n.getName() == section_id; })[0]; + const radioNet = data[2].filter(function(n) { return n.getID() == section_id || n.getName() == section_id; })[0]; const badge = row.querySelector('[data-name="_badge"] > div'); const stat = row.querySelector('[data-name="_stat"]'); const btns = row.querySelectorAll('.cbi-section-actions button'); @@ -816,7 +816,10 @@ return view.extend({ const status = document.querySelector('.cbi-modal [data-name="_wifistat_modal"] .ifacebadge.large'); if (status) - render_modal_status(status, data[2].filter(function(n) { return n.getName() == status.getAttribute('data-network'); })[0]); + render_modal_status(status, data[2].filter(function(n) { + const key = status.getAttribute('data-network'); + return n.getID() == key || n.getName() == key; + })[0]); return network.flushCache(); }, @@ -917,8 +920,15 @@ return view.extend({ rv.push(radio.getName()); this.wifis.forEach(wifi => { - if (wifi.getWifiDeviceName() == radio.getName()) - rv.push(wifi.getName()); + if (wifi.getWifiDeviceName() != radio.getName()) + return; + + /* Sections spanning several radios render one row per radio + * and need the netid to stay unique. Single-radio sections + * keep their UCI section name as before. */ + const multiradio = (L.toArray(uci.get('wireless', wifi.getName(), 'device')).length > 1); + + rv.push(multiradio ? (wifi.getID() || wifi.getName()) : wifi.getName()); }); }); @@ -926,16 +936,29 @@ return view.extend({ }; s.modaltitle = function(section_id) { - const radioNet = this.wifis.filter(function(w) { return w.getName() == section_id; })[0]; + const radioNet = this.editWifiNetwork || this.wifis.filter(function(w) { return w.getID() == section_id || w.getName() == section_id; })[0]; return radioNet ? radioNet.getI18n() : _('Edit wireless network'); }; + s.renderMoreOptionsModal = function(section_id, ev) { + const inst = this.lookupRadioOrNetwork(section_id); + + this.editWifiNetwork = null; + + if (inst && !inst.getWifiNetworks) { + this.editWifiNetwork = inst; + section_id = inst.getName(); + } + + return form.GridSection.prototype.renderMoreOptionsModal.apply(this, [section_id, ev]); + }; + s.lookupRadioOrNetwork = function(section_id) { const radioDev = this.radios.filter(function(r) { return r.getName() == section_id; })[0]; if (radioDev) return radioDev; - const radioNet = this.wifis.filter(function(w) { return w.getName() == section_id; })[0]; + const radioNet = this.wifis.filter(function(w) { return w.getID() == section_id || w.getName() == section_id; })[0]; if (radioNet) return radioNet; @@ -966,6 +989,7 @@ return view.extend({ ]; } else { + const sid = inst.getName(); const isDisabled = (inst.get('disabled') == '1' || uci.get('wireless', inst.getWifiDeviceName(), 'disabled') == '1'); @@ -973,7 +997,7 @@ return view.extend({ E('button', { 'class': 'cbi-button cbi-button-neutral enable-disable', 'title': isDisabled ? _('Enable this network') : _('Disable this network'), - 'click': ui.createHandlerFn(this, network_updown, section_id, this.map) + 'click': ui.createHandlerFn(this, network_updown, sid, this.map) }, isDisabled ? _('Enable') : _('Disable')), E('button', { 'class': 'cbi-button cbi-button-action important', @@ -992,7 +1016,11 @@ return view.extend({ }; s.addModalOptions = function(s) { + const editWifiNetwork = this.editWifiNetwork; + return network.getWifiNetwork(s.section).then(function(radioNet) { + radioNet = editWifiNetwork || radioNet; + const hwtype = uci.get('wireless', radioNet.getWifiDeviceName(), 'type'); const have_mesh = L.hasSystemFeature('hostapd', 'mesh') || L.hasSystemFeature('wpasupplicant', 'mesh'); let o, ss; @@ -2215,8 +2243,38 @@ return view.extend({ }; s.handleRemove = function(section_id, ev) { - document.querySelector('.cbi-section-table-row[data-sid="%s"]'.format(section_id)).style.opacity = 0.5; - return form.TypedSection.prototype.handleRemove.apply(this, [section_id, ev]); + const inst = this.lookupRadioOrNetwork(section_id); + let sid = section_id; + + if (inst && !inst.getWifiNetworks) { + sid = inst.getName(); + + const radioname = inst.getWifiDeviceName(); + const devices = L.toArray(uci.get('wireless', sid, 'device')); + + if (devices.length > 1 && devices.indexOf(radioname) != -1) { + const row = document.querySelector('.cbi-section-table-row[data-sid="%s"]'.format(section_id)); + + if (row) + row.style.opacity = 0.5; + + uci.set('wireless', sid, 'device', devices.filter(d => d != radioname)); + + return this.map.save(null, true); + } + } + + this.wifis.forEach(wifi => { + if (wifi.getName() != sid) + return; + + const row = document.querySelector('.cbi-section-table-row[data-sid="%s"]'.format(wifi.getID() || wifi.getName())); + + if (row) + row.style.opacity = 0.5; + }); + + return form.TypedSection.prototype.handleRemove.apply(this, [sid, ev]); }; s.handleScan = function(radioDev, ev) { @@ -2380,13 +2438,13 @@ return view.extend({ if (replopt.formvalue('_new_') == '1') { for (let ws of wifi_sections) - if (ws.device == radioDev.getName()) + if (L.toArray(ws.device).indexOf(radioDev.getName()) != -1) uci.remove('wireless', ws['.name']); } if (uci.get('wireless', radioDev.getName(), 'disabled') == '1') { for (let ws of wifi_sections) - if (ws.device == radioDev.getName()) + if (L.toArray(ws.device).indexOf(radioDev.getName()) != -1) uci.set('wireless', ws['.name'], 'disabled', '1'); uci.unset('wireless', radioDev.getName(), 'disabled');