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 <github@szelinsky.de>
This commit is contained in:
Carlo Szelinsky
2026-01-25 12:40:49 +01:00
committed by Paul Donald
parent 77f8596e2a
commit 8e493db75a
7 changed files with 248 additions and 24 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@@ -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.
*

View File

@@ -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" ]

View File

@@ -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 ? '<a href="' + L.url("admin", "system", "package-manager", "?query=kmod-bonding") + '">'+
@@ -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;

View File

@@ -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);
const isNew = (uci.get('network', s.section, 'name') == null),
dev = getDevice(s.section),
devName = dev ? dev.getName() : null;
nettools.addDeviceOptions(s, dev, isNew, rtTables);
/* 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 /*, ... */) {

View File

@@ -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;
return ui.itemlist(E('span'), [
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;
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)
])
]);
}));