From: Carlo Szelinsky Date: Sun, 25 Jan 2026 11:40:49 +0000 (+0100) Subject: luci-base: poe and PSE details & configuration X-Git-Url: http://git.cdn.openwrt.org/?a=commitdiff_plain;h=8e493db75a80194a1f76ded11df4dbf32f1235d1;p=project%2Fluci.git luci-base: poe and PSE details & configuration Adds PoE/PSE configuration support for modern linux (PSE-PD). This change is based on the PSE-PD backport (from 6.17) and netifd|ubus changes. * Add getPSE() [receive all status information] and hasPSE() [has the device PSE hardware?] * Changes ACL permissions to query network.device status information * Add two new PoE icons (PoE active with link up + link down) for the port status page * Changes port status to show PoE information, next to link information and data transfer. * Add a new tab for PoE/PSE to the device configuration, which will only be displayed if the device supports PSE Signed-off-by: Carlo Szelinsky --- diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/port_pse_down.svg b/modules/luci-base/htdocs/luci-static/resources/icons/port_pse_down.svg new file mode 100644 index 0000000000..81b9c3cb42 --- /dev/null +++ b/modules/luci-base/htdocs/luci-static/resources/icons/port_pse_down.svg @@ -0,0 +1 @@ + diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/port_pse_up.svg b/modules/luci-base/htdocs/luci-static/resources/icons/port_pse_up.svg new file mode 100644 index 0000000000..05201aba3d --- /dev/null +++ b/modules/luci-base/htdocs/luci-static/resources/icons/port_pse_up.svg @@ -0,0 +1 @@ + diff --git a/modules/luci-base/htdocs/luci-static/resources/network.js b/modules/luci-base/htdocs/luci-static/resources/network.js index a6def061e4..5799ae2973 100644 --- a/modules/luci-base/htdocs/luci-static/resources/network.js +++ b/modules/luci-base/htdocs/luci-static/resources/network.js @@ -3240,6 +3240,50 @@ Device = baseclass.extend(/** @lends LuCI.network.Device.prototype */ { return (duplex != 'unknown') ? duplex : null; }, + /** + * Get the PSE (Power Sourcing Equipment / PoE) status of the device. + * + * @returns {Object|null} + * Returns an object containing PSE status information or null if + * PSE is not available on this device. The object may contain: + * - c33AdminState: "enabled" or "disabled" (C33 PoE admin state) + * - c33PowerStatus: "disabled", "searching", "delivering", "test", "fault", "otherfault" + * - c33PowerClass: Power class number (1-8) + * - c33ActualPower: Actual power consumption in mW + * - c33AvailablePowerLimit: Available power limit in mW + * - podlAdminState: "enabled" or "disabled" (PoDL admin state) + * - podlPowerStatus: "disabled", "searching", "delivering", "sleep", "idle", "error" + * - priority: Current priority level + * - priorityMax: Maximum priority level + */ + getPSE: function() { + const pse = this._devstate('pse'); + if (!pse) + return null; + + return { + c33AdminState: pse['c33-admin-state'] || null, + c33PowerStatus: pse['c33-power-status'] || null, + c33PowerClass: pse['c33-power-class'] || null, + c33ActualPower: pse['c33-actual-power'] || null, + c33AvailablePowerLimit: pse['c33-available-power-limit'] || null, + podlAdminState: pse['podl-admin-state'] || null, + podlPowerStatus: pse['podl-power-status'] || null, + priority: pse['priority'] || null, + priorityMax: pse['priority-max'] || null + }; + }, + + /** + * Check if PSE (PoE) is available on this device. + * + * @returns {boolean} + * Returns true if PSE hardware is available on this device. + */ + hasPSE: function() { + return this._devstate('pse') != null; + }, + /** * Get the primary logical interface this device is assigned to. * diff --git a/modules/luci-base/root/usr/share/rpcd/acl.d/luci-base.json b/modules/luci-base/root/usr/share/rpcd/acl.d/luci-base.json index f24d97cf1b..cc7f06b3f0 100644 --- a/modules/luci-base/root/usr/share/rpcd/acl.d/luci-base.json +++ b/modules/luci-base/root/usr/share/rpcd/acl.d/luci-base.json @@ -39,6 +39,7 @@ "ubus": { "luci-rpc": [ "getBoardJSON", "getHostHints", "getNetworkDevices", "getWirelessDevices" ], "network": [ "get_proto_handlers" ], + "network.device": [ "status" ], "network.interface": [ "dump" ] }, "uci": [ "luci", "network", "wireless" ] diff --git a/modules/luci-mod-network/htdocs/luci-static/resources/tools/network.js b/modules/luci-mod-network/htdocs/luci-static/resources/tools/network.js index d106e7cdd0..5dd487bdef 100644 --- a/modules/luci-mod-network/htdocs/luci-static/resources/tools/network.js +++ b/modules/luci-mod-network/htdocs/luci-static/resources/tools/network.js @@ -440,7 +440,7 @@ return baseclass.extend({ return s.taboption(tabName, optionClass, optionName, optionTitle, optionDescription); }, - addDeviceOptions: function(s, dev, isNew, rtTables) { + addDeviceOptions: function(s, dev, isNew, rtTables, hasPSE) { var parent_dev = dev ? dev.getParent() : null, devname = dev ? dev.getName() : null, o, ss; @@ -449,6 +449,8 @@ return baseclass.extend({ s.tab('devadvanced', _('Advanced device options')); s.tab('brport', _('Bridge port specific options')); s.tab('bridgevlan', _('Bridge VLAN filtering')); + if (hasPSE) + s.tab('devpse', _('PoE / PSE options')); o = this.replaceOption(s, 'devgeneral', form.ListValue, 'type', _('Device type'), (!L.hasSystemFeature('bonding') && isNew ? ''+ @@ -1139,6 +1141,34 @@ return baseclass.extend({ o.placeholder = dev ? dev._devstate('qlen') : ''; o.datatype = 'uinteger'; + /* PSE / PoE options */ + if (hasPSE) { + o = this.replaceOption(s, 'devpse', form.ListValue, 'pse', _('PoE (C33)'), + _('Power over Ethernet (IEEE 802.3af/at/bt) control for this port. Requires PSE hardware support.')); + o.value('', _('Default')); + o.value('1', _('Enabled')); + o.value('0', _('Disabled')); + + o = this.replaceOption(s, 'devpse', form.ListValue, 'pse_podl', _('PoDL'), + _('Power over Data Lines (IEEE 802.3bu/cg) for single-pair Ethernet.')); + o.value('', _('Default')); + o.value('1', _('Enabled')); + o.value('0', _('Disabled')); + + o = this.replaceOption(s, 'devpse', form.Value, 'pse_power_limit', _('Power limit (mW)'), + _('Maximum power budget for this port in milliwatts. Leave empty for default/maximum.')); + o.datatype = 'uinteger'; + o.placeholder = _('auto'); + o.rmempty = true; + + o = this.replaceOption(s, 'devpse', form.ListValue, 'pse_priority', _('Port priority'), + _('Priority level for power allocation when total power budget is exceeded.')); + o.value('', _('Default')); + o.value('1', _('Critical')); + o.value('2', _('High')); + o.value('3', _('Low')); + } + o = this.replaceOption(s, 'devadvanced', cbiFlagTristate, 'promisc', _('Enable promiscuous mode')); o.sysfs_default = (dev && dev.dev && dev.dev.flags) ? dev.dev.flags.promisc : null; diff --git a/modules/luci-mod-network/htdocs/luci-static/resources/view/network/interfaces.js b/modules/luci-mod-network/htdocs/luci-static/resources/view/network/interfaces.js index fe5360f6b8..d2203e4980 100644 --- a/modules/luci-mod-network/htdocs/luci-static/resources/view/network/interfaces.js +++ b/modules/luci-mod-network/htdocs/luci-static/resources/view/network/interfaces.js @@ -5,12 +5,20 @@ 'require fs'; 'require ui'; 'require uci'; +'require rpc'; 'require form'; 'require network'; 'require firewall'; 'require tools.widgets as widgets'; 'require tools.network as nettools'; +const callNetworkDeviceStatus = rpc.declare({ + object: 'network.device', + method: 'status', + params: [ 'name' ], + expect: { '': {} } +}); + var isReadonlyView = !L.hasViewPermission() || null; function count_changes(section_id) { @@ -1570,10 +1578,20 @@ return view.extend({ }; s.addModalOptions = function(s) { - var isNew = (uci.get('network', s.section, 'name') == null), - dev = getDevice(s.section); - - nettools.addDeviceOptions(s, dev, isNew, rtTables); + const isNew = (uci.get('network', s.section, 'name') == null), + dev = getDevice(s.section), + devName = dev ? dev.getName() : null; + + /* Query PSE status from netifd to determine if device has PSE capability */ + if (devName) { + return L.resolveDefault(callNetworkDeviceStatus(devName), {}).then((status) => { + const hasPSE = (status.pse != null); + nettools.addDeviceOptions(s, dev, isNew, rtTables, hasPSE); + }); + } else { + nettools.addDeviceOptions(s, dev, isNew, rtTables, false); + return Promise.resolve(); + } }; s.handleModalCancel = function(map /*, ... */) { diff --git a/modules/luci-mod-status/htdocs/luci-static/resources/view/status/include/29_ports.js b/modules/luci-mod-status/htdocs/luci-static/resources/view/status/include/29_ports.js index 905f1f36dd..d08b84d1e0 100644 --- a/modules/luci-mod-status/htdocs/luci-static/resources/view/status/include/29_ports.js +++ b/modules/luci-mod-status/htdocs/luci-static/resources/view/status/include/29_ports.js @@ -13,6 +13,13 @@ var callGetBuiltinEthernetPorts = rpc.declare({ expect: { result: [] } }); +const callNetworkDeviceStatus = rpc.declare({ + object: 'network.device', + method: 'status', + params: [ 'name' ], + expect: { '': {} } +}); + function isString(v) { return typeof(v) === 'string' && v !== ''; @@ -236,10 +243,52 @@ function formatSpeed(carrier, speed, duplex) { return carrier ? _('Connected') : _('no link'); } -function formatStats(portdev) { - var stats = portdev._devstate('stats') || {}; +function getPSEStatus(pse) { + if (!pse) + return null; + + const status = pse['c33-power-status'] || pse['podl-power-status'], + power = pse['c33-actual-power']; + + return { + status: status, + power: power, + isDelivering: status === 'delivering' && power > 0 + }; +} + +function formatPSEPower(pse) { + if (!pse) + return null; - return ui.itemlist(E('span'), [ + const status = pse['c33-power-status'] || pse['podl-power-status'], + power = pse['c33-actual-power']; + + if (status === 'delivering' && power) { + const watts = (power / 1000).toFixed(1); + /* Format: "⚡ 15.4 W" - lightning bolt + narrow space + watts + narrow space + W */ + return E('span', { 'style': 'color:#000' }, + [ '\u26a1\ufe0e\u202f%s\u202fW'.format(watts) ]); + } + else if (status === 'searching') { + return E('span', { 'style': 'color:#000' }, + [ '\u26a1\ufe0e\u202f' + _('searching') ]); + } + else if (status === 'fault' || status === 'otherfault' || status === 'error') { + return E('span', { 'style': 'color:#d9534f' }, + [ '\u26a1\ufe0e\u202f' + _('fault') ]); + } + else if (status === 'disabled') { + return E('span', { 'style': 'color:#888' }, + [ '\u26a1\ufe0e\u202f' + _('off') ]); + } + + return null; +} + +function formatStats(portdev, pse) { + const stats = portdev._devstate('stats') || {}; + const items = [ _('Received bytes'), '%1024mB'.format(stats.rx_bytes), _('Received packets'), '%1000mPkts.'.format(stats.rx_packets), _('Received multicast'), '%1000mPkts.'.format(stats.multicast), @@ -252,7 +301,27 @@ function formatStats(portdev) { _('Transmit dropped'), '%1000mPkts.'.format(stats.tx_dropped), _('Collisions seen'), stats.collisions - ]); + ]; + + if (pse) { + const status = pse['c33-power-status'] || pse['podl-power-status'], + power = pse['c33-actual-power'], + powerClass = pse['c33-power-class'], + powerLimit = pse['c33-available-power-limit']; + + items.push(_('PoE status'), status || _('unknown')); + + if (power) + items.push(_('PoE power'), '%.1f W'.format(power / 1000)); + + if (powerClass) + items.push(_('PoE class'), powerClass); + + if (powerLimit) + items.push(_('PoE limit'), '%.1f W'.format(powerLimit / 1000)); + } + + return ui.itemlist(E('span'), items); } function renderNetworkBadge(network, zonename) { @@ -309,16 +378,57 @@ return baseclass.extend({ firewall.getZones(), network.getNetworks(), uci.load('network') - ]); + ]).then((data) => { + /* Get all known port names from builtin ports or board.json */ + const builtinPorts = data[0] || []; + const board = JSON.parse(data[1] || '{}'); + const allPorts = new Set(); + + /* Collect port names from builtin ethernet ports */ + builtinPorts.forEach((port) => { + if (port.device) + allPorts.add(port.device); + }); + + /* Collect port names from board.json if no builtin ports */ + if (allPorts.size === 0 && board.network) { + ['lan', 'wan'].forEach((role) => { + if (board.network[role]) { + if (Array.isArray(board.network[role].ports)) + board.network[role].ports.forEach((p) => allPorts.add(p)); + else if (board.network[role].device) + allPorts.add(board.network[role].device); + } + }); + } + + /* Query PSE status from netifd for all known ports */ + const psePromises = Array.from(allPorts).map((devname) => { + return L.resolveDefault(callNetworkDeviceStatus(devname), {}).then((status) => { + return { name: devname, pse: status.pse || null }; + }); + }); + + return Promise.all(psePromises).then((pseResults) => { + const pseMap = {}; + pseResults.forEach((r) => { + if (r.pse) + pseMap[r.name] = r.pse; + }); + data.push(pseMap); + return data; + }); + }); }, render: function(data) { if (L.hasSystemFeature('swconfig')) return null; - var board = JSON.parse(data[1]), - known_ports = [], - port_map = buildInterfaceMapping(data[2], data[3]); + const board = JSON.parse(data[1]), + port_map = buildInterfaceMapping(data[2], data[3]), + pseMap = data[5] || {}; + let known_ports = []; if (Array.isArray(data[0]) && data[0].length > 0) { known_ports = data[0].map(port => ({ @@ -354,16 +464,40 @@ return baseclass.extend({ }); return E('div', { 'style': 'display:grid;grid-template-columns:repeat(auto-fit, minmax(70px, 1fr));margin-bottom:1em' }, known_ports.map(function(port) { - var speed = port.netdev.getSpeed(), - duplex = port.netdev.getDuplex(), - carrier = port.netdev.getCarrier(), - pmap = port_map[port.netdev.getName()], - pzones = (pmap && pmap.zones.length) ? pmap.zones.sort(function(a, b) { return L.naturalCompare(a.getName(), b.getName()) }) : [ null ]; + const speed = port.netdev.getSpeed(); + const duplex = port.netdev.getDuplex(); + const carrier = port.netdev.getCarrier(); + const pmap = port_map[port.netdev.getName()]; + const pzones = (pmap && pmap.zones.length) ? pmap.zones.sort((a, b) => L.naturalCompare(a.getName(), b.getName())) : [ null ]; + const pse = pseMap[port.device]; + const pseInfo = getPSEStatus(pse); + const psePower = formatPSEPower(pse); + + /* Select port icon based on carrier and PSE status */ + let portIcon; + if (pseInfo && pseInfo.isDelivering) { + portIcon = carrier ? 'pse_up' : 'pse_down'; + } else { + portIcon = carrier ? 'up' : 'down'; + } + + const statsContent = [ + '\u25b2\u202f%1024.1mB'.format(port.netdev.getTXBytes()), + E('br'), + '\u25bc\u202f%1024.1mB'.format(port.netdev.getRXBytes()) + ]; + + if (psePower) { + statsContent.push(E('br')); + statsContent.push(psePower); + } + + statsContent.push(E('span', { 'class': 'cbi-tooltip' }, formatStats(port.netdev, pse))); return E('div', { 'class': 'ifacebox', 'style': 'margin:.25em;min-width:70px;max-width:100px' }, [ E('div', { 'class': 'ifacebox-head', 'style': 'font-weight:bold' }, [ port.netdev.getName() ]), E('div', { 'class': 'ifacebox-body' }, [ - E('img', { 'src': L.resource('icons/port_%s.svg').format(carrier ? 'up' : 'down') }), + E('img', { 'src': L.resource('icons/port_%s.svg').format(portIcon) }), E('br'), formatSpeed(carrier, speed, duplex) ]), @@ -377,12 +511,7 @@ return baseclass.extend({ E('span', { 'class': 'cbi-tooltip left' }, [ renderNetworksTooltip(pmap) ]) ]), E('div', { 'class': 'ifacebox-body' }, [ - E('div', { 'class': 'cbi-tooltip-container', 'style': 'text-align:left;font-size:80%' }, [ - '\u25b2\u202f%1024.1mB'.format(port.netdev.getTXBytes()), - E('br'), - '\u25bc\u202f%1024.1mB'.format(port.netdev.getRXBytes()), - E('span', { 'class': 'cbi-tooltip' }, formatStats(port.netdev)) - ]), + E('div', { 'class': 'cbi-tooltip-container', 'style': 'text-align:left;font-size:80%' }, statsContent) ]) ]); }));