🎁 Sync 2026-05-19 21:08:14

This commit is contained in:
github-actions[bot]
2026-05-19 21:08:15 +08:00
parent 487df6e407
commit 9a2ef6ebbc
23 changed files with 813 additions and 677 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ LUCI_TITLE:=Information dashboard for 3G/LTE dongle
LUCI_DEPENDS:=+modeminfo
PKG_LICENSE:=GPLv3
PKG_VERSION:=0.4.7
PKG_RELEASE:=3
PKG_RELEASE:=4
include $(TOPDIR)/feeds/luci/luci.mk
@@ -9,158 +9,162 @@
'require dom';
'require tools.widgets as widgets';
/*
Copyright Konstantine Shevlakov <shevlakov@132lan.ru> 2023
Copyright Konstantine Shevlakov <shevlakov@132lan.ru> 2023-2026
Licensed to the GNU General Public License v3.0.
Refactored: bug fixes, XSS prevention, deduplication.
*/
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Safe getElementById.
* @param {string} id
* @returns {HTMLElement|null}
*/
function getEl(id) {
return document.getElementById(id);
}
/**
* Write a value into a cell by id.
* Falls back to '--' when value is missing/placeholder.
* Uses textContent to prevent XSS.
*
* @param {string} id - Element id
* @param {string} value - Raw value from modem JSON
* @param {string} [suffix] - Optional suffix appended to a real value (e.g. ' °C')
*/
function setCell(id, value, suffix) {
const el = getEl(id);
if (!el) return;
const missing = !value || value === '--';
el.textContent = missing ? '--' : value + (suffix || '');
}
// ─── Field definitions ────────────────────────────────────────────────────────
// Each entry: { key: modem JSON field, suffix: optional unit string }
const MODEM_FIELDS = [
{ key: 'device', suffix: '' },
{ key: 'firmware', suffix: '' },
{ key: 'imsi', suffix: '' },
{ key: 'iccid', suffix: '' },
{ key: 'imei', suffix: '' },
{ key: 'chiptemp', suffix: ' °C' },
];
// ─── Main view ────────────────────────────────────────────────────────────────
return view.extend({
load: function(data) {
return L.resolveDefault(fs.exec_direct('/usr/bin/modeminfo'));
// FIX: removed unused `data` parameter
load: function() {
return L.resolveDefault(fs.exec_direct('/usr/bin/modeminfo'), '{"modem": []}');
},
polldata: poll.add(function() {
return L.resolveDefault(fs.exec_direct('/usr/bin/modeminfo')).then(function(res) {
var json = JSON.parse(res);
if (!json || !json.modem || !Array.isArray(json.modem)) return;
for (var i = 0; i < json.modem.length; i++) {
if (document.getElementById('device'+i)) {
var view = document.getElementById('device'+i);
if (json.modem[i].device == '--') {
view = document.getElementById('--');
} else {
view.innerHTML = String.format(json.modem[i].device);
}
}
if (document.getElementById('firmware'+i)) {
var view = document.getElementById('firmware'+i);
if (json.modem[i].firmware == '--') {
view = document.getElementById('--');
} else {
view.innerHTML = String.format(json.modem[i].firmware);
}
}
if (document.getElementById('imsi'+i)) {
var view = document.getElementById('imsi'+i);
if (json.modem[i].imsi == '--') {
view = document.getElementById('--');
} else {
view.innerHTML = String.format(json.modem[i].imsi);
}
}
if (document.getElementById('iccid'+i)) {
var view = document.getElementById('iccid'+i);
if (json.modem[i].iccid == '--') {
view = document.getElementById('--');
} else {
view.innerHTML = String.format(json.modem[i].iccid);
}
return L.resolveDefault(fs.exec_direct('/usr/bin/modeminfo'), '{"modem": []}')
.then(function(res) {
// FIX: wrapped in try/catch — was crashing on invalid JSON
let json;
try {
json = JSON.parse(res);
} catch (e) {
console.error('modeminfo hw: JSON parse error', e);
return;
}
if (document.getElementById('imei'+i)) {
var view = document.getElementById('imei'+i);
if (json.modem[i].imei == '--') {
view = document.getElementById('--');
} else {
view.innerHTML = String.format(json.modem[i].imei);
if (!json || !Array.isArray(json.modem)) return;
for (let i = 0; i < json.modem.length; i++) {
const modem = json.modem[i];
// FIX: replaced 6 copy-pasted blocks with a single loop.
// FIX: `var view` renamed — was shadowing the LuCI `view` module.
// FIX: value '--' now explicitly written to cell (was silently ignored).
// FIX: innerHTML → textContent to prevent XSS.
// FIX: String.format(x) with no extra args replaced by direct assignment.
for (const { key, suffix } of MODEM_FIELDS) {
setCell(key + i, modem[key], suffix);
}
}
if (document.getElementById('chiptemp'+i)) {
var view = document.getElementById('chiptemp'+i);
if (json.modem[i].chiptemp == '--') {
view = document.getElementById('--');
} else {
view.innerHTML = String.format(json.modem[i].chiptemp+' °C');
}
}
};
});
});
}),
render: function(data){
var m, s, o;
m = new form.Map('modeminfo', _('Modeminfo: Hardware'), _('Hardware and sim-card info.'));
s = m.section(form.TypedSection, 'general', null);
var json = JSON.parse(data);
// for future use
/*
var vendors = [];
var duplicate_modem_index = 1;
json.modem.forEach(obj => {
const device = obj.device;
if (!vendors.includes(device)) {
vendors.push(device);
} else {
vendors.push(device+" ("+duplicate_modem_index+")");
duplicate_modem_index++;
}
});
*/
for (var i = 0; i < json.modem.length; i++) {
let device = 'device'+i;
let firmware = 'firmware'+i;
let imsi = 'imsi'+i;
let iccid = 'iccid'+i;
let imei = 'imei'+i;
let chiptemp = 'chiptemp'+i;
let m = i+1;
if ( json.modem.length > 1 ) {
s.tab('modem'+i, _('Modem')+' '+m);
o = s.taboption('modem'+i, form.HiddenValue, 'generic');
render: function(data) {
// FIX: wrapped in try/catch — was crashing on invalid JSON
let json;
try {
json = JSON.parse(data);
} catch (e) {
json = { modem: [] };
}
const m = new form.Map('modeminfo', _('Modeminfo: Hardware'), _('Hardware and sim-card info.'));
const s = m.section(form.TypedSection, 'general', null);
s.anonymous = true;
for (let i = 0; i < json.modem.length; i++) {
const idx = i + 1;
// Pre-build all element ids for this modem slot
const ids = {};
for (const { key } of MODEM_FIELDS) {
ids[key] = key + i;
}
let o;
if (json.modem.length > 1) {
s.tab('modem' + i, _('Modem') + ' ' + idx);
o = s.taboption('modem' + i, form.HiddenValue, 'generic');
} else {
o = s.option(form.HiddenValue, 'generic');
}
o.render = L.bind(function(data){
return (
E('div', {}, [
E('h3', { 'class': 'data-tab' }),
E('div', { 'class': 'cbi-section', 'data-title': 'modem'+i }, [
E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr cbi-rowstyle-2' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [ _('Device')]),
E('td', { 'class': 'td left', 'id': device }, [ '--' ]),
]),
E('tr', { 'class': 'tr cbi-rowstyle-1' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [ _('Firmware')]),
E('td', { 'class': 'td left', 'id': firmware }, [ '--' ]),
]),
E('tr', { 'class': 'tr cbi-rowstyle-2' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [ _('IMSI')]),
E('td', { 'class': 'td left', 'id': imsi }, [ '--' ]),
]),
E('tr', { 'class': 'tr cbi-rowstyle-1' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [ _('ICCID')]),
E('td', { 'class': 'td left', 'id': iccid }, [ '--' ]),
]),
E('tr', { 'class': 'tr cbi-rowstyle-2' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [ _('IMEI')]),
E('td', { 'class': 'td left', 'id': imei }, [ '--' ]),
]),
E('tr', { 'class': 'tr cbi-rowstyle-1' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [ _('Chiptemp')]),
E('td', { 'class': 'td left', 'id': chiptemp }, [ '--' ]),
])
])
]),
]
)
)}, o, this.polldata);
o.anonymous = true;
o.rmempty = true;
};
s.anonymous = true;
return m.render();
o.render = L.bind(function() {
return E('div', {}, [
E('h3', { 'class': 'data-tab' }),
E('div', { 'class': 'cbi-section' }, [
E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr cbi-rowstyle-2' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [_('Device')]),
E('td', { 'class': 'td left', 'id': ids.device }, ['--']),
]),
E('tr', { 'class': 'tr cbi-rowstyle-1' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [_('Firmware')]),
E('td', { 'class': 'td left', 'id': ids.firmware }, ['--']),
]),
E('tr', { 'class': 'tr cbi-rowstyle-2' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [_('IMSI')]),
E('td', { 'class': 'td left', 'id': ids.imsi }, ['--']),
]),
E('tr', { 'class': 'tr cbi-rowstyle-1' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [_('ICCID')]),
E('td', { 'class': 'td left', 'id': ids.iccid }, ['--']),
]),
E('tr', { 'class': 'tr cbi-rowstyle-2' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [_('IMEI')]),
E('td', { 'class': 'td left', 'id': ids.imei }, ['--']),
]),
E('tr', { 'class': 'tr cbi-rowstyle-1' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [_('Chiptemp')]),
E('td', { 'class': 'td left', 'id': ids.chiptemp }, ['--']),
]),
])
])
]);
}, this.polldata);
o.anonymous = true;
o.rmempty = true;
}
return m.render();
},
handleSaveApply: null,
handleSave: null,
handleReset: null
handleSave: null,
handleReset: null,
});
@@ -9,464 +9,543 @@
'require dom';
'require tools.widgets as widgets';
/*
Copyright Konstantine Shevlakov <shevlakov@132lan.ru> 2023
Licensed to the GNU General Public License v3.0.
Refactored: bug fixes, performance improvements, code deduplication.
*/
const REGISTERED_STATUSES = [1, 6, 9];
const ROAMING_STATUSES = [3, 5, 7, 10];
const ROAMING_STATUSES = [3, 5, 7, 10];
// ─── Signal Icons ────────────────────────────────────────────────────────────
const SIGNAL_ICONS = [
{ max: 10, icn: 'signal-000-000.svg' },
{ max: 25, icn: 'signal-000-025.svg' },
{ max: 50, icn: 'signal-025-050.svg' },
{ max: 75, icn: 'signal-050-075.svg' },
{ max: Infinity, icn: 'signal-075-100.svg' },
];
// ─── Registration Status Labels ───────────────────────────────────────────────
const REG_STATUSES = new Map([
[0, _('No Registration')],
[2, _('Searching')], [8, _('Searching')],
[3, _('Denied')],
[4, _('Unknown')],
[5, _('Roaming')], [7, _('Roaming')], [10, _('Roaming')],
]);
// ─── Progress bar configuration ───────────────────────────────────────────────
// calc(vn, mn): vn = clamped signal value, mn = min boundary
// All formulas normalise the value to 0100% range for the progress bar.
const PROGRESS_CONFIG = {
rssi: {
selector: '#rssi',
min: -110, max: -50,
// Linear map from [-110..-50] → [0..100]
calc: (vn, mn) => Math.floor(100 * (1 - (-50 - vn) / (-50 - mn))),
},
rsrp: {
selector: '#rsrp',
min: -140, max: -50,
// Slightly expanded scale (×1.2) to better use bar width
calc: (vn, mn) => Math.floor(120 * (1 - (-50 - vn) / (-70 - mn))),
},
sinr: {
selector: '#sinr',
min: -20, max: 30,
// Linear map from [-20..30] → [0..100]
calc: (vn, mn) => Math.floor(100 - (100 * (1 - ((mn - vn) / (mn - 30))))),
},
rsrq: {
selector: '#rsrq',
min: -20, max: 0,
// Proportional map from [-20..0] → [0..100]
calc: (vn, mn) => Math.floor(115 - (100 / mn) * vn),
},
ecio: {
selector: '#sinr',
min: -24, max: 0,
// Same shape as rsrq but for EC/IO range [-24..0]
calc: (vn, mn) => Math.floor(100 - (100 / mn) * vn),
},
};
// ─── LTE EARFCN → band/frequency table ───────────────────────────────────────
const LTE_BANDS = [
{ min: 0, max: 599, frdl: 2110, frul: 1920, offset: 0, band: '1' },
{ min: 600, max: 1199, frdl: 1930, frul: 1850, offset: 600, band: '2' },
{ min: 1200, max: 1949, frdl: 1805, frul: 1710, offset: 1200, band: '3' },
{ min: 1950, max: 2399, frdl: 2110, frul: 1710, offset: 1950, band: '4' },
{ min: 2400, max: 2469, frdl: 869, frul: 824, offset: 2400, band: '5' },
{ min: 2750, max: 3449, frdl: 2620, frul: 2500, offset: 2750, band: '7' },
{ min: 3450, max: 3799, frdl: 925, frul: 880, offset: 3450, band: '8' },
{ min: 6150, max: 6449, frdl: 791, frul: 832, offset: 6150, band: '20' },
{ min: 9210, max: 9659, frdl: 758, frul: 703, offset: 9210, band: '28' },
{ min: 9870, max: 9919, frdl: 452.5, frul: 462.5, offset: 9870, band: '31' },
{ min: 37750, max: 38249, frdl: 2570, frul: 2570, offset: 37750, band: '38' },
{ min: 38650, max: 39649, frdl: 2300, frul: 2300, offset: 38650, band: '40' },
{ min: 39650, max: 41589, frdl: 2496, frul: 2496, offset: 39650, band: '41' },
];
// ─── Non-LTE (UMTS/GSM) ARFCN → band/frequency table ─────────────────────────
const NON_LTE_BANDS = [
{
condition: rfcn => rfcn >= 10562 && rfcn <= 10838,
calc: rfcn => ({ offset: 950, dlfreq: rfcn / 5, ulfreq: (rfcn - 950) / 5, band: 'IMT2100' }),
},
{
condition: rfcn => rfcn >= 2937 && rfcn <= 3088,
calc: rfcn => ({ frul: 925, ulfreq: 340 + (rfcn / 5), dlfreq: (340 + (rfcn / 5)) - 45, band: 'UMTS900' }),
},
{
condition: rfcn => rfcn >= 955 && rfcn <= 1023,
calc: rfcn => ({ frul: 890, ulfreq: 890 + ((rfcn - 1024) / 5), dlfreq: (890 + ((rfcn - 1024) / 5)) + 45, band: 'DSC900' }),
},
{
condition: rfcn => rfcn >= 512 && rfcn <= 885,
calc: rfcn => ({ frul: 1710, ulfreq: 1710 + ((rfcn - 512) / 5), dlfreq: (1710 + ((rfcn - 512) / 5)) + 95, band: 'DCS1800' }),
},
{
condition: rfcn => rfcn >= 1 && rfcn <= 124,
calc: rfcn => ({ frul: 890, ulfreq: 890 + (rfcn / 5), dlfreq: (890 + (rfcn / 5)) + 45, band: 'GSM900' }),
},
];
const UMTS_MODES = /(HS|3G|UMTS|WCDMA)/i;
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Safe getElementById wrapper.
* @param {string} id
* @returns {HTMLElement|null}
*/
function getEl(id) {
return document.getElementById(id);
}
/**
* Show or hide the grandparent row of a signal element.
* @param {HTMLElement} el
* @param {boolean} show
*/
function setRowVisible(el, show) {
const row = el && el.parentElement && el.parentElement.parentElement;
if (row) row.style.display = show ? '' : 'none';
}
/**
* Update a cbi-progressbar element.
* @param {string} type - Key in PROGRESS_CONFIG
* @param {string} value - Raw signal value string (e.g. "-85 dBm")
* @param {number} max - Boundary value (passed as second calc argument)
* @param {number} idx - Modem index suffix for the element id
*/
function updateProgressBar(type, value, max, idx) {
const config = PROGRESS_CONFIG[type];
if (!config) return;
const pg = document.querySelector(`${config.selector}${idx}`);
if (!pg) return;
const vn = Math.max(config.min, Math.min(config.max, parseInt(value) || 0));
const mn = parseInt(max) || 100;
const pc = Math.min(100, Math.max(0, config.calc(vn, mn)));
pg.firstElementChild.style.width = `${pc}%`;
pg.firstElementChild.style.animationDirection = 'reverse';
pg.setAttribute('title', String(value));
}
/**
* Format distance string, returns empty string when unavailable.
* @param {string|number} dist
* @returns {string}
*/
function formatDistance(dist) {
if (!dist || dist === '--' || dist === '' || dist === '0.00') return '';
return ' ~' + dist + ' km';
}
/**
* Build the operator status badge HTML safely using LuCI E() helper.
* @param {Object} modem - Single modem entry from JSON
* @param {string} icon - URL to signal icon
* @param {string} reg - Human-readable registration status
* @returns {string} - Safe innerHTML string via dom.content
*/
function formatModemStatus(modem, icon, reg) {
const rg = parseInt(modem.reg) || 0;
const p = modem.csq_per || 0;
const cops = modem.cops || '--';
const color = modem.csq_col || '#000000';
const distanceText = formatDistance(modem.distance);
const iconEl = icon ? E('img', { 'class': 'modem-signal-icon', 'src': icon }) : null;
const boldEl = E('b', { 'style': `color:${color}` }, [`${p}%`]);
const children = iconEl
? [cops + ' ', iconEl, ' ', boldEl, distanceText]
: [cops + ' ', boldEl, distanceText];
if (REGISTERED_STATUSES.includes(rg)) {
return E('span', { 'class': 'ifacebadge' }, children).outerHTML;
} else if (ROAMING_STATUSES.includes(rg)) {
return E('span', { 'class': 'ifacebadge' }, [`${cops} (${reg}) `, ...(iconEl ? [iconEl, ' '] : []), boldEl, distanceText]).outerHTML;
} else {
return E('span', { 'class': 'ifacebadge' }, [reg || '--']).outerHTML;
}
}
/**
* Derive signal icon URL from signal percentage.
* @param {number} pct
* @returns {string}
*/
function resolveSignalIcon(pct) {
const { icn } = SIGNAL_ICONS.find(({ max }) => pct <= max) || SIGNAL_ICONS[SIGNAL_ICONS.length - 1];
return L.resource(`view/modem/icons/${icn}`);
}
/**
* Calculate DL/UL frequencies and band name for LTE mode.
* @param {number} rfcn
* @returns {{ dlfreq: number, ulfreq: number, band: string, frdl: number, frul: number, offset: number }}
*/
function calcLteBand(rfcn) {
const b = LTE_BANDS.find(b => rfcn >= b.min && rfcn <= b.max);
if (!b) return { frdl: 0, frul: 0, offset: 0, band: String(rfcn), dlfreq: 0, ulfreq: 0 };
const dlfreq = b.frdl + (rfcn - b.offset) / 10;
const ulfreq = b.frul + (rfcn - b.offset) / 10;
return { ...b, dlfreq, ulfreq };
}
/**
* Calculate DL/UL frequencies and band name for non-LTE modes.
* @param {number} rfcn
* @returns {{ dlfreq: number, ulfreq: number, band: string }}
*/
function calcNonLteBand(rfcn) {
const match = NON_LTE_BANDS.find(b => b.condition(rfcn));
return match ? match.calc(rfcn) : { ulfreq: 0, dlfreq: 0, band: String(rfcn) };
}
/**
* Build a LAC/CID/eNB/Cell/PCI label and value string.
* @param {Object} modem - Single modem entry
* @returns {{ namecid: string, lactac: string }}
*/
function buildCellId(modem) {
const { enbid, cell, pci, lac, cid } = modem;
const parts = [lac, cid];
let namecid = 'LAC/CID';
if (enbid) {
parts.push(enbid);
namecid += '/eNB ID';
if (cell) {
parts.push(cell);
namecid += '/Cell';
if (pci) {
parts.push(pci);
namecid += '/PCI';
}
}
}
const lactac = parts.join(' / ');
return { namecid, lactac };
}
/**
* Derive mode-specific labels and carrier aggregation info.
* @param {Object} modem - Single modem entry
* @param {string} netmode
* @param {string} band
* @param {number} bw
* @returns {{ carrier, bcc, bca, bwDisplay, namech, namesnr, namecid, lactac, namebnd }}
*/
function buildModeInfo(modem, netmode, band, bw) {
let carrier = '';
let bcc, bca, bwDisplay, namech, namesnr, namecid, lactac;
if (netmode === 'LTE') {
const calte = modem.lteca;
namech = 'EARFCN';
namesnr = 'SINR';
if (calte > 0) {
carrier = '+';
bwDisplay = modem.bwca;
bcc = ` B${band}${modem.scc}`;
bca = bwDisplay ? ` / ${bwDisplay} MHz` : '';
} else {
bwDisplay = bw;
bcc = ` B${band}`;
bca = bw ? ` / ${bw} MHz` : '';
}
const cellInfo = buildCellId(modem);
namecid = cellInfo.namecid;
lactac = cellInfo.lactac;
} else if (UMTS_MODES.test(netmode)) {
namech = 'UARFCN';
namesnr = 'ECIO';
namecid = 'LAC/CID';
lactac = `${modem.lac} / ${modem.cid}`;
bcc = ` ${band}`;
} else {
namech = 'ARFCN';
namesnr = 'SINR/ECIO';
namecid = 'LAC/CID';
lactac = `${modem.lac} / ${modem.cid}`;
bcc = ` ${band}`;
}
const namebnd = bw
? _('Network/Band/Bandwidth')
: _('Network/Band');
return { carrier, bcc, bca: bca || '', bwDisplay, namech, namesnr, namecid, lactac, namebnd };
}
/**
* Set textContent of element by id (if it exists).
* @param {string} id
* @param {string} text
*/
function setText(id, text) {
const el = getEl(id);
if (el) el.textContent = text;
}
/**
* Set innerHTML of element by id (if it exists).
* @param {string} id
* @param {string} html
*/
function setHtml(id, html) {
const el = getEl(id);
if (el) el.innerHTML = html;
}
/**
* Update a signal progress bar, hiding its parent row if value is missing.
* @param {string} elId - Element id (without modem index)
* @param {number} idx - Modem index
* @param {*} rawVal - Raw value from modem JSON
* @param {string} unit - Unit suffix, e.g. ' dBm'
* @param {string} type - Key in PROGRESS_CONFIG
* @param {number} boundary
*/
function updateSignalBar(elId, idx, rawVal, unit, type, boundary) {
const el = getEl(elId + idx);
if (!el) return;
const missing = !rawVal || rawVal === '--' || rawVal === '';
setRowVisible(el, !missing);
if (!missing) {
updateProgressBar(type, rawVal + unit, boundary, idx);
}
}
// ─── Main view ────────────────────────────────────────────────────────────────
return view.extend({
load: function(data) {
load: function() {
return L.resolveDefault(fs.exec_direct('/usr/bin/modeminfo'), '{"modem": []}');
},
polldata: poll.add(function() {
return L.resolveDefault(fs.exec_direct('/usr/bin/modeminfo'), '{"modem": []}').then(function(res) {
var json = JSON.parse(res);
if (!json || !json.modem || !Array.isArray(json.modem)) return;
for (var i = 0; i < json.modem.length; i++) {
// progressbar cellular metric
const progressConfig = {
rssi: {
selector: '#rssi', min: -110, max: -50,
calc: (vn, mn) => Math.floor(100 * (1 - (-50 - vn) / (-50 - mn)))
},
rsrp: {
selector: '#rsrp', min: -140, max: -50,
calc: (vn, mn) => Math.floor(120 * (1 - (-50 - vn) / (-70 - mn)))
},
sinr: {
selector: '#sinr', min: -20, max: 30,
calc: (vn, mn) => Math.floor(100 - (100 * (1 - ((mn - vn) / (mn - 30)))))
},
rsrq: {
selector: '#rsrq', min: -20, max: 0,
calc: (vn, mn) => Math.floor(115 - (100 / mn) * vn)
},
ecio: {
selector: '#sinr', min: -24, max: 0,
calc: (vn, mn) => Math.floor(100 - (100 / mn) * vn)
}
};
function updateProgressBar(type, value, max, i) {
const config = progressConfig[type];
if (!config) return;
const pg = document.querySelector(`${config.selector}${i}`);
if (!pg) return;
const vn = Math.max(config.min, Math.min(config.max, parseInt(value) || 0));
const mn = parseInt(max) || 100;
const pc = config.calc(vn, mn);
pg.firstElementChild.style.width = `${pc}%`;
pg.style.width = '%d%%';
pg.firstElementChild.style.animationDirection = "reverse";
pg.setAttribute('title', '%s'.format(value));
return L.resolveDefault(fs.exec_direct('/usr/bin/modeminfo'), '{"modem": []}')
.then(function(res) {
let json;
try {
json = JSON.parse(res);
} catch (e) {
console.error('modeminfo: JSON parse error', e);
return;
}
function formatDistance(dist) {
if (!dist || dist === "--" || dist === "" || dist === "0.00") {
return '';
}
return ' ~' + dist + ' km';
}
if (!json || !Array.isArray(json.modem)) return;
function formatModemStatus(modem, icon, reg) {
var rg = parseInt(modem.reg) || 0;
var p = modem.csq_per || 0;
var cops = modem.cops || '--';
var color = modem.csq_col || '#000000';
var distanceHtml = formatDistance(modem.distance);
var iconHtml = icon ? '<img class="modem-signal-icon" src="' + icon + '"/>' : '';
var percentWithColor = p + '%';
if (REGISTERED_STATUSES.includes(rg)) {
return '<span class="ifacebadge">' + cops + ' ' + iconHtml +
' <b style="color:' + color + '">' + percentWithColor + '</b>' +
distanceHtml + '</span>';
} else if (ROAMING_STATUSES.includes(rg)) {
return '<span class="ifacebadge">' + cops + ' (' + reg + ') ' + iconHtml +
' <b style="color:' + color + '">' + percentWithColor + '</b>' +
distanceHtml + '</span>';
for (let i = 0; i < json.modem.length; i++) {
const modem = json.modem[i];
const netmode = modem.mode || '';
const rfcn = modem.arfcn || 0;
// ── Band / Frequency ────────────────────────────────────
let dlfreq, ulfreq, band, bw;
if (netmode === 'LTE') {
({ dlfreq, ulfreq, band } = calcLteBand(rfcn));
const bandwidths = [1.4, 3, 5, 10, 15, 20];
bw = bandwidths[modem.bwdl] || '';
} else {
return '<span class="ifacebadge">' + (reg || '--') + '</span>';
}
}
// icon signal strength
var icn;
var signalIcons = [
{ max: -1, icn: 'signal-000-000.svg' },
{ max: 0, icn: 'signal-000-000.svg' },
{ max: 10, icn: 'signal-000-000.svg' },
{ max: 25, icn: 'signal-000-025.svg' },
{ max: 50, icn: 'signal-025-050.svg' },
{ max: 75, icn: 'signal-050-075.svg' },
{ max: Infinity, icn: 'signal-075-100.svg' }
];
var p = json.modem[i].csq_per || 0;
var { icn } = signalIcons.find(({ max }) => p <= max);
var icon = L.resource(`view/modem/icons/${icn}`);
// Registration Status
var regStatuses = new Map([
[0, _('No Registration')],
[2, _('Searching')], [8, _('Searching')],
[3, _('Denied')],
[4, _('Unknown')],
[5, _('Roaming')], [7, _('Roaming')], [10, _('Roaming')]
]);
var rg = Number(json.modem[i].reg);
var reg = regStatuses.get(rg) || _('No Data');
// frequency band calculator
var offset, band, bw, frdl, frul;
var netmode = (json.modem[i].mode)
var rfcn = (json.modem[i].arfcn)
if (netmode === "LTE") {
// list LTE-bans
var lteBands = [
{ min: 0, max: 599, frdl: 2110, frul: 1920, offset: 0, band: "1" },
{ min: 600, max: 1199, frdl: 1930, frul: 1850, offset: 600, band: "2" },
{ min: 1200, max: 1949, frdl: 1805, frul: 1710, offset: 1200, band: "3" },
{ min: 1950, max: 2399, frdl: 2110, frul: 1710, offset: 1950, band: "4" },
{ min: 2400, max: 2469, frdl: 869, frul: 824, offset: 2400, band: "5" },
{ min: 2750, max: 3449, frdl: 2620, frul: 2500, offset: 2750, band: "7" },
{ min: 3450, max: 3799, frdl: 925, frul: 880, offset: 3450, band: "8" },
{ min: 6150, max: 6449, frdl: 791, frul: 832, offset: 6150, band: "20" },
{ min: 9210, max: 9659, frdl: 758, frul: 703, offset: 9210, band: "28" },
{ min: 9870, max: 9919, frdl: 452.5, frul: 462.5, offset: 9870, band: "31" },
{ min: 37750, max: 38249, frdl: 2570, frul: 2570, offset: 37750, band: "38" },
{ min: 38650, max: 39649, frdl: 2300, frul: 2300, offset: 38650, band: "40" },
{ min: 39650, max: 41589, frdl: 2496, frul: 2496, offset: 39650, band: "41" }
];
var bandConfig = lteBands.find(b => rfcn >= b.min && rfcn <= b.max) || {
frdl: 0, frul: 0, offset: 0, band: rfcn
};
({ frdl, frul, offset, band } = bandConfig);
// Bandwidth channel (bw)
var bandwidths = [1.4, 3, 5, 10, 15, 20];
var bw = bandwidths[json.modem[i].bwdl] || "";
// Calc frequency
var dlfreq = frdl + (rfcn - offset) / 10;
var ulfreq = frul + (rfcn - offset) / 10;
} else {
var nonLteBands = [
{
condition: (rfcn) => rfcn >= 10562 && rfcn <= 10838,
calc: (rfcn) => ({ offset: 950, dlfreq: rfcn / 5, ulfreq: (rfcn - 950) / 5, band: "IMT2100" })
},
{
condition: (rfcn) => rfcn >= 2937 && rfcn <= 3088,
calc: (rfcn) => ({ frul: 925, offset: 340, ulfreq: 340 + (rfcn / 5), dlfreq: (340 + (rfcn / 5)) - 45, band: "UMTS900" })
},
{
condition: (rfcn) => rfcn >= 955 && rfcn <= 1023,
calc: (rfcn) => ({ frul: 890, ulfreq: 890 + ((rfcn - 1024) / 5), dlfreq: (890 + ((rfcn - 1024) / 5)) + 45, band: "DSC900" })
},
{
condition: (rfcn) => rfcn >= 512 && rfcn <= 885,
calc: (rfcn) => ({ frul: 1710, ulfreq: 1710 + ((rfcn - 512) / 5), dlfreq: (1710 + ((rfcn - 512) / 5)) + 95, band: "DCS1800" })
},
{
condition: (rfcn) => rfcn >= 1 && rfcn <= 124,
calc: (rfcn) => ({ frul: 890, ulfreq: 890 + (rfcn / 5), dlfreq: (890 + (rfcn / 5)) + 45, band: "GSM900" })
}
];
var bandConfig = nonLteBands.find(b => b.condition(rfcn))?.calc(rfcn) || { ulfreq: 0, dlfreq: 0, band: String(rfcn) };
// CALC BANDS
({ frul, offset, ulfreq, dlfreq, band } = bandConfig);
}
var carrier = "";
var bcc, freq, distance, calte, namebnd;
var dist = (json.modem[i].distance)
var { enbid, cell, pci, lac, cid } = json.modem[i];
const parts = [lac, cid];
var namecid = "LAC/CID";
if (enbid) {
parts.push(enbid);
namecid += "/eNB ID";
if (cell) {
parts.push(`/${cell}`);
namecid += "/Cell";
if (pci) {
parts.push(`/${pci}`);
namecid += "/PCI";
}
}
({ dlfreq, ulfreq, band } = calcNonLteBand(rfcn));
bw = '';
}
var lactac = parts.join(' / ').replace(' ', ' ').replace('/ /', '/ ').replace('/ /', '/ ')
const arfcnStr = `${rfcn} (${dlfreq} / ${ulfreq} MHz)`;
var UMTS_MODES = /(HS|3G|UMTS|WCDMA)/i;
// ── Registration ────────────────────────────────────────
const rg = Number(modem.reg);
const reg = REG_STATUSES.get(rg) || _('No Data');
var bcc, scc, cid;
var bca = "";
var arfcn = json.modem[i].arfcn + " (" + dlfreq + " / " + ulfreq + " MHz)";
// name channels and signal/noise
if (netmode == "LTE") {
var calte = (json.modem[i].lteca)
var bwca = json.modem[i].bwca;
distance = " ~"+ dist +" km";
if (calte > 0) {
carrier = "+";
scc = json.modem[i].scc;
bw = bwca;
bca = " / " + bw + " MHz";
bcc = " B" + band + "" + scc;
// ── Signal icon ─────────────────────────────────────────
const icon = resolveSignalIcon(modem.csq_per || 0);
// ── Mode-specific labels ────────────────────────────────
const { carrier, bcc, bca, namech, namesnr, namecid, lactac, namebnd } =
buildModeInfo(modem, netmode, band, bw);
// ── DOM updates ─────────────────────────────────────────
setHtml('status' + i, formatModemStatus(modem, icon, reg));
const modeEl = getEl('mode' + i);
if (modeEl) {
// FIX: was `signal = 0` (assignment), must be `=== 0`
if (modem.signal === 0 || modem.signal === '' || !netmode) {
modeEl.textContent = '--';
} else {
scc = "";
bcc = " B" + band;
if (bw) {
bca = " / " + bw + " MHz";
} else{
bca = ""
}
modeEl.textContent = `${netmode}${carrier} /${bcc}${bca}`;
}
var namech = "EARFCN";
var namesnr = "SINR";
} else if (UMTS_MODES.test(netmode)) {
var namech = "UARFCN";
var namesnr = "ECIO";
var namecid = "LAC/CID";
var lactac = json.modem[i].lac + " / " + json.modem[i].cid;
var bcc = " " + band;
} else {
var namech = "ARFCN";
var namesnr = "SINR/ECIO";
var namecid = "LAC/CID";
var lactac = json.modem[i].lac + " / " + json.modem[i].cid;
var bcc = " " + band;
}
if (bw) {
namebnd = _('Network/Band/Bandwidth');
} else {
namebnd = _('Network/Band');
}
// data by element
setText('namebnd' + i, namebnd);
setText('chname' + i, namech);
setText('namecid' + i, namecid);
setText('snrname' + i, namesnr);
setHtml('arfcn' + i, arfcnStr);
setHtml('lac' + i, lactac);
if (document.getElementById('status'+i)) {
var view = document.getElementById('status'+i);
view.innerHTML = formatModemStatus(json.modem[i], icon, reg);
}
if (document.getElementById('mode'+i)){
var view = document.getElementById('mode'+i);
if (json.modem[i].signal = 0 || json.modem[i].signal == '') {
view.textContent = '--';
} else {
if (json.modem[i].mode == '') {
view.textContent = '--';
// ── Progress bars ───────────────────────────────────────
// RSSI always visible if element exists
if (getEl('rssi' + i)) {
if (!modem.rssi || modem.rssi === '') {
setText('rssi' + i, '--');
} else {
view.innerHTML = String.format(json.modem[i].mode + "" + carrier + " /"+ bcc +""+ bca);
updateProgressBar('rssi', modem.rssi + ' dBm', -110, i);
}
}
}
if (document.getElementById('namebnd'+i)){
var view = document.getElementById('namebnd'+i);
view.innerHTML = String.format(namebnd);
}
// SINR / ECIO (shared element, hidden when unavailable)
updateSignalBar(
'sinr', i, modem.sinr, ' dB',
netmode === 'LTE' ? 'sinr' : 'ecio',
netmode === 'LTE' ? -20 : -24
);
if (document.getElementById('chname'+i)){
var view = document.getElementById('chname'+i);
view.innerHTML = String.format(namech);
}
// RSRP (hidden when unavailable)
updateSignalBar('rsrp', i, modem.rsrp, ' dBm', 'rsrp', -140);
if (document.getElementById('namecid'+i)){
var view = document.getElementById('namecid'+i);
view.innerHTML = String.format(namecid);
// RSRQ (hidden when unavailable)
updateSignalBar('rsrq', i, modem.rsrq, ' dB', 'rsrq', -20);
}
if (document.getElementById('arfcn'+i)){
var view = document.getElementById('arfcn'+i);
view.innerHTML = String.format(arfcn);
}
if (document.getElementById('lac'+i)){
var view = document.getElementById('lac'+i);
view.innerHTML = String.format(lactac);
}
if (document.getElementById('snrname'+i)) {
var view = document.getElementById('snrname'+i);
view.innerHTML = String.format(namesnr);
}
if (document.getElementById('rssi'+i)) {
var view = document.getElementById('rssi'+i);
if (json.modem[i].rssi == '') {
view.textContent = '--';
} else {
updateProgressBar('rssi', json.modem[i].rssi + ' dBm', -110, i);
}
}
if (document.getElementById('sinr'+i)) {
var sinrElement = document.getElementById('sinr'+i);
var sinrParent = sinrElement ? sinrElement.parentElement : null;
if (!json.modem[i].sinr || json.modem[i].sinr === "--" || json.modem[i].sinr === "") {
if (sinrParent && sinrParent.parentElement) {
sinrParent.parentElement.style.display = 'none';
}
} else {
if (sinrParent && sinrParent.parentElement) {
sinrParent.parentElement.style.display = '';
}
if (netmode == "LTE") {
updateProgressBar('sinr', json.modem[i].sinr + ' dB', -20, i);
} else {
updateProgressBar('ecio', json.modem[i].sinr + ' dB', -24, i);
}
}
}
if (document.getElementById('rsrp'+i)) {
var rsrpElement = document.getElementById('rsrp'+i);
var rsrpParent = rsrpElement ? rsrpElement.parentElement : null;
if (!json.modem[i].rsrp || json.modem[i].rsrp === "--" || json.modem[i].rsrp === "") {
if (rsrpParent && rsrpParent.parentElement) {
rsrpParent.parentElement.style.display = 'none';
}
} else {
if (rsrpParent && rsrpParent.parentElement) {
rsrpParent.parentElement.style.display = '';
}
updateProgressBar('rsrp', json.modem[i].rsrp + ' dBm', -140, i);
}
}
if (document.getElementById('rsrq'+i)) {
var rsrqElement = document.getElementById('rsrq'+i);
var rsrqParent = rsrqElement ? rsrqElement.parentElement : null;
if (!json.modem[i].rsrq || json.modem[i].rsrq === "--" || json.modem[i].rsrq === "") {
if (rsrqParent && rsrqParent.parentElement) {
rsrqParent.parentElement.style.display = 'none';
}
} else {
if (rsrqParent && rsrqParent.parentElement) {
rsrqParent.parentElement.style.display = '';
}
updateProgressBar('rsrq', json.modem[i].rsrq + ' dB', -20, i);
}
}
};
});
});
}),
render: function(data){
var m, s, o;
render: function(data) {
let json;
try {
json = JSON.parse(data);
} catch (e) {
json = { modem: [] };
}
m = new form.Map('modeminfo', _('Modeminfo: Network'), _('Cellular network'));
s = m.section(form.TypedSection, 'general', null);
var json = JSON.parse(data);
for (var i = 0; i < json.modem.length; i++) {
let status = 'status'+i;
let mode = 'mode'+i;
let namebnd = 'namebnd'+i;
let chname = 'chname'+i;
let namecid = 'namecid'+i;
let arfcn = 'arfcn'+i;
let lac = 'lac'+i;
let rssi = 'rssi'+i;
let sinr = 'sinr'+i;
let snrname = 'snrname'+i;
let rsrp = 'rsrp'+i;
let rsrq = 'rsrq'+i;
let m = i+1;
if ( json.modem.length > 1 ) {
s.tab("modem" + i, _('Modem')+' '+m);
o = s.taboption('modem'+i, form.HiddenValue, 'generic');
const m = new form.Map('modeminfo', _('Modeminfo: Network'), _('Cellular network'));
const s = m.section(form.TypedSection, 'general', null);
s.anonymous = true;
for (let i = 0; i < json.modem.length; i++) {
const idx = i + 1;
const statusId = 'status' + i;
const modeId = 'mode' + i;
const namebndId= 'namebnd'+ i;
const chnameId = 'chname' + i;
const namecidId= 'namecid'+ i;
const arfcnId = 'arfcn' + i;
const lacId = 'lac' + i;
const rssiId = 'rssi' + i;
const sinrId = 'sinr' + i;
const snrnameId= 'snrname'+ i;
const rsrpId = 'rsrp' + i;
const rsrqId = 'rsrq' + i;
let o;
if (json.modem.length > 1) {
s.tab('modem' + i, _('Modem') + ' ' + idx);
o = s.taboption('modem' + i, form.HiddenValue, 'generic');
} else {
o = s.option(form.HiddenValue, 'generic');
}
o.render = L.bind(function(data){
return (
E('div', {}, [
E('h3', { 'class': 'data-tab' }),
E('div', { 'class': 'cbi-section', 'data-title': 'modem'+i }, [
E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr cbi-rowstyle-2' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [ _('Operator')]),
E('td', { 'class': 'td left', 'id': status }, [ '--' ]),
]),
E('tr', { 'class': 'tr cbi-rowstyle-1' }, [
E('td', { 'class': 'td left', 'width': '50%', 'id': namebnd }, [ _('Network/band')]),
E('td', { 'class': 'td left', 'id': mode }, [ '--' ]),
]),
E('tr', { 'class': 'tr cbi-rowstyle-2' }, [
E('td', { 'class': 'td left', 'width': '50%', 'id': chname }, [ _('E/U/ARFCN')]),
E('td', { 'class': 'td left', 'id': arfcn }, [ '--' ]),
]),
E('tr', { 'class': 'tr cbi-rowstyle-1' }, [
E('td', { 'class': 'td left', 'width': '50%', 'id': namecid }, [ _('LAC/CID')]),
E('td', { 'class': 'td left', 'id': lac }, [ '--' ]),
]),
E('tr', { 'class': 'tr cbi-rowstyle-2' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [ _('RSSI')]),
E('td', { 'class': 'td left'}, E('div', {'id': rssi, 'class': 'cbi-progressbar', 'title': '--' }, E('div'))),
]),
E('tr', { 'class': 'tr cbi-rowstyle-1' }, [
E('td', { 'class': 'td left', 'width': '50%', 'id': snrname }, [ _('SINR/EcIO')]),
E('td', { 'class': 'td left'}, E('div', {'id': sinr, 'class': 'cbi-progressbar', 'title': '--'}, E('div'))),
]),
E('tr', { 'class': 'tr cbi-rowstyle-2' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [ _('RSRP')]),
E('td', { 'class': 'td left'}, E('div', {'id': rsrp, 'class': 'cbi-progressbar', 'title': '--'}, E('div'))),
]),
E('tr', { 'class': 'tr cbi-rowstyle-1' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [ _('RSRQ')]),
E('td', { 'class': 'td left'}, E('div', {'id': rsrq, 'class': 'cbi-progressbar', 'title': '--'}, E('div'))),
])
])
o.render = L.bind(function() {
return E('div', {}, [
E('h3', { 'class': 'data-tab' }),
E('div', { 'class': 'cbi-section' }, [
E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr cbi-rowstyle-2' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [_('Operator')]),
E('td', { 'class': 'td left', 'id': statusId }, ['--']),
]),
E('tr', { 'class': 'tr cbi-rowstyle-1' }, [
E('td', { 'class': 'td left', 'width': '50%', 'id': namebndId }, [_('Network/Band')]),
E('td', { 'class': 'td left', 'id': modeId }, ['--']),
]),
E('tr', { 'class': 'tr cbi-rowstyle-2' }, [
E('td', { 'class': 'td left', 'width': '50%', 'id': chnameId }, [_('E/U/ARFCN')]),
E('td', { 'class': 'td left', 'id': arfcnId }, ['--']),
]),
E('tr', { 'class': 'tr cbi-rowstyle-1' }, [
E('td', { 'class': 'td left', 'width': '50%', 'id': namecidId }, [_('LAC/CID')]),
E('td', { 'class': 'td left', 'id': lacId }, ['--']),
]),
E('tr', { 'class': 'tr cbi-rowstyle-2' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [_('RSSI')]),
E('td', { 'class': 'td left' },
E('div', { 'id': rssiId, 'class': 'cbi-progressbar', 'title': '--' }, E('div'))
),
]),
E('tr', { 'class': 'tr cbi-rowstyle-1' }, [
E('td', { 'class': 'td left', 'width': '50%', 'id': snrnameId }, [_('SINR/EcIO')]),
E('td', { 'class': 'td left' },
E('div', { 'id': sinrId, 'class': 'cbi-progressbar', 'title': '--' }, E('div'))
),
]),
E('tr', { 'class': 'tr cbi-rowstyle-2' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [_('RSRP')]),
E('td', { 'class': 'td left' },
E('div', { 'id': rsrpId, 'class': 'cbi-progressbar', 'title': '--' }, E('div'))
),
]),
E('tr', { 'class': 'tr cbi-rowstyle-1' }, [
E('td', { 'class': 'td left', 'width': '50%' }, [_('RSRQ')]),
E('td', { 'class': 'td left' },
E('div', { 'id': rsrqId, 'class': 'cbi-progressbar', 'title': '--' }, E('div'))
),
]),
])
])
)
]);
}, this.polldata);
o.anonymous = true;
o.rmempty = true;
};
s.anonymous = true;
return m.render();
o.anonymous = true;
o.rmempty = true;
}
return m.render();
},
handleSaveApply: null,
handleSave: null,
handleReset: null
handleSave: null,
handleReset: null,
});