🎨 Sync 2026-10-12 21:43:19

This commit is contained in:
github-actions[bot] committed 2026-10-12 21:43:19 +08:00
1 parent 3a82c347ab
commit bfe30ffbef
51 files changed
+8523 -149

No files matched your search

@@ -0,0 +1,96 @@
'use strict';
'require view';
'require rpc';
'require poll';
'require ui';
'require dom';
const callGetLog = rpc.declare({
object: 'luci.modem',
method: 'get_log',
expect: { log: '' }
});
const callClearLog = rpc.declare({
object: 'luci.modem',
method: 'clear_log'
});
return view.extend({
handleSaveApply: null,
handleSave: null,
handleReset: null,
load: function() {
return L.resolveDefault(callGetLog(), '');
},
handleClearLog: function() {
const self = this;
callClearLog().then(function() {
const logArea = document.getElementById('modem_log_content');
if (logArea) logArea.value = '';
ui.addNotification(null, E('p', {}, _('Log buffer cleared successfully.')), 'info');
});
},
updateLog: function() {
return callGetLog().then(function(res) {
const logArea = document.getElementById('modem_log_content');
if (logArea) {
let logText = (typeof res === 'string') ? res : ((res && res.log) ? res.log : '');
logArea.value = logText || _('No log entries recorded yet.');
logArea.scrollTop = logArea.scrollHeight;
}
}).catch(function() {});
},
render: function(logData) {
const self = this;
let initialLog = (typeof logData === 'string') ? logData : ((logData && logData.log) ? logData.log : '');
if (!initialLog) initialLog = _('No log entries recorded yet.');
const container = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, _('Cellular Modem Manager') + ' - ' + _('System Log')),
E('div', { 'class': 'cbi-map-descr' }, _('View background AT command traces, hardware detection events, and auto-reconnect activities.'))
]);
const logSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', { 'style': 'display: flex; justify-content: space-between; align-items: center; width: 100%; box-sizing: border-box;' }, [
E('span', {}, _('Modem Manager Logs')),
E('span', { 'style': 'display: inline-flex; gap: 8px; align-items: center; margin-left: auto;' }, [
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-reload',
'value': _('Refresh'),
'style': 'font-size: 11px;',
'click': ui.createHandlerFn(self, 'updateLog')
}),
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-reset',
'value': _('Clear Log'),
'style': 'font-size: 11px;',
'click': ui.createHandlerFn(self, 'handleClearLog')
})
])
]),
E('div', { 'class': 'cbi-section-node' }, [
E('textarea', {
'id': 'modem_log_content',
'class': 'cbi-input-textarea',
'style': 'width: 100%; min-height: 420px; font-family: monospace; font-size: 12px; background: #fafafa; border: 1px solid #ccc; box-sizing: border-box; padding: 10px;',
'readonly': 'readonly',
'wrap': 'off'
}, [ initialLog ])
])
]);
container.appendChild(logSection);
poll.add(function() {
return self.updateLog();
}, 5);
return container;
}
});
@@ -0,0 +1,430 @@
'use strict';
'require view';
'require rpc';
'require poll';
'require ui';
'require dom';
const callGetDevices = rpc.declare({
object: 'luci.modem',
method: 'get_devices',
expect: { devices: [] }
});
const callGetStatus = rpc.declare({
object: 'luci.modem',
method: 'get_status',
params: [ 'port', 'primary_net' ]
});
const callRefreshStatus = rpc.declare({
object: 'luci.modem',
method: 'refresh_status',
params: [ 'port', 'primary_net' ]
});
const callDeviceAction = rpc.declare({
object: 'luci.modem',
method: 'device_action',
params: [ 'port', 'action' ]
});
return view.extend({
handleSaveApply: null,
handleSave: null,
handleReset: null,
selectedPort: null,
selectedNet: null,
devicesList: [],
load: function() {
return L.resolveDefault(callGetDevices(), []);
},
renderProgressBar: function(percent, label, color) {
const bg = color || '#2bc1a4';
return E('div', { 'style': 'width: 220px; background: #eee; border-radius: 3px; height: 16px; position: relative; display: inline-block; vertical-align: middle; margin-right: 10px; overflow: hidden;' }, [
E('div', {
'style': 'height: 100%; width: ' + Math.min(100, Math.max(0, percent)) + '%; background: ' + bg + '; transition: width 0.3s;'
}),
E('span', {
'style': 'position: absolute; width: 100%; top: 0; left: 0; text-align: center; font-size: 11px; line-height: 16px; color: #333; font-weight: bold;'
}, label)
]);
},
handleAction: function(action) {
const self = this;
if (!self.selectedPort) {
ui.addNotification(null, E('p', {}, _('Please select a valid modem port first.')), 'error');
return;
}
ui.showModal(_('Executing Action'), [
E('p', { 'class': 'spinning' }, _('Sending action to cellular module, please wait...'))
]);
callDeviceAction(self.selectedPort, action).then(function(res) {
ui.hideModal();
if (res && res.success) {
ui.addNotification(null, E('p', {}, _('Action executed successfully.')), 'info');
self.forceRefresh();
} else {
ui.addNotification(null, E('p', {}, _('Action failed: ') + (res ? res.output : _('Unknown error'))), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, err.message), 'error');
});
},
forceRefresh: function() {
const self = this;
if (!self.selectedPort) return;
return callRefreshStatus(self.selectedPort, self.selectedNet).then(function(res) {
self.applyStatusDOM(res);
});
},
updateStatus: function() {
const self = this;
if (!self.selectedPort)
return Promise.resolve();
return callGetStatus(self.selectedPort, self.selectedNet).then(function(res) {
self.applyStatusDOM(res);
}).catch(function(e) {});
},
formatBytes: function(bytes) {
if (bytes === null || bytes === undefined || isNaN(bytes) || bytes < 0) return '0 B';
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
const val = (bytes / Math.pow(k, i)).toFixed(i === 0 ? 0 : 2);
return val + ' ' + units[i];
},
formatSpeed: function(bytesPerSec) {
if (bytesPerSec === null || bytesPerSec === undefined || isNaN(bytesPerSec) || bytesPerSec < 0) return '0 B/s';
return this.formatBytes(bytesPerSec) + '/s';
},
formatUptime: function(sec) {
if (!sec || sec <= 0) return '-';
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60);
let str = (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
if (d > 0) return d + ' ' + _('days') + ' ' + str;
return str;
},
applyStatusDOM: function(status) {
const self = this;
if (!status) return;
const csq = status.csq || {};
const cell = status.cell || {};
const net = status.network || {};
const tTemp = document.getElementById('modem_temp_val');
if (tTemp) dom.content(tTemp, (status.temperature !== null && status.temperature !== undefined) ? (status.temperature + ' °C') : _('Not Supported'));
const tOp = document.getElementById('modem_operator_val');
if (tOp) dom.content(tOp, status.operator || _('Searching'));
const tRat = document.getElementById('modem_rat_val');
if (tRat) dom.content(tRat, status.act || _('Unknown'));
const tBand = document.getElementById('modem_band_val');
if (tBand) dom.content(tBand, cell.band || _('None'));
const tFw = document.getElementById('modem_firmware_val');
if (tFw && status.firmware) dom.content(tFw, status.firmware);
const tImei = document.getElementById('modem_imei_val');
if (tImei && status.imei) dom.content(tImei, status.imei);
const tSim = document.getElementById('modem_sim_val');
if (tSim) {
let sText = _('Normal');
let sColor = 'green';
if (status.sim_status === 'PIN_LOCKED') { sText = _('PIN Locked'); sColor = 'orange'; }
else if (status.sim_status === 'PUK_LOCKED') { sText = _('PUK Locked'); sColor = 'red'; }
else if (status.sim_status === 'NOT_INSERTED') { sText = _('Not Detected'); sColor = 'red'; }
dom.content(tSim, E('span', { 'style': 'color: ' + sColor + '; font-weight: bold;' }, sText));
}
const sigCsq = document.getElementById('modem_sig_csq');
if (sigCsq) {
dom.content(sigCsq, [
self.renderProgressBar(csq.percent || 0, (csq.csq || 0) + ' / 31 (' + (csq.percent || 0) + '%)', '#2bc1a4'),
E('span', {}, (csq.rssi_dbm ? csq.rssi_dbm + ' dBm' : ''))
]);
}
const sigRsrp = document.getElementById('modem_sig_rsrp');
if (sigRsrp) {
let rsrpVal = (cell.rsrp !== null && cell.rsrp !== undefined && cell.rsrp < 0 && cell.rsrp >= -145) ? cell.rsrp : null;
let rsrpPercent = rsrpVal ? Math.max(0, Math.min(100, Math.floor((rsrpVal + 140) * (100 / 96)))) : 0;
let qualityText = '';
if (rsrpVal !== null) {
if (rsrpVal >= -85) qualityText = _('Excellent');
else if (rsrpVal >= -100) qualityText = _('Good');
else qualityText = _('Weak');
}
dom.content(sigRsrp, [
self.renderProgressBar(rsrpPercent, rsrpVal ? (rsrpVal + ' dBm') : _('Calculating'), '#4895ef'),
E('span', {}, qualityText)
]);
}
const sigSinr = document.getElementById('modem_sig_sinr');
if (sigSinr) {
let sinrVal = (cell.sinr !== null && cell.sinr !== undefined) ? cell.sinr : null;
let sinrPercent = sinrVal ? Math.max(0, Math.min(100, Math.floor((sinrVal + 10) * 2.5))) : 0;
dom.content(sigSinr, [
self.renderProgressBar(sinrPercent, sinrVal ? (sinrVal + ' dB') : _('Calculating'), '#4cc9f0')
]);
}
const cellPci = document.getElementById('modem_cell_pci');
if (cellPci) dom.content(cellPci, cell.pci || '-');
const cellId = document.getElementById('modem_cell_id');
if (cellId) dom.content(cellId, cell.cell_id || '-');
const cellTac = document.getElementById('modem_cell_tac');
if (cellTac) dom.content(cellTac, cell.tac || '-');
const cellArfcn = document.getElementById('modem_cell_arfcn');
if (cellArfcn) dom.content(cellArfcn, cell.earfcn || '-');
const netStatus = document.getElementById('modem_net_status');
if (netStatus) {
let isConn = !!net.connected;
let netText = isConn ? _('Connected') : _('Disconnected');
let netColor = isConn ? '#4caf50' : '#f44336';
let uptimeStr = (isConn && net.uptime) ? self.formatUptime(net.uptime) : '';
dom.content(netStatus, [
E('span', {
'style': 'background-color: ' + netColor + '; color: #fff; padding: 2px 8px; border-radius: 3px; font-weight: bold; font-size: 11px; margin-right: 8px;'
}, netText),
uptimeStr ? E('span', { 'style': 'color: #555; font-size: 12px;' }, _('Uptime: ') + uptimeStr) : ''
]);
}
const netPhys = document.getElementById('modem_net_phys');
if (netPhys) {
let devName = net.device || self.selectedNet || _('Not Registered');
dom.content(netPhys, E('span', { 'style': 'font-weight: bold; font-family: monospace; color: #0366d6;' }, devName));
}
const netIface = document.getElementById('modem_net_iface');
if (netIface) {
let ifaceName = net.interface || _('Unmanaged (Config in OpenWrt Interfaces)');
let protoStr = net.proto ? ' [' + net.proto.toUpperCase() + ']' : '';
dom.content(netIface, ifaceName + protoStr);
}
const netIp4 = document.getElementById('modem_net_ip4');
if (netIp4) dom.content(netIp4, (net.ip4 && net.ip4.length > 0) ? net.ip4.join(', ') : '-');
const netIp6 = document.getElementById('modem_net_ip6');
if (netIp6) dom.content(netIp6, (net.ip6 && net.ip6.length > 0) ? net.ip6.join(', ') : '-');
const netTraffic = document.getElementById('modem_net_traffic');
if (netTraffic) {
const rxFormatted = self.formatBytes(net.rx_bytes || 0);
const txFormatted = self.formatBytes(net.tx_bytes || 0);
const totalFormatted = self.formatBytes((net.rx_bytes || 0) + (net.tx_bytes || 0));
const rxPackets = net.rx_packets || 0;
const txPackets = net.tx_packets || 0;
dom.content(netTraffic, [
E('div', { 'style': 'display: flex; gap: 24px; align-items: center; white-space: nowrap;' }, [
E('span', {}, [
E('strong', {}, _('Download') + ': '),
rxFormatted + (rxPackets > 0 ? ' / ' + rxPackets + ' ' + _('Packets') : '')
]),
E('span', {}, [
E('strong', {}, _('Upload') + ': '),
txFormatted + (txPackets > 0 ? ' / ' + txPackets + ' ' + _('Packets') : '')
]),
E('span', { 'style': 'color: #333;' }, [
E('strong', {}, _('Total Traffic') + ': '),
totalFormatted
])
])
]);
}
},
render: function(devicesData) {
const self = this;
self.devicesList = Array.isArray(devicesData) ? devicesData : ((devicesData && Array.isArray(devicesData.devices)) ? devicesData.devices : []);
const container = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, _('Cellular Modem Manager') + ' - ' + _('Overview')),
E('div', { 'class': 'cbi-map-descr' }, _('Real-time overview of cellular modem hardware, network registration, signal metrics, and data connection.'))
]);
if (self.devicesList.length === 0) {
container.appendChild(E('div', { 'class': 'alert-message warning', 'style': 'margin-top: 20px; padding: 20px; border-left: 5px solid #ff9800; background: #fff8e1;' }, [
E('h4', { 'style': 'margin: 0 0 10px 0; color: #d32f2f; font-size: 16px;' }, _('No Active Cellular Modem Detected')),
E('p', { 'style': 'margin: 0 0 15px 0; line-height: 1.6; color: #555;' }, _('No active 4G or 5G cellular modem detected on the USB bus. Please verify hardware connection, power supply, or driver status.')),
E('div', {}, [
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-action',
'value': _('Rescan Devices'),
'click': function() {
location.reload();
}
})
])
]));
return container;
}
self.selectedPort = self.devicesList[0].primary_port;
self.selectedNet = self.devicesList[0].primary_net;
const firstDev = self.devicesList[0] || {};
const hwSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Hardware Information')),
E('table', { 'class': 'table cbi-section-table' }, [
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'width': '16%' }, _('Manufacturer')),
E('td', { 'class': 'td left', 'width': '34%' }, firstDev.vendor || '-'),
E('td', { 'class': 'td left', 'width': '16%' }, _('Model')),
E('td', { 'class': 'td left', 'width': '34%' }, firstDev.model || '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Firmware Version')),
E('td', { 'class': 'td left', 'id': 'modem_firmware_val' }, '-'),
E('td', { 'class': 'td left' }, _('IMEI')),
E('td', { 'class': 'td left', 'id': 'modem_imei_val' }, '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('USB Identifiers')),
E('td', { 'class': 'td left' }, (firstDev.vid && firstDev.pid) ? (firstDev.vid + ':' + firstDev.pid) : '-'),
E('td', { 'class': 'td left' }, _('Core Temperature')),
E('td', { 'class': 'td left', 'id': 'modem_temp_val' }, '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Driver Binding')),
E('td', { 'class': 'td left' }, firstDev.driver || _('Option Driver')),
E('td', { 'class': 'td left' }, _('SIM Status')),
E('td', { 'class': 'td left', 'id': 'modem_sim_val' }, '-')
])
])
]);
container.appendChild(hwSection);
const sigSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Cellular Network and Signal Metrics')),
E('table', { 'class': 'table cbi-section-table' }, [
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'width': '16%' }, _('Operator')),
E('td', { 'class': 'td left', 'width': '34%', 'id': 'modem_operator_val' }, '-'),
E('td', { 'class': 'td left', 'width': '16%' }, _('Access Technology')),
E('td', { 'class': 'td left', 'width': '34%', 'id': 'modem_rat_val' }, '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Active Band')),
E('td', { 'class': 'td left', 'id': 'modem_band_val' }, '-'),
E('td', { 'class': 'td left' }, _('Physical Cell ID')),
E('td', { 'class': 'td left', 'id': 'modem_cell_pci' }, '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Cell ID')),
E('td', { 'class': 'td left', 'id': 'modem_cell_id' }, '-'),
E('td', { 'class': 'td left' }, _('Tracking Area Code')),
E('td', { 'class': 'td left', 'id': 'modem_cell_tac' }, '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Carrier Channel')),
E('td', { 'class': 'td left', 'id': 'modem_cell_arfcn' }, '-'),
E('td', { 'class': 'td left' }, _('Signal Strength CSQ')),
E('td', { 'class': 'td left', 'id': 'modem_sig_csq' }, '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Signal Power RSRP')),
E('td', { 'class': 'td left', 'id': 'modem_sig_rsrp' }, '-'),
E('td', { 'class': 'td left' }, _('Signal Quality SINR')),
E('td', { 'class': 'td left', 'id': 'modem_sig_sinr' }, '-')
])
])
]);
container.appendChild(sigSection);
const connSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Network Interface Connection')),
E('table', { 'class': 'table cbi-section-table' }, [
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'width': '16%' }, _('Connection State')),
E('td', { 'class': 'td left', 'width': '34%', 'id': 'modem_net_status' }, '-'),
E('td', { 'class': 'td left', 'width': '16%' }, _('Physical & Logical Interface')),
E('td', { 'class': 'td left', 'width': '34%' }, [
E('div', {}, [
E('span', { 'style': 'color: #333; font-weight: bold; margin-right: 4px;' }, _('Physical:')),
E('span', { 'id': 'modem_net_phys' }, '-'),
E('span', { 'style': 'color: #888; margin: 0 8px;' }, '|'),
E('span', { 'style': 'color: #333; font-weight: bold; margin-right: 4px;' }, _('Logical:')),
E('span', { 'id': 'modem_net_iface' }, '-')
])
])
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Modem IPv4 Address')),
E('td', { 'class': 'td left', 'id': 'modem_net_ip4' }, '-'),
E('td', { 'class': 'td left' }, _('Modem IPv6 Address')),
E('td', { 'class': 'td left', 'id': 'modem_net_ip6' }, '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Traffic Statistics')),
E('td', { 'class': 'td left', 'colspan': '3', 'id': 'modem_net_traffic' }, '-')
])
]),
E('div', { 'style': 'margin-top: 15px; display: flex; gap: 10px;' }, [
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-reload',
'value': _('Refresh'),
'click': ui.createHandlerFn(self, 'forceRefresh')
}),
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-reset',
'value': _('Restart Modem'),
'click': ui.createHandlerFn(self, 'handleAction', 'reboot')
}),
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-action',
'value': _('Toggle Airplane Mode'),
'click': ui.createHandlerFn(self, 'handleAction', 'airplane_on')
}),
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-reload',
'value': _('Reset RF Radio'),
'click': ui.createHandlerFn(self, 'handleAction', 'reset_radio')
})
])
]);
container.appendChild(connSection);
poll.add(function() {
return self.updateStatus();
}, 5);
setTimeout(function() {
self.updateStatus();
}, 200);
return container;
}
});
@@ -0,0 +1,565 @@
'use strict';
'require view';
'require rpc';
'require ui';
const callGetDevices = rpc.declare({
object: 'luci.modem',
method: 'get_devices',
expect: { devices: [] }
});
const callGetConfig = rpc.declare({
object: 'luci.modem',
method: 'get_config',
expect: { port: '', baud: '115200' }
});
const callSaveConfig = rpc.declare({
object: 'luci.modem',
method: 'save_config',
params: [ 'port', 'baud' ]
});
const callSetMode = rpc.declare({
object: 'luci.modem',
method: 'set_mode',
params: [ 'port', 'mode' ]
});
const callSetPreferences = rpc.declare({
object: 'luci.modem',
method: 'set_preferences',
params: [ 'port', 'rat', 'bands_lte', 'bands_nr5g' ]
});
const callSetApn = rpc.declare({
object: 'luci.modem',
method: 'set_apn',
params: [ 'port', 'apn', 'pdp_type' ]
});
const callGetApn = rpc.declare({
object: 'luci.modem',
method: 'get_apn',
params: [ 'port' ]
});
const callGetCellLock = rpc.declare({
object: 'luci.modem',
method: 'get_cell_lock',
params: [ 'port' ]
});
const callSetCellLock = rpc.declare({
object: 'luci.modem',
method: 'set_cell_lock',
params: [ 'port', 'mode', 'earfcn', 'pci' ]
});
const callGetStatus = rpc.declare({
object: 'luci.modem',
method: 'get_status',
params: [ 'port', 'primary_net' ]
});
return view.extend({
handleSaveApply: null,
handleSave: null,
handleReset: null,
selectedPort: null,
selectedNet: null,
devicesList: [],
configData: {},
cellLockData: {},
apnData: {},
load: function() {
const self = this;
return Promise.all([
L.resolveDefault(callGetDevices(), []),
L.resolveDefault(callGetConfig(), {})
]).then(function(results) {
const devs = results[0];
const cfg = results[1] || {};
const list = Array.isArray(devs) ? devs : ((devs && Array.isArray(devs.devices)) ? devs.devices : []);
const port = cfg.port || (list.length > 0 ? list[0].primary_port : null);
if (port) {
return Promise.all([
L.resolveDefault(callGetCellLock(port), {}),
L.resolveDefault(callGetApn(port), {})
]).then(function(extraResults) {
return [ devs, cfg, extraResults[0] || {}, extraResults[1] || {} ];
});
}
return [ devs, cfg, {}, {} ];
});
},
handleApplySettings: function() {
const self = this;
const portSelect = document.getElementById('modem_port_select');
const customPortInput = document.getElementById('modem_custom_port');
let targetPort = (portSelect && portSelect.value === 'custom') ? (customPortInput ? customPortInput.value.trim() : '') : (portSelect ? portSelect.value : '');
if (!targetPort) {
targetPort = self.selectedPort;
}
if (!targetPort) {
ui.addNotification(null, E('p', {}, _('Please specify or select an active AT communication port.')), 'error');
return;
}
const baudVal = document.getElementById('modem_baud_select').value || '115200';
const modeEl = document.querySelector('input[name="modem_mode"]:checked');
const modeVal = modeEl ? modeEl.value : 'ncm';
const ratEl = document.getElementById('modem_rat_select');
const ratVal = ratEl ? ratEl.value : 'AUTO';
const apnEl = document.getElementById('modem_apn');
const apnVal = apnEl ? apnEl.value.trim() : 'cmiot';
const pdpEl = document.getElementById('modem_pdp');
const pdpVal = pdpEl ? pdpEl.value : 'IPV4V6';
const lteEls = document.querySelectorAll('.modem-band-lte:checked');
const nrEls = document.querySelectorAll('.modem-band-nr:checked');
const bandsLte = [];
const bandsNr = [];
for (let i = 0; i < lteEls.length; i++) bandsLte.push(lteEls[i].value);
for (let i = 0; i < nrEls.length; i++) bandsNr.push(nrEls[i].value);
const cellModeEl = document.getElementById('modem_cell_lock_mode');
const cellLockMode = cellModeEl ? cellModeEl.value : 'none';
const cellEarfcnEl = document.getElementById('modem_cell_lock_earfcn');
const cellLockEarfcn = cellEarfcnEl ? cellEarfcnEl.value.trim() : '';
const cellPciEl = document.getElementById('modem_cell_lock_pci');
const cellLockPci = cellPciEl ? cellPciEl.value.trim() : '';
if (cellLockMode !== 'none') {
if (!cellLockEarfcn || !cellLockPci) {
ui.addNotification(null, E('p', {}, _('Both carrier frequency and physical cell ID are required for cell locking.')), 'error');
return;
}
if (!/^[0-9]+$/.test(cellLockEarfcn) || !/^[0-9]+$/.test(cellLockPci)) {
ui.addNotification(null, E('p', {}, _('Carrier frequency and physical cell ID must be valid positive integers.')), 'error');
return;
}
}
ui.showModal(_('Applying Configuration'), [
E('p', { 'class': 'spinning' }, _('Saving port settings and sending configuration commands, please wait...'))
]);
callSaveConfig(targetPort, baudVal)
.then(function() {
return callSetMode(targetPort, modeVal);
})
.then(function() {
return callSetPreferences(targetPort, ratVal, bandsLte, bandsNr);
})
.then(function() {
return callSetApn(targetPort, apnVal, pdpVal);
})
.then(function() {
return callSetCellLock(targetPort, cellLockMode, cellLockEarfcn, cellLockPci);
})
.then(function(lockRes) {
ui.hideModal();
if (lockRes && lockRes.success === false) {
ui.addNotification(null, E('p', {}, _('Settings saved, but cell lock failed: ') + (lockRes.error || lockRes.output || '')), 'warning');
} else {
ui.addNotification(null, E('p', {}, _('Modem settings successfully applied.')), 'info');
}
})
.catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Failed to apply settings: ') + err.message), 'error');
});
},
render: function(loadData) {
const self = this;
const devicesData = loadData ? loadData[0] : null;
self.configData = loadData ? loadData[1] : {};
self.cellLockData = loadData ? loadData[2] : {};
self.apnData = loadData ? loadData[3] : {};
self.devicesList = Array.isArray(devicesData) ? devicesData : ((devicesData && Array.isArray(devicesData.devices)) ? devicesData.devices : []);
if (self.devicesList.length > 0) {
self.selectedPort = self.configData.port || self.devicesList[0].primary_port;
self.selectedNet = self.devicesList[0].primary_net;
}
const deviceOptions = [];
self.devicesList.forEach(function(dev, idx) {
let label = dev.vendor + ' ' + dev.model + ' (' + (dev.primary_port || _('No Port')) + ')';
const optAttrs = { value: dev.primary_port, 'data-net': dev.primary_net };
if (idx === 0) optAttrs.selected = '';
deviceOptions.push(E('option', optAttrs, label));
});
const container = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, _('Cellular Modem Manager') + ' - ' + _('Settings')),
E('div', { 'class': 'cbi-map-descr' }, _('Configure driver protocols, lock preferred access technologies, customize APN credentials, and manage cellular network interfaces.'))
]);
if (self.devicesList.length === 0) {
container.appendChild(E('div', { 'class': 'alert-message warning', 'style': 'margin-top: 15px; padding: 15px; border-left: 5px solid #ff9800; background: #fff8e1;' }, [
E('h4', { 'style': 'margin: 0 0 8px 0; color: #d32f2f;' }, _('No Cellular Modem Detected')),
E('p', { 'style': 'margin: 0; color: #555;' }, _('No active cellular modem detected. You can still pre-configure serial port path or refresh after connecting.'))
]));
}
// Target Modem Selector
const selectorNode = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Target Modem Selection')),
E('div', { 'class': 'cbi-section-node' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Target Modem')),
E('div', { 'class': 'cbi-value-field' }, [
deviceOptions.length > 0 ? E('select', {
'id': 'modem_device_select',
'class': 'cbi-input-select',
'style': 'min-width: 320px;',
'change': function(ev) {
self.selectedPort = ev.target.value;
let opt = ev.target.options[ev.target.selectedIndex];
self.selectedNet = opt ? opt.getAttribute('data-net') : null;
}
}, deviceOptions) : E('span', { 'style': 'color: red;' }, _('No cellular modems detected.')),
E('div', { 'class': 'cbi-value-description' }, _('Select the active cellular module to configure.'))
])
])
])
]);
container.appendChild(selectorNode);
// Serial Port Customization Section
const dev = self.devicesList[0] || {};
const availablePorts = dev.ports || [];
const activeConfigPort = self.configData.port || dev.primary_port || '';
const portSelectOptions = [];
let matchedInList = false;
availablePorts.forEach(function(p) {
let isSelected = (p === activeConfigPort);
if (isSelected) matchedInList = true;
let isAuto = (p === dev.primary_port);
let optLabel = p + (isAuto ? ' [' + _('Auto Recommended') + ']' : '');
const optAttrs = { value: p };
if (isSelected) optAttrs.selected = '';
portSelectOptions.push(E('option', optAttrs, optLabel));
});
const customOptAttrs = { value: 'custom' };
if (!matchedInList && activeConfigPort !== '') customOptAttrs.selected = '';
portSelectOptions.push(E('option', customOptAttrs, _('Manual Custom Path')));
const portSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Serial Port and Communication Configuration')),
E('div', { 'class': 'cbi-section-node' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Primary AT Port')),
E('div', { 'class': 'cbi-value-field' }, [
E('select', {
'id': 'modem_port_select',
'class': 'cbi-input-select',
'style': 'min-width: 280px;',
'change': function(ev) {
const customWrap = document.getElementById('modem_custom_port_wrap');
if (customWrap) {
customWrap.style.display = (ev.target.value === 'custom') ? 'block' : 'none';
}
}
}, portSelectOptions),
E('div', {
'id': 'modem_custom_port_wrap',
'style': 'margin-top: 8px; display: ' + ((!matchedInList && activeConfigPort !== '') ? 'block' : 'none') + ';'
}, [
E('input', {
'type': 'text',
'id': 'modem_custom_port',
'class': 'cbi-input-text',
'style': 'min-width: 280px;',
'placeholder': '/dev/ttyUSB2 or /dev/modem_at',
'value': (!matchedInList ? activeConfigPort : '')
}),
E('span', { 'style': 'margin-left: 10px; color: #888; font-size: 12px;' }, _('Specify custom node path'))
]),
E('div', { 'class': 'cbi-value-description' }, _('Select or enter the dedicated AT control port for background status polling and control commands.'))
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Serial Baudrate')),
E('div', { 'class': 'cbi-value-field' }, [
E('select', { 'id': 'modem_baud_select', 'class': 'cbi-input-select', 'style': 'min-width: 150px;' },
['9600', '38400', '57600', '115200', '230400', '460800', '921600'].map(function(b) {
const opt = { value: b };
if (b === (self.configData.baud || '115200')) opt.selected = '';
return E('option', opt, b);
})
),
E('div', { 'class': 'cbi-value-description' }, _('Standard cellular modem AT serial baudrate (Default: 115200).'))
])
])
])
]);
container.appendChild(portSection);
// Protocol Mode Switching Section
const modeSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Driver Protocol and Operation Mode')),
E('div', { 'class': 'cbi-section-node' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Driver Mode')),
E('div', { 'class': 'cbi-value-field' }, [
E('label', { 'style': 'margin-right: 20px;' }, [
E('input', { 'type': 'radio', 'name': 'modem_mode', 'value': 'ncm', 'checked': 'checked' }), ' ',
_('NCM Mode')
]),
E('label', { 'style': 'margin-right: 20px;' }, [
E('input', { 'type': 'radio', 'name': 'modem_mode', 'value': 'mbim' }), ' ',
_('MBIM Mode')
]),
E('label', { 'style': 'margin-right: 20px;' }, [
E('input', { 'type': 'radio', 'name': 'modem_mode', 'value': 'qmi' }), ' ',
_('QMI Mode')
]),
E('label', {}, [
E('input', { 'type': 'radio', 'name': 'modem_mode', 'value': 'ecm' }), ' ',
_('ECM Mode')
]),
E('div', { 'class': 'cbi-value-description' }, _('Switching the operation mode requires restarting the modem to take effect.'))
])
])
])
]);
container.appendChild(modeSection);
// Cell Locking Section
const isCellLocked = !!(self.cellLockData && self.cellLockData.locked);
const cellLockType = isCellLocked ? (self.cellLockData.mode || 'nr5g') : 'none';
const cellLockSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Base Station Cell Locking')),
E('div', { 'class': 'cbi-section-node' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Cell Lock Mode')),
E('div', { 'class': 'cbi-value-field' }, [
E('select', {
'id': 'modem_cell_lock_mode',
'class': 'cbi-input-select',
'style': 'min-width: 240px;',
'change': function(ev) {
const val = ev.target.value;
const inputsWrap = document.getElementById('modem_cell_lock_inputs');
if (inputsWrap) {
inputsWrap.style.display = (val === 'none') ? 'none' : 'block';
}
}
}, [
E('option', { 'value': 'none', 'selected': (cellLockType === 'none' ? '' : null) }, _('Automatic Selection')),
E('option', { 'value': 'nr5g', 'selected': (cellLockType === 'nr5g' ? '' : null) }, _('Lock 5G NR Cell')),
E('option', { 'value': 'lte', 'selected': (cellLockType === 'lte' ? '' : null) }, _('Lock 4G LTE Cell'))
]),
E('div', { 'class': 'cbi-value-description' }, _('Lock cellular module to a specific base station cell ID and carrier frequency.'))
])
]),
E('div', { 'id': 'modem_cell_lock_inputs', 'style': 'display: ' + (isCellLocked ? 'block' : 'none') + ';' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Carrier Frequency')),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'modem_cell_lock_earfcn',
'class': 'cbi-input-text',
'style': 'min-width: 240px;',
'placeholder': _('e.g. 627264 or 1850'),
'value': (self.cellLockData.earfcn || '')
}),
E('div', { 'class': 'cbi-value-description' }, _('Target carrier ARFCN or EARFCN.'))
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Physical Cell ID')),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'modem_cell_lock_pci',
'class': 'cbi-input-text',
'style': 'min-width: 240px;',
'placeholder': _('e.g. 468'),
'value': (self.cellLockData.pci || '')
}),
E('div', { 'class': 'cbi-value-description' }, _('Target base station physical cell identifier PCI.'))
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Lock Actions')),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-action',
'value': _('Autofill Current Serving Cell'),
'click': function() {
if (!self.selectedPort) return;
ui.showIndicator('modem_query', _('Querying serving cell...'));
callGetStatus(self.selectedPort, self.selectedNet).then(function(st) {
ui.hideIndicator('modem_query');
if (st && st.cell && st.cell.earfcn && st.cell.pci) {
const cell = st.cell;
const earfcnInput = document.getElementById('modem_cell_lock_earfcn');
const pciInput = document.getElementById('modem_cell_lock_pci');
const modeSelect = document.getElementById('modem_cell_lock_mode');
if (earfcnInput) earfcnInput.value = cell.earfcn;
if (pciInput) pciInput.value = cell.pci;
if (modeSelect) {
modeSelect.value = cell.is_5g ? 'nr5g' : 'lte';
const inputsWrap = document.getElementById('modem_cell_lock_inputs');
if (inputsWrap) inputsWrap.style.display = 'block';
}
ui.addNotification(null, E('p', {}, _('Current cell values populated.')), 'info');
} else {
ui.addNotification(null, E('p', {}, _('No active serving cell details detected.')), 'warning');
}
}).catch(function(e) {
ui.hideIndicator('modem_query');
ui.addNotification(null, E('p', {}, e.message), 'error');
});
}
}),
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-reset',
'style': 'margin-left: 10px;',
'value': _('Clear Lock Immediately'),
'click': function() {
if (!self.selectedPort) return;
ui.showModal(_('Clearing Cell Lock'), [
E('p', { 'class': 'spinning' }, _('Sending unlocking commands...'))
]);
callSetCellLock(self.selectedPort, 'none').then(function(res) {
ui.hideModal();
const modeSelect = document.getElementById('modem_cell_lock_mode');
if (modeSelect) modeSelect.value = 'none';
const inputsWrap = document.getElementById('modem_cell_lock_inputs');
if (inputsWrap) inputsWrap.style.display = 'none';
ui.addNotification(null, E('p', {}, _('Cell lock cleared successfully.')), 'info');
}).catch(function(e) {
ui.hideModal();
ui.addNotification(null, E('p', {}, e.message), 'error');
});
}
})
])
])
])
])
]);
container.appendChild(cellLockSection);
// Network RAT and Band Locking Section
const ratSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Network Access Technology and Band Locking')),
E('div', { 'class': 'cbi-section-node' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Access Technology Preference')),
E('div', { 'class': 'cbi-value-field' }, [
E('select', { 'id': 'modem_rat_select', 'class': 'cbi-input-select' }, [
E('option', { 'value': 'AUTO', 'selected': 'selected' }, _('Automatic Mode')),
E('option', { 'value': 'NR5G' }, _('5G Standalone Only')),
E('option', { 'value': 'NR5G:LTE' }, _('5G and 4G Hybrid')),
E('option', { 'value': 'LTE' }, _('4G LTE Only'))
]),
E('div', { 'class': 'cbi-value-description' }, _('Select priority radio access technology for network registration.'))
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('4G LTE Bands')),
E('div', { 'class': 'cbi-value-field' }, [
E('div', { 'style': 'display: flex; flex-wrap: wrap; gap: 10px; padding-top: 5px;' },
['B1', 'B3', 'B5', 'B8', 'B34', 'B38', 'B39', 'B40', 'B41'].map(function(band) {
return E('label', {
'style': 'display: inline-flex; align-items: center; gap: 4px; cursor: pointer; min-width: 60px;'
}, [
E('input', { 'type': 'checkbox', 'class': 'cbi-input-checkbox modem-band-lte', 'value': band, 'checked': 'checked' }),
E('span', {}, band)
]);
})
)
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('5G NR Bands')),
E('div', { 'class': 'cbi-value-field' }, [
E('div', { 'style': 'display: flex; flex-wrap: wrap; gap: 10px; padding-top: 5px;' },
['N1', 'N28', 'N41', 'N77', 'N78', 'N79'].map(function(band) {
return E('label', {
'style': 'display: inline-flex; align-items: center; gap: 4px; cursor: pointer; min-width: 60px;'
}, [
E('input', { 'type': 'checkbox', 'class': 'cbi-input-checkbox modem-band-nr', 'value': band, 'checked': 'checked' }),
E('span', {}, band)
]);
})
)
])
])
])
]);
container.appendChild(ratSection);
// APN Section
const activeApn = (self.apnData && self.apnData.apn) ? self.apnData.apn : '';
const activePdp = (self.apnData && self.apnData.pdp_type) ? self.apnData.pdp_type : 'IPV4V6';
const apnSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Access Point Name (APN) Settings')),
E('div', { 'class': 'cbi-section-node' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('APN Name')),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'modem_apn',
'class': 'cbi-input-text',
'placeholder': _('e.g. ctnet, cmiot, uninet'),
'value': activeApn
}),
E('div', { 'class': 'cbi-value-description' }, _('Cellular carrier APN profile name (e.g. cmiot, cbnet, uninet).'))
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('PDP Protocol Type')),
E('div', { 'class': 'cbi-value-field' }, [
E('select', { 'id': 'modem_pdp', 'class': 'cbi-input-select' }, [
E('option', { 'value': 'IPV4V6', 'selected': (activePdp === 'IPV4V6' ? 'selected' : null) }, _('Dual Stack IPv4 and IPv6')),
E('option', { 'value': 'IP', 'selected': (activePdp === 'IP' ? 'selected' : null) }, _('IPv4 Only')),
E('option', { 'value': 'IPV6', 'selected': (activePdp === 'IPV6' ? 'selected' : null) }, _('IPv6 Only'))
])
])
])
])
]);
container.appendChild(apnSection);
// Submit Section
const btnSection = E('div', { 'class': 'cbi-page-actions' }, [
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-save',
'value': _('Save and Apply'),
'click': ui.createHandlerFn(self, 'handleApplySettings')
})
]);
container.appendChild(btnSection);
return container;
}
});
@@ -0,0 +1,503 @@
'use strict';
'require view';
'require rpc';
'require ui';
const callGetDevices = rpc.declare({
object: 'luci.modem',
method: 'get_devices',
expect: { devices: [] }
});
const callGetConfig = rpc.declare({
object: 'luci.modem',
method: 'get_config',
expect: { port: '', baud: '115200' }
});
const callGetSmsStatus = rpc.declare({
object: 'luci.modem',
method: 'get_sms_status',
params: [ 'port' ]
});
const callSetSmsEnable = rpc.declare({
object: 'luci.modem',
method: 'set_sms_enable',
params: [ 'enabled', 'port' ]
});
const callGetSmsList = rpc.declare({
object: 'luci.modem',
method: 'get_sms_list',
params: [ 'port' ]
});
const callSendSms = rpc.declare({
object: 'luci.modem',
method: 'send_sms',
params: [ 'port', 'recipient', 'message' ]
});
const callDeleteSms = rpc.declare({
object: 'luci.modem',
method: 'delete_sms',
params: [ 'port', 'index' ]
});
return view.extend({
handleSaveApply: null,
handleSave: null,
handleReset: null,
selectedPort: null,
devicesList: [],
smsEnabled: false,
smsCapacity: { used: 0, total: 0 },
smsList: [],
load: function() {
const self = this;
return Promise.all([
L.resolveDefault(callGetDevices(), []),
L.resolveDefault(callGetConfig(), {})
]).then(function(results) {
const devicesData = results[0];
const configData = results[1] || {};
let list = [];
if (Array.isArray(devicesData)) {
list = devicesData;
} else if (devicesData && Array.isArray(devicesData.devices)) {
list = devicesData.devices;
}
self.devicesList = list;
if (self.devicesList.length > 0) {
self.selectedPort = configData.port || self.devicesList[0].primary_port;
} else {
self.selectedPort = null;
}
if (!self.selectedPort) {
self.smsEnabled = false;
self.smsCapacity = { used: 0, total: 0 };
self.smsList = [];
return Promise.resolve();
}
return callGetSmsStatus(self.selectedPort).then(function(statusRes) {
self.smsEnabled = !!(statusRes && statusRes.enabled);
self.smsCapacity = {
used: statusRes?.used || 0,
total: statusRes?.total || 0
};
if (self.smsEnabled) {
return callGetSmsList(self.selectedPort).then(function(listRes) {
self.smsList = (listRes && listRes.messages) ? listRes.messages : [];
}).catch(function() {
self.smsList = [];
});
} else {
self.smsList = [];
return Promise.resolve();
}
}).catch(function() {
self.smsEnabled = false;
self.smsCapacity = { used: 0, total: 0 };
self.smsList = [];
return Promise.resolve();
});
}).catch(function() {
self.devicesList = [];
self.selectedPort = null;
self.smsEnabled = false;
self.smsCapacity = { used: 0, total: 0 };
self.smsList = [];
return Promise.resolve();
});
},
renderSmsState: function() {
const self = this;
const mainArea = document.getElementById('sms_main_content');
if (!mainArea) return;
while (mainArea.firstChild) {
mainArea.removeChild(mainArea.firstChild);
}
if (!self.smsEnabled) {
// Disabled state: safety notice card
const disabledCard = E('div', {
'style': 'background: #fff; border: 1px solid #e1e4e8; border-radius: 6px; padding: 30px; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.05); margin-top: 15px;'
}, [
E('div', { 'style': 'font-size: 18px; font-weight: bold; color: #333; margin-bottom: 12px;' }, _('SMS Feature is Currently Disabled')),
E('div', {
'style': 'max-width: 680px; margin: 0 auto 24px auto; color: #555; line-height: 1.6; font-size: 14px;'
}, _('To prevent IoT SIM cards, enterprise telemetry cards, or data-only SIM cards from unexpected billing, network carrier account suspensions, or serial AT buffer deadlocks, the SMS module is disabled by default. In this mode, no background polling or SMS commands will ever touch the cellular modem.')),
E('div', { 'style': 'display: flex; justify-content: center; gap: 15px;' }, [
E('button', {
'class': 'btn cbi-button cbi-button-action',
'style': 'min-width: 180px; font-weight: bold;',
'click': function() {
self.handleToggleSms(true);
}
}, _('Enable SMS Feature'))
])
]);
mainArea.appendChild(disabledCard);
return;
}
// Enabled state
const toolbar = E('div', {
'style': 'display: flex; justify-content: space-between; align-items: center; background: #fff; border: 1px solid #e1e4e8; border-radius: 6px; padding: 15px 20px; margin-top: 15px; box-shadow: 0 1px 3px rgba(0,0,0,0.05);'
}, [
E('div', { 'style': 'display: flex; align-items: center; gap: 20px;' }, [
E('div', {}, [
E('span', { 'style': 'color: #333; font-size: 13px;' }, _('SIM Card Storage Capacity: ')),
E('strong', { 'style': 'color: #333; font-size: 14px;' },
self.smsCapacity.total > 0 ? (self.smsCapacity.used + ' / ' + self.smsCapacity.total + ' ' + _('Messages')) : (self.smsList.length + ' ' + _('Messages')))
]),
E('span', {
'style': 'background: #e6ffed; color: #22863a; border: 1px solid #bef5cb; padding: 2px 8px; border-radius: 12px; font-size: 12px;'
}, _('Module Active'))
]),
E('div', { 'style': 'display: flex; gap: 10px;' }, [
E('button', {
'class': 'btn cbi-button cbi-button-save',
'click': function() {
self.handleOpenSendModal();
}
}, _('Compose Message')),
E('button', {
'class': 'btn cbi-button cbi-button-action',
'click': function() {
self.handleRefreshList();
}
}, _('Refresh List')),
E('button', {
'class': 'btn cbi-button cbi-button-reset',
'click': function() {
self.handleClearAll();
}
}, _('Clear All')),
E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'style': 'color: #d73a49;',
'click': function() {
self.handleToggleSms(false);
}
}, _('Disable Feature'))
])
]);
mainArea.appendChild(toolbar);
// SMS Message List
const listContainer = E('div', { 'style': 'margin-top: 15px;' });
if (!self.smsList || self.smsList.length === 0) {
listContainer.appendChild(E('div', {
'style': 'background: #fff; border: 1px dashed #d1d5db; border-radius: 6px; padding: 40px; text-align: center; color: #888;'
}, _('Inbox is empty. No messages stored on SIM card.')));
} else {
self.smsList.forEach(function(item) {
const isUnread = (item.status && item.status.indexOf('UNREAD') !== -1);
const card = E('div', {
'style': 'background: #fff; border: 1px solid #e1e4e8; border-left: 4px solid ' + (isUnread ? '#0366d6' : '#28a745') + '; border-radius: 4px; padding: 15px 20px; margin-bottom: 12px; box-shadow: 0 1px 2px rgba(0,0,0,0.04);'
}, [
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;' }, [
E('div', { 'style': 'display: flex; align-items: center; gap: 10px;' }, [
E('strong', { 'style': 'font-size: 15px; color: #24292e;' }, item.sender || _('Unknown Sender')),
E('span', {
'style': 'font-size: 11px; padding: 1px 6px; border-radius: 10px; background: ' + (isUnread ? '#dbedff; color: #0366d6;' : '#f1f8ff; color: #586069;')
}, isUnread ? _('Unread') : _('Read')),
E('span', { 'style': 'font-size: 11px; color: #999;' }, '#' + item.index)
]),
E('div', { 'style': 'color: #888; font-size: 12px;' }, item.time || '')
]),
E('div', {
'style': 'color: #333; font-size: 14px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; margin: 10px 0; background: #fafbfc; padding: 10px 12px; border-radius: 4px;'
}, item.content || _('[Empty Message Body]')),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px;' }, [
E('button', {
'class': 'btn cbi-button cbi-button-action',
'style': 'font-size: 12px;',
'click': function() {
self.handleOpenSendModal(item.sender);
}
}, _('Reply')),
E('button', {
'class': 'btn cbi-button cbi-button-reset',
'style': 'font-size: 12px;',
'click': function() {
self.handleDeleteSingle(item.index);
}
}, _('Delete'))
])
]);
listContainer.appendChild(card);
});
}
mainArea.appendChild(listContainer);
},
handleToggleSms: function(enable) {
const self = this;
const actionTitle = enable ? _('Enable SMS Feature') : _('Disable SMS Feature');
const actionText = enable ?
_('Enabling SMS allows sending, receiving, and reading messages. Ensure your SIM card plan supports SMS traffic.') :
_('Disabling SMS stops all SMS-related interactions and returns the cellular modem to pure data safety mode.');
ui.showModal(actionTitle, [
E('p', {}, actionText),
E('div', { 'class': 'cbi-page-actions' }, [
E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'click': ui.hideModal
}, _('Cancel')),
E('button', {
'class': 'btn cbi-button ' + (enable ? 'cbi-button-save' : 'cbi-button-reset'),
'click': function() {
ui.showModal(actionTitle, [
E('p', { 'class': 'spinning' }, _('Updating configuration, please wait...'))
]);
callSetSmsEnable(!!enable, self.selectedPort).then(function(res) {
self.smsEnabled = !!(res && (res.enabled || res.success));
if (self.smsEnabled) {
return Promise.all([
callGetSmsStatus(self.selectedPort),
callGetSmsList(self.selectedPort)
]).then(function(datas) {
const st = datas[0] || {};
const ls = datas[1] || {};
self.smsCapacity = { used: st.used || 0, total: st.total || 0 };
self.smsList = (ls && Array.isArray(ls.messages)) ? ls.messages : [];
ui.hideModal();
self.renderSmsState();
});
} else {
self.smsList = [];
self.smsCapacity = { used: 0, total: 0 };
ui.hideModal();
self.renderSmsState();
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Failed to toggle SMS state: ') + err.message), 'error');
});
}
}, _('Confirm'))
])
]);
},
handleRefreshList: function() {
const self = this;
if (!self.selectedPort) {
self.renderSmsState();
return;
}
ui.showModal(_('Refreshing Messages'), [
E('p', { 'class': 'spinning' }, _('Reading SMS inbox from SIM card, please wait...'))
]);
Promise.all([
callGetSmsStatus(self.selectedPort),
callGetSmsList(self.selectedPort)
]).then(function(results) {
const st = results[0] || {};
const ls = results[1] || {};
self.smsCapacity = { used: st.used || 0, total: st.total || 0 };
self.smsList = (ls && Array.isArray(ls.messages)) ? ls.messages : [];
ui.hideModal();
self.renderSmsState();
ui.addNotification(null, E('p', {}, _('SMS list refreshed successfully.')), 'info');
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Failed to refresh SMS list: ') + err.message), 'error');
});
},
handleOpenSendModal: function(prefillRecipient) {
const self = this;
const recipientInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'style': 'width: 100%; box-sizing: border-box; padding: 7px 10px; font-family: monospace;',
'placeholder': _('Target phone number, e.g. 10086 or +8613800000000'),
'value': prefillRecipient || ''
});
const charCountEl = E('span', { 'style': 'color: #888; font-size: 12px;' }, '0 ' + _('characters'));
const messageInput = E('textarea', {
'class': 'cbi-input-textarea',
'style': 'width: 100%; height: 130px; box-sizing: border-box; padding: 8px 10px; font-family: sans-serif; resize: vertical;',
'placeholder': _('Enter SMS message content...'),
'input': function(ev) {
charCountEl.textContent = ev.target.value.length + ' ' + _('characters');
}
});
const formContainer = E('div', {
'style': 'min-width: 520px; max-width: 100%; box-sizing: border-box; padding: 4px 0;'
}, [
E('div', { 'style': 'margin-bottom: 16px;' }, [
E('label', {
'style': 'display: block; font-weight: bold; margin-bottom: 6px; color: #333; font-size: 13px;'
}, _('Recipient Phone Number')),
recipientInput
]),
E('div', { 'style': 'margin-bottom: 16px;' }, [
E('div', {
'style': 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;'
}, [
E('label', {
'style': 'display: block; font-weight: bold; color: #333; font-size: 13px;'
}, _('Message Content')),
charCountEl
]),
messageInput
]),
E('div', {
'style': 'display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; padding-top: 15px; border-top: 1px solid #e1e4e8;'
}, [
E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'click': ui.hideModal
}, _('Cancel')),
E('button', {
'class': 'btn cbi-button cbi-button-save',
'click': function() {
const to = recipientInput.value.trim();
const text = messageInput.value.trim();
if (!to) {
ui.addNotification(null, E('p', {}, _('Please specify a valid recipient phone number.')), 'error');
return;
}
if (!text) {
ui.addNotification(null, E('p', {}, _('Message content cannot be empty.')), 'error');
return;
}
ui.showModal(_('Sending Message'), [
E('p', { 'class': 'spinning' }, _('Transmitting SMS message over cellular radio, please wait...'))
]);
callSendSms(self.selectedPort, to, text).then(function(res) {
ui.hideModal();
if (res && res.success) {
ui.addNotification(null, E('p', {}, _('Message transmitted successfully.')), 'info');
self.handleRefreshList();
} else {
ui.addNotification(null, E('p', {}, _('Failed to transmit message: ') + (res?.output || _('Unknown cellular error'))), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Failed to transmit message: ') + err.message), 'error');
});
}
}, _('Send'))
])
]);
ui.showModal(_('Compose Message'), [ formContainer ]);
},
handleDeleteSingle: function(index) {
const self = this;
ui.showModal(_('Delete Message'), [
E('p', {}, _('Are you sure you want to delete message #') + index + '?'),
E('div', { 'class': 'cbi-page-actions' }, [
E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'click': ui.hideModal
}, _('Cancel')),
E('button', {
'class': 'btn cbi-button cbi-button-reset',
'click': function() {
ui.showModal(_('Deleting Message'), [
E('p', { 'class': 'spinning' }, _('Deleting message, please wait...'))
]);
callDeleteSms(self.selectedPort, index).then(function(res) {
ui.hideModal();
if (res && res.success) {
ui.addNotification(null, E('p', {}, _('Message deleted successfully.')), 'info');
self.handleRefreshList();
} else {
ui.addNotification(null, E('p', {}, _('Failed to delete message: ') + (res?.output || _('Unknown error'))), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Failed to delete message: ') + err.message), 'error');
});
}
}, _('Delete'))
])
]);
},
handleClearAll: function() {
const self = this;
ui.showModal(_('Clear All Messages'), [
E('p', {}, _('Are you sure you want to clear all SMS messages stored on the SIM card? This action cannot be undone.')),
E('div', { 'class': 'cbi-page-actions' }, [
E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'click': ui.hideModal
}, _('Cancel')),
E('button', {
'class': 'btn cbi-button cbi-button-reset',
'click': function() {
ui.showModal(_('Clearing Messages'), [
E('p', { 'class': 'spinning' }, _('Clearing all SMS messages, please wait...'))
]);
callDeleteSms(self.selectedPort, 'all').then(function(res) {
ui.hideModal();
if (res && res.success) {
ui.addNotification(null, E('p', {}, _('All SMS messages cleared successfully.')), 'info');
self.handleRefreshList();
} else {
ui.addNotification(null, E('p', {}, _('Failed to clear messages: ') + (res?.output || _('Unknown error'))), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Failed to clear messages: ') + err.message), 'error');
});
}
}, _('Confirm Clear'))
])
]);
},
render: function() {
const self = this;
const container = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, _('Cellular Modem Manager') + ' - ' + _('SMS Management')),
E('div', { 'class': 'cbi-map-descr' }, _('Read, compose, and manage cellular SMS messages stored on the SIM card.'))
]);
if (self.devicesList.length === 0) {
container.appendChild(E('div', { 'class': 'alert-message warning', 'style': 'margin-top: 15px; padding: 15px; border-left: 5px solid #ff9800; background: #fff8e1;' }, [
E('h4', { 'style': 'margin: 0 0 8px 0; color: #d32f2f;' }, _('No Cellular Modem Detected')),
E('p', { 'style': 'margin: 0; color: #555;' }, _('No active cellular modem detected. Unable to manage SMS messages. Please check physical connection.'))
]));
return container;
}
// Dynamic SMS content container
const mainArea = E('div', { 'id': 'sms_main_content' });
container.appendChild(mainArea);
// Initial render after DOM is mounted
setTimeout(function() {
self.renderSmsState();
}, 0);
return container;
}
});
@@ -0,0 +1,109 @@
'use strict';
'require view';
'require rpc';
'require ui';
const callGetTemplates = rpc.declare({
object: 'luci.modem',
method: 'get_templates',
expect: { templates: [] }
});
return view.extend({
handleSaveApply: null,
handleSave: null,
handleReset: null,
load: function() {
return L.resolveDefault(callGetTemplates(), []);
},
showTemplateDetail: function(tpl) {
ui.showModal(tpl.name || _('Template Detail'), [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Template ID')),
E('div', { 'class': 'cbi-value-field' }, tpl.id || '-')
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Target Vendor')),
E('div', { 'class': 'cbi-value-field' }, tpl.vendor || '-')
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('USB VID')),
E('div', { 'class': 'cbi-value-field' }, tpl.vid || '-')
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Mode Switch Command')),
E('div', { 'class': 'cbi-value-field' }, (tpl.mode_switch && tpl.mode_switch.cmd) ? tpl.mode_switch.cmd : _('Not Defined'))
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Raw Configuration')),
E('div', { 'class': 'cbi-value-field' }, [
E('pre', {
'style': 'background: #f7f7f7; padding: 10px; border: 1px solid #ddd; max-height: 250px; overflow-y: auto; font-size: 12px; font-family: monospace;'
}, JSON.stringify(tpl, null, 2))
])
]),
E('div', { 'class': 'right', 'style': 'margin-top: 15px;' }, [
E('input', {
'type': 'button',
'class': 'btn cbi-button',
'value': _('Close'),
'click': ui.hideModal
})
])
]);
},
render: function(tplData) {
const self = this;
const templates = Array.isArray(tplData) ? tplData : ((tplData && Array.isArray(tplData.templates)) ? tplData.templates : []);
const container = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, _('Cellular Modem Manager') + ' - ' + _('Configuration Templates')),
E('div', { 'class': 'cbi-map-descr' }, _('Manage vendor preset command profiles, private AT mappings, band lock masks, and driver switching templates.'))
]);
const rows = [];
templates.forEach(function(tpl) {
rows.push(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'style': 'font-weight: bold;' }, tpl.name || tpl.id),
E('td', { 'class': 'td left' }, tpl.vendor || '-'),
E('td', { 'class': 'td left' }, tpl.vid || '*'),
E('td', { 'class': 'td left' }, (tpl.mode_switch && tpl.mode_switch.cmd) ? tpl.mode_switch.cmd : '-'),
E('td', { 'class': 'td right' }, [
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-action',
'value': _('View Details'),
'click': ui.createHandlerFn(self, 'showTemplateDetail', tpl)
})
])
]));
});
if (rows.length === 0) {
rows.push(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td center', 'colspan': '5' }, _('No templates installed.'))
]));
}
const tableRows = [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th left' }, _('Template Name')),
E('th', { 'class': 'th left' }, _('Vendor')),
E('th', { 'class': 'th left' }, _('Vendor ID')),
E('th', { 'class': 'th left' }, _('Mode Switching Command')),
E('th', { 'class': 'th right' }, _('Actions'))
])
].concat(rows);
const listSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Preset Template Library')),
E('table', { 'class': 'table cbi-section-table' }, tableRows)
]);
container.appendChild(listSection);
return container;
}
});
@@ -0,0 +1,643 @@
'use strict';
'require view';
'require rpc';
'require ui';
const callGetDevices = rpc.declare({
object: 'luci.modem',
method: 'get_devices',
expect: { devices: [] }
});
const callSendAt = rpc.declare({
object: 'luci.modem',
method: 'send_at',
params: [ 'port', 'cmd', 'timeout' ]
});
// Standard 3GPP and Vendor AT Command Knowledge Base
const AT_DICTIONARY = [
{ cmd: 'ATI', desc: _('Query module manufacturer, model, and firmware version') },
{ cmd: 'AT+CPIN?', desc: _('Query SIM card lock and ready status') },
{ cmd: 'AT+CSQ', desc: _('Query received signal strength and bit error rate') },
{ cmd: 'AT+CCID', desc: _('Query SIM card ICCID number') },
{ cmd: 'AT+CFUN?', desc: _('Query current RF radio state (0:Min, 1:Full, 4:Airplane)') },
{ cmd: 'AT+CFUN=1', desc: _('Enable full RF function (Turn off airplane mode)') },
{ cmd: 'AT+CFUN=4', desc: _('Turn on airplane mode') },
{ cmd: 'AT+CFUN=1,1', desc: _('Reboot module processor and RF reset') },
{ cmd: 'AT+COPS?', desc: _('Query registered cellular operator and RAT') },
{ cmd: 'AT+COPS=?', desc: _('Scan all available cellular networks in air (Long timeout)') },
{ cmd: 'AT+CREG?', desc: _('Query 2G/3G network registration status') },
{ cmd: 'AT+CEREG?', desc: _('Query 4G LTE network registration status') },
{ cmd: 'AT+C5GREG?', desc: _('Query 5G NR network registration status') },
{ cmd: 'AT+CGDCONT?', desc: _('Query configured PDP contexts and APN profiles') },
{ cmd: 'AT+CGDCONT=1,"IPV4V6","cmiot"', desc: _('Configure PDP context 1 with APN') },
{ cmd: 'AT+QENG="servingcell"', desc: _('Quectel: Query current serving cell details (5G SA/LTE, PCI, TAC, Band, RSRP, SINR)') },
{ cmd: 'AT+QCAINFO', desc: _('Quectel: Query carrier aggregation (CA) state and secondary cells') },
{ cmd: 'AT+QTEMP', desc: _('Quectel: Query CPU and RF chip temperatures') },
{ cmd: 'AT+QNWINFO', desc: _('Quectel: Query current network band and channel info') },
{ cmd: 'AT+GTCCINFO?', desc: _('Fibocom: Query current cell info and radio metrics') },
{ cmd: 'AT+GTCAINFO?', desc: _('Fibocom: Query carrier aggregation info') },
{ cmd: 'AT+GTRNDIS?', desc: _('Fibocom: Query RNDIS network connection status') }
];
// 3GPP TS 27.007 CME / CMS Error Descriptions
const CME_ERRORS = {
'0': _('Phone failure'),
'1': _('No connection to phone'),
'2': _('Phone-adaptor link reserved'),
'3': _('Operation not allowed'),
'4': _('Operation not supported'),
'5': _('PH-SIM PIN required'),
'10': _('SIM card not inserted or not detected'),
'11': _('SIM PIN required (Please enter PIN to unlock)'),
'12': _('SIM PUK required (Please enter PUK to unlock)'),
'13': _('SIM card failure or communication defect'),
'14': _('SIM card busy, please retry later'),
'15': _('SIM wrong password'),
'16': _('Incorrect PIN entered'),
'17': _('PIN2 required'),
'18': _('PUK2 required'),
'20': _('Memory storage full'),
'21': _('Invalid index or memory entry not found'),
'22': _('Not found'),
'24': _('Text string too long'),
'25': _('Invalid characters in text string'),
'26': _('Dial string too long'),
'27': _('Invalid characters in dial string'),
'30': _('No cellular network service available'),
'31': _('Network timeout'),
'32': _('Network not allowed emergency calls only'),
'100': _('Unknown error or unsupported extended command')
};
// Chinese & Full-width punctuation replacement mapping
function sanitizeAtPunctuation(str) {
if (!str) return '';
let res = str;
// Replace full-width quotes
res = res.replace(/[“”]/g, '"').replace(/[‘’]/g, "'");
// Replace full-width punctuation
res = res.replace(/,/g, ',')
.replace(/?/g, '?')
.replace(/:/g, ':')
.replace(/;/g, ';')
.replace(/=/g, '=')
.replace(/+/g, '+')
.replace(/-/g, '-')
.replace(/(/g, '(')
.replace(/)/g, ')')
.replace(/\u3000/g, ' '); // full-width space
// Ensure uppercase prefix for 'at'
if (/^at([+=?&]|\s|$)/i.test(res)) {
res = 'AT' + res.slice(2);
}
return res;
}
// Levenshtein distance for fuzzy suggestion
function levenshteinDistance(s1, s2) {
const a = s1.toUpperCase();
const b = s2.toUpperCase();
const matrix = [];
for (let i = 0; i <= b.length; i++) matrix[i] = [i];
for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
);
}
}
}
return matrix[b.length][a.length];
}
function findBestSuggestion(inputCmd) {
if (!inputCmd || inputCmd.length < 3) return null;
const clean = inputCmd.trim().toUpperCase();
let bestMatch = null;
let lowestDist = 999;
for (let i = 0; i < AT_DICTIONARY.length; i++) {
const target = AT_DICTIONARY[i].cmd.toUpperCase();
const baseTarget = target.split('=')[0]; // base command prefix
const baseClean = clean.split('=')[0];
const dist = levenshteinDistance(baseClean, baseTarget);
if (dist > 0 && dist <= 2 && dist < lowestDist) {
lowestDist = dist;
bestMatch = AT_DICTIONARY[i];
}
}
return bestMatch;
}
return view.extend({
handleSaveApply: null,
handleSave: null,
handleReset: null,
selectedPort: null,
devicesList: [],
cmdHistory: [],
historyIndex: -1,
favorites: [],
load: function() {
return L.resolveDefault(callGetDevices(), []);
},
loadFavorites: function() {
try {
const saved = localStorage.getItem('modem_at_favorites');
if (saved) this.favorites = JSON.parse(saved);
} catch(e) {}
if (!Array.isArray(this.favorites) || this.favorites.length === 0) {
this.favorites = [
{ cmd: 'ATI', label: _('Module Info') },
{ cmd: 'AT+CSQ', label: _('Signal CSQ') },
{ cmd: 'AT+CPIN?', label: _('SIM Status') },
{ cmd: 'AT+COPS?', label: _('Operator') },
{ cmd: 'AT+QENG="servingcell"', label: _('Serving Cell') }
];
}
},
saveFavorites: function() {
try {
localStorage.setItem('modem_at_favorites', JSON.stringify(this.favorites));
} catch(e) {}
},
appendLog: function(text, type, durationMs) {
const out = document.getElementById('terminal_output');
if (!out) return;
const d = new Date();
const timeStr = d.toTimeString().split(' ')[0] + '.' + String(d.getMilliseconds()).padStart(3, '0');
const line = document.createElement('div');
line.style.whiteSpace = 'pre-wrap';
line.style.wordBreak = 'break-all';
line.style.fontFamily = 'monospace';
line.style.lineHeight = '1.4em';
line.style.marginBottom = '4px';
if (type === 'cmd') {
line.style.color = '#cc7832';
line.style.fontWeight = 'bold';
line.textContent = timeStr + ' >>> ' + text;
} else if (type === 'error') {
line.style.color = '#ff6b68';
line.textContent = timeStr + ' <<< ' + text;
} else if (type === 'meta') {
line.style.color = '#808080';
line.style.fontSize = '11px';
line.textContent = text;
} else {
line.style.color = '#a9b7c6';
line.textContent = timeStr + ' <<< ' + text;
}
if (durationMs !== undefined && durationMs !== null) {
const durSpan = document.createElement('span');
durSpan.style.float = 'right';
durSpan.style.color = '#629755';
durSpan.style.fontSize = '11px';
durSpan.textContent = '[' + _('Duration: ') + durationMs + ' ms]';
line.appendChild(durSpan);
}
out.appendChild(line);
// Parse CME / CMS Error Code Annotation
const mCme = text.match(/\+(CME|CMS) ERROR:\s*([0-9]+)/);
if (mCme) {
const errCode = mCme[2];
const explanation = CME_ERRORS[errCode];
if (explanation) {
const expLine = document.createElement('div');
expLine.style.color = '#e5c07b';
expLine.style.background = 'rgba(229, 192, 123, 0.1)';
expLine.style.padding = '2px 6px';
expLine.style.margin = '2px 0';
expLine.style.borderRadius = '3px';
expLine.style.fontSize = '12px';
expLine.textContent = '[' + _('Error Explanation') + '] ' + mCme[0] + ' -> ' + explanation;
out.appendChild(expLine);
}
}
out.scrollTop = out.scrollHeight;
},
executeCommand: function(cmdStr) {
const self = this;
const inputEl = document.getElementById('terminal_input');
let raw = cmdStr || (inputEl ? inputEl.value : '');
// Auto sanitize Chinese/Full-width punctuation
const cleanCmd = sanitizeAtPunctuation(raw).trim();
if (!cleanCmd) return;
if (!self.selectedPort) {
ui.addNotification(null, E('p', {}, _('No active communication port configured. Please configure an AT control port in Settings first.')), 'error');
return;
}
// Security Guard: warn on dangerous destructive reset commands
if (/^AT&F|^AT\+RESTORE/i.test(cleanCmd)) {
if (!confirm(_('Warning: This command may perform a factory reset on the cellular modem. Are you sure to execute?'))) {
return;
}
}
self.appendLog(cleanCmd, 'cmd');
if (self.cmdHistory.length === 0 || self.cmdHistory[self.cmdHistory.length - 1] !== cleanCmd) {
self.cmdHistory.push(cleanCmd);
}
self.historyIndex = self.cmdHistory.length;
if (inputEl) {
inputEl.value = '';
self.hideAutocomplete();
}
const btn = document.getElementById('terminal_send_btn');
if (btn) btn.disabled = true;
// Calculate client-side timestamp fallback
const tStart = Date.now();
callSendAt(self.selectedPort, cleanCmd, 10).then(function(res) {
if (btn) btn.disabled = false;
const dur = (res && res.duration_ms) ? res.duration_ms : (Date.now() - tStart);
if (res && res.output) {
self.appendLog(res.output, res.success ? 'resp' : 'error', dur);
// Suggest command if failed
if (!res.success) {
const sugg = findBestSuggestion(cleanCmd);
if (sugg) {
self.renderSuggestionNotice(sugg);
}
}
} else {
self.appendLog(_('No response from modem (Timeout or device busy).'), 'error', dur);
}
}).catch(function(err) {
if (btn) btn.disabled = false;
self.appendLog(_('Execution failed: ') + err.message, 'error');
});
},
renderSuggestionNotice: function(sugg) {
const self = this;
const out = document.getElementById('terminal_output');
if (!out) return;
const line = document.createElement('div');
line.style.color = '#61afef';
line.style.background = 'rgba(97, 175, 239, 0.1)';
line.style.padding = '3px 8px';
line.style.margin = '4px 0';
line.style.borderRadius = '3px';
line.style.fontSize = '12px';
const textSpan = document.createElement('span');
textSpan.textContent = _('Did you mean: ') + sugg.cmd + ' - ' + sugg.desc + '? ';
const fillBtn = document.createElement('a');
fillBtn.href = '#';
fillBtn.style.color = '#98c379';
fillBtn.style.marginLeft = '8px';
fillBtn.style.textDecoration = 'underline';
fillBtn.textContent = _('[Fill in]');
fillBtn.onclick = function(e) {
e.preventDefault();
const inputEl = document.getElementById('terminal_input');
if (inputEl) {
inputEl.value = sugg.cmd;
inputEl.focus();
}
};
line.appendChild(textSpan);
line.appendChild(fillBtn);
out.appendChild(line);
out.scrollTop = out.scrollHeight;
},
showAutocomplete: function(query) {
const self = this;
const box = document.getElementById('terminal_autocomplete_box');
if (!box) return;
const clean = query.trim().toUpperCase();
if (!clean || clean.length < 2) {
box.style.display = 'none';
return;
}
const matches = AT_DICTIONARY.filter(function(item) {
return item.cmd.toUpperCase().indexOf(clean) !== -1;
}).slice(0, 6);
if (matches.length === 0) {
box.style.display = 'none';
return;
}
box.innerHTML = '';
matches.forEach(function(m) {
const itemEl = document.createElement('div');
itemEl.style.padding = '5px 10px';
itemEl.style.cursor = 'pointer';
itemEl.style.fontSize = '12px';
itemEl.style.borderBottom = '1px solid #eee';
itemEl.style.display = 'flex';
itemEl.style.justifyContent = 'space-between';
const cmdSpan = document.createElement('b');
cmdSpan.style.fontFamily = 'monospace';
cmdSpan.textContent = m.cmd;
const descSpan = document.createElement('span');
descSpan.style.color = '#888';
descSpan.style.marginLeft = '10px';
descSpan.textContent = m.desc;
itemEl.appendChild(cmdSpan);
itemEl.appendChild(descSpan);
itemEl.onmouseenter = function() { itemEl.style.background = '#f0f4f8'; };
itemEl.onmouseleave = function() { itemEl.style.background = '#fff'; };
itemEl.onclick = function() {
const inputEl = document.getElementById('terminal_input');
if (inputEl) {
inputEl.value = m.cmd;
inputEl.focus();
}
self.hideAutocomplete();
};
box.appendChild(itemEl);
});
box.style.display = 'block';
},
hideAutocomplete: function() {
const box = document.getElementById('terminal_autocomplete_box');
if (box) box.style.display = 'none';
},
refreshFavoritesUI: function() {
const self = this;
const favContainer = document.getElementById('terminal_favorites_list');
if (!favContainer) return;
favContainer.innerHTML = '';
self.favorites.forEach(function(f, idx) {
const btn = E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-action',
'style': 'margin-right: 6px; margin-bottom: 6px; font-family: monospace; font-size: 12px;',
'value': f.label || f.cmd,
'click': function() {
const inputEl = document.getElementById('terminal_input');
if (inputEl) inputEl.value = f.cmd;
self.executeCommand(f.cmd);
}
});
favContainer.appendChild(btn);
});
},
addCurrentToFavorites: function() {
const self = this;
const inputEl = document.getElementById('terminal_input');
const cmd = inputEl ? inputEl.value.trim() : '';
if (!cmd) {
ui.addNotification(null, E('p', {}, _('Please enter a command to favorite.')), 'warning');
return;
}
const label = prompt(_('Enter a friendly name for this shortcut:'), cmd);
if (label !== null) {
self.favorites.push({ cmd: cmd, label: label.trim() || cmd });
self.saveFavorites();
self.refreshFavoritesUI();
ui.addNotification(null, E('p', {}, _('Command added to favorites.')), 'info');
}
},
exportLogFile: function() {
const out = document.getElementById('terminal_output');
if (!out) return;
const text = out.innerText;
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const a = document.createElement('a');
const d = new Date().toISOString().replace(/[:.]/g, '-');
a.download = 'modem_at_log_' + d + '.txt';
a.href = URL.createObjectURL(blob);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
},
render: function(devicesData) {
const self = this;
self.devicesList = Array.isArray(devicesData) ? devicesData : ((devicesData && Array.isArray(devicesData.devices)) ? devicesData.devices : []);
self.loadFavorites();
if (self.devicesList.length > 0) {
self.selectedPort = self.devicesList[0].primary_port || (self.devicesList[0].ports ? self.devicesList[0].ports[0] : null);
} else {
self.selectedPort = null;
}
const container = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, _('Cellular Modem Manager') + ' - ' + _('AT Command Terminal')),
E('div', { 'class': 'cbi-map-descr' }, _('Direct interactive terminal for sending AT commands with intelligent auto-punctuation correction, fuzzy suggestions, error translation, and custom favorites.'))
]);
if (!self.selectedPort) {
container.appendChild(E('div', { 'class': 'alert-message warning', 'style': 'margin-top: 15px; padding: 15px; border-left: 5px solid #ff9800; background: #fff8e1;' }, [
E('h4', { 'style': 'margin: 0 0 8px 0; color: #d32f2f;' }, _('No Serial Ports Available')),
E('p', { 'style': 'margin: 0; color: #555;' }, _('No ttyUSB or ttyACM serial communication ports detected. Please verify hardware connection and driver state.'))
]));
}
// Port Status Bar (Display Only)
const activePortLabel = self.selectedPort ? (self.selectedPort + ' @ 115200') : _('No Port Available');
const portStatusBar = E('div', {
'style': 'margin-top: 15px; margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; background: #f4f6f8; border: 1px solid #e1e4e8; border-radius: 4px; padding: 8px 16px;'
}, [
E('span', {}, [
E('strong', { 'style': 'color: #333;' }, _('Communication Port: ')),
E('span', { 'style': 'color: #0366d6; font-weight: bold; font-family: monospace; font-size: 13px;' }, activePortLabel)
]),
E('span', { 'style': 'color: #666; font-size: 12px;' }, _('Bound to configured primary AT control port'))
]);
container.appendChild(portStatusBar);
// Favorites and Presets Section
const presetsSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', { 'style': 'display: flex; justify-content: space-between; align-items: center; width: 100%; box-sizing: border-box;' }, [
E('span', {}, _('Command Presets and Favorites')),
E('span', { 'style': 'display: inline-flex; gap: 8px; align-items: center; margin-left: auto;' }, [
E('input', {
'type': 'button',
'class': 'btn cbi-button',
'style': 'font-size: 11px;',
'value': _('+ Add Current to Favorites'),
'click': ui.createHandlerFn(self, 'addCurrentToFavorites')
})
])
]),
E('div', { 'class': 'cbi-section-node', 'style': 'padding: 10px;' }, [
E('div', { 'style': 'margin-bottom: 8px;' }, [
E('b', { 'style': 'display: inline-block; width: 110px;' }, _('Favorites:')),
E('span', { 'id': 'terminal_favorites_list' })
]),
E('div', {}, [
E('b', { 'style': 'display: inline-block; width: 110px;' }, _('Advanced:')),
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-action',
'style': 'margin-right: 6px; margin-bottom: 6px; font-family: monospace; font-size: 12px;',
'value': 'AT+COPS=? - ' + _('Scan Networks'),
'click': function() { self.executeCommand('AT+COPS=?'); }
}),
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-action',
'style': 'margin-right: 6px; margin-bottom: 6px; font-family: monospace; font-size: 12px;',
'value': 'AT+CGDCONT? - ' + _('Query APN'),
'click': function() { self.executeCommand('AT+CGDCONT?'); }
})
])
])
]);
container.appendChild(presetsSection);
// Autocomplete Dropdown Box
const autocompleteBox = E('div', {
'id': 'terminal_autocomplete_box',
'style': 'display: none; position: absolute; background: #fff; border: 1px solid #ccc; border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 1000; max-height: 200px; overflow-y: auto; width: 550px;'
});
// Command Input Bar (Positioned ABOVE Output Box)
const inputSection = E('fieldset', { 'class': 'cbi-section', 'style': 'margin-bottom: 15px; position: relative;' }, [
E('legend', {}, _('Command Execution')),
E('div', { 'class': 'cbi-section-node', 'style': 'padding: 10px;' }, [
E('div', { 'style': 'display: flex; gap: 10px; align-items: center;' }, [
E('input', {
'type': 'text',
'id': 'terminal_input',
'class': 'cbi-input-text',
'style': 'flex: 1; font-family: monospace; font-size: 14px; padding: 6px 10px;',
'placeholder': _('Enter AT command (e.g. AT+CSQ)... Chinese punctuation auto-converted'),
'input': function(ev) {
self.showAutocomplete(ev.target.value);
},
'keydown': function(ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
self.executeCommand();
} else if (ev.key === 'Escape') {
self.hideAutocomplete();
} else if (ev.key === 'ArrowUp') {
if (self.cmdHistory.length > 0 && self.historyIndex > 0) {
self.historyIndex--;
ev.target.value = self.cmdHistory[self.historyIndex];
}
} else if (ev.key === 'ArrowDown') {
if (self.historyIndex < self.cmdHistory.length - 1) {
self.historyIndex++;
ev.target.value = self.cmdHistory[self.historyIndex];
} else {
self.historyIndex = self.cmdHistory.length;
ev.target.value = '';
}
}
}
}),
E('input', {
'type': 'button',
'id': 'terminal_send_btn',
'class': 'btn cbi-button cbi-button-action',
'value': _('Send Command'),
'click': function() {
self.executeCommand();
}
})
]),
autocompleteBox
])
]);
container.appendChild(inputSection);
// Terminal Output Box (Positioned BELOW Input Bar)
const outputSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', { 'style': 'display: flex; justify-content: space-between; align-items: center; width: 100%; box-sizing: border-box;' }, [
E('span', {}, _('Interactive Console Output')),
E('span', { 'style': 'display: inline-flex; gap: 8px; align-items: center; margin-left: auto;' }, [
E('input', {
'type': 'button',
'class': 'btn cbi-button',
'value': _('Clear Output'),
'style': 'font-size: 11px;',
'click': function() {
const out = document.getElementById('terminal_output');
if (out) out.innerHTML = '';
}
}),
E('input', {
'type': 'button',
'class': 'btn cbi-button',
'value': _('Export Log'),
'style': 'font-size: 11px;',
'click': ui.createHandlerFn(self, 'exportLogFile')
}),
E('input', {
'type': 'button',
'class': 'btn cbi-button',
'value': _('Copy All'),
'style': 'font-size: 11px;',
'click': function() {
const out = document.getElementById('terminal_output');
if (out && navigator.clipboard) {
navigator.clipboard.writeText(out.innerText);
ui.addNotification(null, E('p', {}, _('Terminal output copied to clipboard.')), 'info');
}
}
})
])
]),
E('div', {
'id': 'terminal_output',
'style': 'background: #2b2b2b; color: #a9b7c6; font-family: monospace; font-size: 13px; min-height: 280px; max-height: 480px; overflow-y: auto; padding: 12px; border-radius: 4px; box-sizing: border-box; white-space: pre-wrap;'
}, [
E('div', { 'style': 'color: #808080;' }, _('System ready. Full-width punctuation will be automatically converted to standard ASCII.'))
])
]);
container.appendChild(outputSection);
setTimeout(function() {
self.refreshFavoritesUI();
}, 100);
return container;
}
});