From 3950b548b5f9d78464ec8408f3f68759d06e64e7 Mon Sep 17 00:00:00 2001 From: Brandon Cleary Date: Sun, 29 Mar 2026 12:37:55 -0400 Subject: [PATCH] luci-app-photonicat: modernize JS and move hardware logic Address review feedback from systemcrash: - Modernize JavaScript to ES2016 (use const/let instead of var). - Remove hardware-specific fan control scripts (moved to core repo). Signed-off-by: Brandon Cleary --- applications/luci-app-photonicat/Makefile | 15 + .../resources/view/photonicat/status.js | 562 ++++++++++++++++++ .../root/etc/config/photonicat | 22 + .../luci/menu.d/luci-app-photonicat.json | 13 + .../share/rpcd/acl.d/luci-app-photonicat.json | 44 ++ .../root/usr/share/rpcd/ucode/photonicat.uc | 292 +++++++++ 6 files changed, 948 insertions(+) create mode 100644 applications/luci-app-photonicat/Makefile create mode 100644 applications/luci-app-photonicat/htdocs/luci-static/resources/view/photonicat/status.js create mode 100644 applications/luci-app-photonicat/root/etc/config/photonicat create mode 100644 applications/luci-app-photonicat/root/usr/share/luci/menu.d/luci-app-photonicat.json create mode 100644 applications/luci-app-photonicat/root/usr/share/rpcd/acl.d/luci-app-photonicat.json create mode 100644 applications/luci-app-photonicat/root/usr/share/rpcd/ucode/photonicat.uc diff --git a/applications/luci-app-photonicat/Makefile b/applications/luci-app-photonicat/Makefile new file mode 100644 index 000000000000..2427ebcc4c6a --- /dev/null +++ b/applications/luci-app-photonicat/Makefile @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: GPL-2.0 + +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI support for Ariaboard Photonicat 2 +LUCI_DEPENDS:=+luci-base +pcat2-mcu +ucode-mod-ubus +jsonfilter +LUCI_PKGARCH:=all + +PKG_LICENSE:=GPL-2.0 +PKG_MAINTAINER:=Brandon Cleary + +include ../../luci.mk + +# call BuildPackage - OpenWrt buildroot signature +$(eval $(call BuildPackage,luci-app-photonicat)) diff --git a/applications/luci-app-photonicat/htdocs/luci-static/resources/view/photonicat/status.js b/applications/luci-app-photonicat/htdocs/luci-static/resources/view/photonicat/status.js new file mode 100644 index 000000000000..86a85776fac2 --- /dev/null +++ b/applications/luci-app-photonicat/htdocs/luci-static/resources/view/photonicat/status.js @@ -0,0 +1,562 @@ +'use strict'; +'require view'; +'require rpc'; +'require poll'; +'require dom'; + +const callGetStatus = rpc.declare({ + object: 'luci.photonicat', + method: 'get_status' +}); + +const callSetFanMode = rpc.declare({ + object: 'luci.photonicat', + method: 'set_fan_mode', + params: ['mode'] +}); + +const callSetFanLevel = rpc.declare({ + object: 'luci.photonicat', + method: 'set_fan_level', + params: ['level'] +}); + +const callSetFanCurve = rpc.declare({ + object: 'luci.photonicat', + method: 'set_fan_curve', + params: ['min_temp', 'max_temp', 'hysteresis'] +}); + +const callSetCPUGovernor = rpc.declare({ + object: 'luci.photonicat', + method: 'set_cpu_governor', + params: ['governor'] +}); + +const callGetDisplayConfig = rpc.declare({ + object: 'luci.photonicat', + method: 'get_display_config' +}); + +const callSetDisplayConfig = rpc.declare({ + object: 'luci.photonicat', + method: 'set_display_config', + params: ['backlight', 'refresh', 'theme', 'font_scale', 'pages'] +}); + +/* ── helpers ─────────────────────────────────────────────── */ + +function tempColor(temp) { + if (temp < 40) return '#4CAF50'; + if (temp < 55) return '#8BC34A'; + if (temp < 65) return '#FFC107'; + if (temp < 75) return '#FF9800'; + if (temp < 85) return '#FF5722'; + return '#F44336'; +} + +function bar(pct, color) { + return E('div', { + 'style': 'background:#e0e0e0; border-radius:4px; overflow:hidden; height:18px;' + }, [ + E('div', { + 'style': 'background:' + color + '; width:' + Math.max(0, Math.min(100, pct)) + + '%; height:100%; border-radius:4px; transition:width 0.6s ease;' + }) + ]); +} + +function tempBar(temp) { + return bar(Math.min(100, temp), tempColor(temp)); +} + +function row(label, value) { + return E('div', { 'class': 'tr' }, [ + E('div', { 'class': 'td', 'style': 'width:160px; font-weight:bold; padding:6px 8px;' }, label), + E('div', { 'class': 'td', 'style': 'padding:6px 8px;' }, value) + ]); +} + +function section(title, content) { + return E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, title), + E('div', { 'class': 'cbi-section-node' }, [ + E('div', { 'class': 'table' }, content) + ]) + ]); +} + +/* ── view ────────────────────────────────────────────────── */ + +return view.extend({ + _status: {}, + _displayConfig: {}, + _fanModeButtons: {}, + _fanLevelSlider: null, + _fanLevelLabel: null, + _govSelect: null, + + load: function() { + return Promise.all([callGetStatus(), callGetDisplayConfig()]); + }, + + render: function(data) { + this._status = data[0] || {}; + this._displayConfig = data[1] || {}; + + let statusDiv = E('div', { 'id': 'pcat-status' }); + let controlsDiv = E('div', { 'id': 'pcat-controls' }); + + this.updateStatus(statusDiv); + this.renderControls(controlsDiv); + + poll.add(L.bind(function() { + return callGetStatus().then(L.bind(function(s) { + this._status = s; + let el = document.getElementById('pcat-status'); + if (el) this.updateStatus(el); + this.syncControlState(); + }, this)); + }, this), 5); + + return E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, _('Photonicat 2')), + E('div', { 'class': 'cbi-map-descr' }, + _('System dashboard and hardware control for the Photonicat 2 SBC.')), + statusDiv, + controlsDiv + ]); + }, + + /* ── status display (rebuilt every poll) ──────────────── */ + + updateStatus: function(container) { + if (!container) return; + let s = this._status; + dom.content(container, [ + this.renderPower(s), + this.renderTemps(s), + this.renderFanStatus(s), + this.renderCPUStatus(s) + ]); + }, + + renderPower: function(s) { + let bat = s.battery || {}; + let chg = s.charger || {}; + + let pct = (bat.capacity != null) ? bat.capacity : null; + let batStatus = bat.status || 'Unknown'; + let volts = (bat.voltage != null) ? bat.voltage.toFixed(2) + ' V' : '--'; + let amps = (bat.current != null) ? Math.abs(bat.current).toFixed(2) + ' A' : '--'; + let batColor = (pct > 50) ? '#4CAF50' : (pct > 20) ? '#FFC107' : '#F44336'; + let chgText = chg.online ? _('Connected') : _('Disconnected'); + + let rows = []; + + if (pct != null) { + rows.push(row(_('Battery'), [ + E('span', { 'style': 'font-size:1.2em; font-weight:bold;' }, pct + '%'), + E('span', { 'style': 'margin-left:12px; color:#666;' }, batStatus) + ])); + rows.push(E('div', { 'class': 'tr' }, [ + E('div', { 'class': 'td' }, ''), + E('div', { 'class': 'td', 'style': 'padding:2px 8px 6px;' }, [bar(pct, batColor)]) + ])); + rows.push(row('', [ + E('span', {}, volts), + E('span', { 'style': 'margin-left:20px;' }, amps) + ])); + } + + rows.push(row(_('Charger'), chgText)); + + return section(_('Power'), rows); + }, + + renderTemps: function(s) { + let zones = s.thermal_zones || []; + let board = s.board_temp; + let rows = []; + + if (board != null) { + rows.push(E('div', { 'class': 'tr' }, [ + E('div', { 'class': 'td', 'style': 'width:160px; padding:6px 8px;' }, _('Board (MCU)')), + E('div', { 'class': 'td', 'style': 'width:70px; text-align:right; font-weight:bold; color:' + + tempColor(board) + '; padding:6px 4px;' }, board.toFixed(0) + '\u00b0C'), + E('div', { 'class': 'td', 'style': 'padding:6px 8px;' }, [tempBar(board)]) + ])); + } + + for (let i = 0; i < zones.length; i++) { + let z = zones[i]; + rows.push(E('div', { 'class': 'tr' }, [ + E('div', { 'class': 'td', 'style': 'width:160px; padding:6px 8px;' }, z.type || ('Zone ' + i)), + E('div', { 'class': 'td', 'style': 'width:70px; text-align:right; font-weight:bold; color:' + + tempColor(z.temp) + '; padding:6px 4px;' }, z.temp.toFixed(0) + '\u00b0C'), + E('div', { 'class': 'td', 'style': 'padding:6px 8px;' }, [tempBar(z.temp)]) + ])); + } + + return section(_('Temperatures'), rows); + }, + + renderFanStatus: function(s) { + let rpm = (s.fan_rpm != null) ? s.fan_rpm + ' RPM' : '--'; + let level = (s.fan_level != null) ? s.fan_level : '--'; + let max = s.fan_max_level || 9; + let cfg = s.fan_config || {}; + + return section(_('Fan'), [ + row(_('Speed'), rpm), + row(_('Level'), level + ' / ' + max + + ' (' + (cfg.mode || 'auto') + ' mode)') + ]); + }, + + renderCPUStatus: function(s) { + let cpu = s.cpu || {}; + let labels = { 'policy0': 'Cortex-A55 (Little)', 'policy4': 'Cortex-A76 (Big)' }; + let rows = []; + + for (let policy in cpu) { + let p = cpu[policy]; + rows.push(row(labels[policy] || policy, [ + E('span', { 'style': 'font-weight:bold;' }, + (p.cur_freq || '--') + ' MHz'), + E('span', { 'style': 'margin-left:12px; color:#666;' }, + p.governor || '--') + ])); + } + + return section(_('CPU'), rows); + }, + + /* ── controls (built once, kept persistent) ──────────── */ + + renderControls: function(container) { + let s = this._status; + let cfg = s.fan_config || {}; + let cpu = s.cpu || {}; + + /* Fan mode buttons */ + let modes = ['auto', 'manual', 'off']; + let modeButtons = []; + for (let i = 0; i < modes.length; i++) { + let m = modes[i]; + let btn = E('button', { + 'class': 'cbi-button' + (cfg.mode === m ? ' cbi-button-positive' : ''), + 'data-mode': m, + 'click': L.bind(this.handleFanMode, this, m), + 'style': 'margin-right:6px;' + }, m.charAt(0).toUpperCase() + m.slice(1)); + this._fanModeButtons[m] = btn; + modeButtons.push(btn); + } + + /* Fan level slider (manual mode) */ + this._fanLevelLabel = E('span', { 'style': 'font-weight:bold; min-width:2em; display:inline-block;' }, + String(cfg.manual_level || 3)); + + this._fanLevelSlider = E('input', { + 'type': 'range', 'min': '0', 'max': '9', + 'value': String(cfg.manual_level || 3), + 'style': 'width:200px; vertical-align:middle;', + 'input': L.bind(function(ev) { + this._fanLevelLabel.textContent = ev.target.value; + }, this), + 'change': L.bind(this.handleFanLevel, this) + }); + + let levelRow = E('div', { + 'class': 'cbi-value', + 'id': 'fan-level-row', + 'style': cfg.mode === 'manual' ? '' : 'display:none;' + }, [ + E('label', { 'class': 'cbi-value-title' }, _('Fan Level')), + E('div', { 'class': 'cbi-value-field' }, [ + this._fanLevelSlider, + E('span', { 'style': 'margin-left:10px;' }, [ + this._fanLevelLabel, E('span', {}, ' / 9') + ]) + ]) + ]); + + /* Fan curve inputs (auto mode) */ + let minInput = E('input', { 'type': 'number', 'class': 'cbi-input-text', + 'id': 'fc-min', 'value': String(cfg.min_temp || 45), + 'style': 'width:60px;', 'min': '20', 'max': '80' }); + let maxInput = E('input', { 'type': 'number', 'class': 'cbi-input-text', + 'id': 'fc-max', 'value': String(cfg.max_temp || 85), + 'style': 'width:60px;', 'min': '50', 'max': '110' }); + let hystInput = E('input', { 'type': 'number', 'class': 'cbi-input-text', + 'id': 'fc-hyst', 'value': String(cfg.hysteresis || 3), + 'style': 'width:60px;', 'min': '1', 'max': '15' }); + + let curveRow = E('div', { + 'class': 'cbi-value', + 'id': 'fan-curve-row', + 'style': cfg.mode === 'auto' ? '' : 'display:none;' + }, [ + E('label', { 'class': 'cbi-value-title' }, _('Fan Curve')), + E('div', { 'class': 'cbi-value-field', 'style': 'display:flex; align-items:center; flex-wrap:wrap; gap:6px;' }, [ + E('span', {}, _('Start')), minInput, E('span', {}, '\u00b0C'), + E('span', { 'style': 'margin-left:8px;' }, _('Max')), maxInput, E('span', {}, '\u00b0C'), + E('span', { 'style': 'margin-left:8px;' }, _('Hyst')), hystInput, E('span', {}, '\u00b0C'), + E('button', { + 'class': 'cbi-button cbi-button-apply', + 'style': 'margin-left:12px;', + 'click': L.bind(this.handleFanCurve, this) + }, _('Apply')) + ]) + ]); + + /* CPU governor selector */ + let govs = []; + let currentGov = ''; + for (let p in cpu) { + if (cpu[p].governors && cpu[p].governors.length) + govs = cpu[p].governors; + if (cpu[p].governor) + currentGov = cpu[p].governor; + } + + this._govSelect = E('select', { + 'class': 'cbi-input-select', + 'change': L.bind(this.handleGovernor, this) + }); + for (let j = 0; j < govs.length; j++) { + let opt = E('option', { 'value': govs[j] }, govs[j]); + if (govs[j] === currentGov) opt.selected = true; + this._govSelect.appendChild(opt); + } + + dom.content(container, [ + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Fan Control')), + E('div', { 'class': 'cbi-section-node' }, [ + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Mode')), + E('div', { 'class': 'cbi-value-field' }, modeButtons) + ]), + levelRow, + curveRow + ]) + ]), + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('CPU Governor')), + E('div', { 'class': 'cbi-section-node' }, [ + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Governor')), + E('div', { 'class': 'cbi-value-field' }, [ + this._govSelect, + E('span', { 'style': 'margin-left:12px; color:#666;' }, + _('Applies to all CPU clusters')) + ]) + ]) + ]) + ]), + this.renderDisplayControls() + ]); + }, + + /* ── sync control visual state after poll ────────────── */ + + syncControlState: function() { + let mode = (this._status.fan_config || {}).mode || 'auto'; + + for (let m in this._fanModeButtons) { + this._fanModeButtons[m].className = + 'cbi-button' + (m === mode ? ' cbi-button-positive' : ''); + } + + let lr = document.getElementById('fan-level-row'); + if (lr) lr.style.display = (mode === 'manual') ? '' : 'none'; + + let cr = document.getElementById('fan-curve-row'); + if (cr) cr.style.display = (mode === 'auto') ? '' : 'none'; + }, + + /* ── event handlers ──────────────────────────────────── */ + + handleFanMode: function(mode) { + callSetFanMode(mode).then(L.bind(function() { + this._status.fan_config = this._status.fan_config || {}; + this._status.fan_config.mode = mode; + this.syncControlState(); + }, this)); + }, + + handleFanLevel: function(ev) { + let level = parseInt(ev.target.value); + callSetFanLevel(level); + }, + + handleFanCurve: function() { + let min = parseInt(document.getElementById('fc-min').value) || 45; + let max = parseInt(document.getElementById('fc-max').value) || 85; + let hyst = parseInt(document.getElementById('fc-hyst').value) || 3; + callSetFanCurve(min, max, hyst); + }, + + handleGovernor: function(ev) { + callSetCPUGovernor(ev.target.value); + }, + + /* ── Display controls ────────────────────────────────── */ + + renderDisplayControls: function() { + let dc = this._displayConfig; + let allPages = ['dashboard', 'clock', 'battery', 'network', 'wifi', 'thermal', 'system', 'custom']; + let activePages = dc.pages || ['dashboard']; + let themes = ['dark', 'light', 'green', 'cyan', 'amber']; + + /* Backlight toggle */ + let blOn = E('button', { + 'class': 'cbi-button' + (dc.backlight ? ' cbi-button-positive' : ''), + 'click': L.bind(this.handleDisplayApply, this, { backlight: 1 }), + 'style': 'margin-right:6px;' + }, _('On')); + let blOff = E('button', { + 'class': 'cbi-button' + (!dc.backlight ? ' cbi-button-positive' : ''), + 'click': L.bind(this.handleDisplayApply, this, { backlight: 0 }), + 'style': 'margin-right:6px;' + }, _('Off')); + + /* Theme selector */ + let themeSelect = E('select', { + 'class': 'cbi-input-select', + 'id': 'disp-theme', + 'change': L.bind(function(ev) { + this.handleDisplayApply({ theme: ev.target.value }); + }, this) + }); + for (let i = 0; i < themes.length; i++) { + let opt = E('option', { 'value': themes[i] }, themes[i].charAt(0).toUpperCase() + themes[i].slice(1)); + if (themes[i] === (dc.theme || 'dark')) opt.selected = true; + themeSelect.appendChild(opt); + } + + /* Refresh rate */ + let refreshLabel = E('span', { 'style': 'font-weight:bold; min-width:2em; display:inline-block;' }, + String(dc.refresh || 5)); + let refreshSlider = E('input', { + 'type': 'range', 'min': '1', 'max': '30', + 'value': String(dc.refresh || 5), + 'style': 'width:200px; vertical-align:middle;', + 'id': 'disp-refresh', + 'input': L.bind(function(ev) { + refreshLabel.textContent = ev.target.value; + }, this), + 'change': L.bind(function(ev) { + this.handleDisplayApply({ refresh: parseInt(ev.target.value) }); + }, this) + }); + + /* Font scale */ + let scaleSelect = E('select', { + 'class': 'cbi-input-select', + 'id': 'disp-scale', + 'change': L.bind(function(ev) { + this.handleDisplayApply({ font_scale: parseFloat(ev.target.value) }); + }, this) + }); + let scaleValues = [1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0]; + for (let si = 0; si < scaleValues.length; si++) { + let sv = scaleValues[si]; + let sopt = E('option', { 'value': String(sv) }, sv.toFixed(1) + 'x'); + if (Math.abs(sv - (dc.font_scale || 1.0)) < 0.05) sopt.selected = true; + scaleSelect.appendChild(sopt); + } + + /* Pages checkboxes */ + /* note: when using the external display the power button cycles through + pages in the order listed here (short press advances, hold ≥3s to power + off) */ + let pageLabels = { + dashboard: 'Dashboard', clock: 'Clock', battery: 'Battery', network: 'Network', + wifi: 'WiFi', thermal: 'Thermal', system: 'System', custom: 'Custom' + }; + let pageChecks = []; + for (let p = 0; p < allPages.length; p++) { + let pg = allPages[p]; + let checked = false; + for (let k = 0; k < activePages.length; k++) { + if (activePages[k] === pg) { checked = true; break; } + } + let cb = E('label', { 'style': 'margin-right:14px; white-space:nowrap;' }, [ + E('input', { + 'type': 'checkbox', + 'data-page': pg, + 'checked': checked ? '' : null, + 'change': L.bind(this.handlePagesChange, this), + 'style': 'margin-right:4px;' + }), + pageLabels[pg] || pg + ]); + pageChecks.push(cb); + } + + return E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Display')), + E('div', { 'class': 'cbi-section-node' }, [ + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Backlight')), + E('div', { 'class': 'cbi-value-field' }, [blOn, blOff]) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Theme')), + E('div', { 'class': 'cbi-value-field' }, [themeSelect]) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Refresh')), + E('div', { 'class': 'cbi-value-field' }, [ + refreshSlider, + E('span', { 'style': 'margin-left:10px;' }, [refreshLabel, E('span', {}, 's')]) + ]) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Font Scale')), + E('div', { 'class': 'cbi-value-field' }, [scaleSelect]) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Pages')), + E('div', { 'class': 'cbi-value-field', 'style': 'display:flex; flex-wrap:wrap;' }, pageChecks) + ]) + ]) + ]); + }, + + handleDisplayApply: function(overrides) { + let dc = this._displayConfig; + let opts = { + backlight: (overrides.backlight != null) ? overrides.backlight : dc.backlight, + refresh: overrides.refresh || dc.refresh || 5, + theme: overrides.theme || dc.theme || 'dark', + font_scale: (overrides.font_scale != null) ? overrides.font_scale : (dc.font_scale || 1.0), + pages: overrides.pages || dc.pages || ['dashboard'], + }; + + /* Update local cache */ + for (let k in overrides) + dc[k] = overrides[k]; + + callSetDisplayConfig(opts.backlight, opts.refresh, opts.theme, opts.font_scale, opts.pages); + }, + + handlePagesChange: function() { + let allPages = ['dashboard', 'clock', 'battery', 'network', 'wifi', 'thermal', 'system', 'custom']; + let selected = []; + for (let i = 0; i < allPages.length; i++) { + let cb = document.querySelector('input[data-page="' + allPages[i] + '"]'); + if (cb && cb.checked) + selected.push(allPages[i]); + } + if (selected.length > 0) + this.handleDisplayApply({ pages: selected }); + }, + + handleSave: null, + handleSaveApply: null, + handleReset: null +}); diff --git a/applications/luci-app-photonicat/root/etc/config/photonicat b/applications/luci-app-photonicat/root/etc/config/photonicat new file mode 100644 index 000000000000..778262f46374 --- /dev/null +++ b/applications/luci-app-photonicat/root/etc/config/photonicat @@ -0,0 +1,22 @@ +config fan 'fan' + option mode 'auto' + option manual_level '3' + option min_temp '45' + option max_temp '85' + option hysteresis '3' + option interval '3' + +config cpu 'cpu' + option governor 'schedutil' + +config display 'display' + option backlight '1' + option refresh '5' + option theme 'dark' + option font_scale '1.0' + # default pages for multi‑page UI; short‑press power button to cycle + list pages 'clock' + list pages 'cellular' + list pages 'battery' + list pages 'network' + list pages 'system' diff --git a/applications/luci-app-photonicat/root/usr/share/luci/menu.d/luci-app-photonicat.json b/applications/luci-app-photonicat/root/usr/share/luci/menu.d/luci-app-photonicat.json new file mode 100644 index 000000000000..3dcc2e39a37a --- /dev/null +++ b/applications/luci-app-photonicat/root/usr/share/luci/menu.d/luci-app-photonicat.json @@ -0,0 +1,13 @@ +{ + "admin/status/photonicat": { + "title": "Photonicat", + "order": 10, + "action": { + "type": "view", + "path": "photonicat/status" + }, + "depends": { + "acl": ["luci-app-photonicat"] + } + } +} diff --git a/applications/luci-app-photonicat/root/usr/share/rpcd/acl.d/luci-app-photonicat.json b/applications/luci-app-photonicat/root/usr/share/rpcd/acl.d/luci-app-photonicat.json new file mode 100644 index 000000000000..c495dc6ab34b --- /dev/null +++ b/applications/luci-app-photonicat/root/usr/share/rpcd/acl.d/luci-app-photonicat.json @@ -0,0 +1,44 @@ +{ + "luci-app-photonicat": { + "description": "Grant access to Photonicat 2 system status and controls", + "read": { + "ubus": { + "luci.photonicat": [ + "get_status", + "get_display_config" + ] + }, + "file": { + "/sys/class/thermal/thermal_zone*/temp": ["read"], + "/sys/class/thermal/thermal_zone*/type": ["read"], + "/sys/class/thermal/cooling_device*/cur_state": ["read"], + "/sys/class/thermal/cooling_device*/max_state": ["read"], + "/sys/class/thermal/cooling_device*/type": ["read"], + "/sys/class/hwmon/hwmon*/temp1_input": ["read"], + "/sys/class/hwmon/hwmon*/fan1_input": ["read"], + "/sys/class/hwmon/hwmon*/name": ["read"], + "/sys/class/power_supply/battery/*": ["read"], + "/sys/class/power_supply/charger/*": ["read"], + "/sys/devices/system/cpu/cpufreq/policy*/scaling_governor": ["read"], + "/sys/devices/system/cpu/cpufreq/policy*/scaling_available_governors": ["read"], + "/sys/devices/system/cpu/cpufreq/policy*/scaling_cur_freq": ["read"] + }, + "uci": ["photonicat"] + }, + "write": { + "ubus": { + "luci.photonicat": [ + "set_fan_mode", + "set_fan_level", + "set_fan_curve", + "set_cpu_governor", + "set_display_config" + ] + }, + "uci": ["photonicat"], + "file": { + "/sys/devices/system/cpu/cpufreq/policy*/scaling_governor": ["write"] + } + } + } +} diff --git a/applications/luci-app-photonicat/root/usr/share/rpcd/ucode/photonicat.uc b/applications/luci-app-photonicat/root/usr/share/rpcd/ucode/photonicat.uc new file mode 100644 index 000000000000..97375cd3c127 --- /dev/null +++ b/applications/luci-app-photonicat/root/usr/share/rpcd/ucode/photonicat.uc @@ -0,0 +1,292 @@ +#!/usr/bin/env ucode +// Photonicat 2 — rpcd ucode backend for LuCI +// Provides system status and hardware control via ubus RPC. + +'use strict'; + +import { readfile, writefile, glob, access, popen } from 'fs'; +import { cursor } from 'uci'; + +function find_hwmon(name) { + let dirs = glob('/sys/class/hwmon/hwmon*'); + for (let dir in dirs) { + let n = trim(readfile(dir + '/name') || ''); + if (n == name) + return dir; + } + return null; +} + +function find_cooling_device(type) { + let dirs = glob('/sys/class/thermal/cooling_device*'); + for (let dir in dirs) { + let t = trim(readfile(dir + '/type') || ''); + if (t == type) + return dir; + } + return null; +} + +function read_int(path) { + let val = readfile(path); + return val ? +trim(val) : null; +} + +const methods = { + get_status: { + call: function() { + let result = {}; + + // ── Thermal zones ── + let zones = []; + let tz_dirs = glob('/sys/class/thermal/thermal_zone*'); + for (let dir in tz_dirs) { + let temp = read_int(dir + '/temp'); + let type = trim(readfile(dir + '/type') || ''); + if (temp != null) + push(zones, { type: type, temp: temp / 1000.0 }); + } + result.thermal_zones = zones; + + // ── Board temperature (MCU hwmon) ── + let hwmon_temp = find_hwmon('pcat_pm_hwmon_temp_mb'); + if (hwmon_temp) { + let t = read_int(hwmon_temp + '/temp1_input'); + if (t != null) + result.board_temp = t / 1000.0; + } + + // ── Fan RPM ── + let hwmon_fan = find_hwmon('pcat_pm_hwmon_speed_fan'); + if (hwmon_fan) + result.fan_rpm = read_int(hwmon_fan + '/fan1_input'); + + // ── Fan cooling device ── + let cooling = find_cooling_device('pcat-pm-fan'); + if (cooling) { + result.fan_level = read_int(cooling + '/cur_state'); + result.fan_max_level = read_int(cooling + '/max_state'); + } + + // ── Battery ── + let bat = '/sys/class/power_supply/battery'; + if (access(bat)) { + result.battery = { + status: trim(readfile(bat + '/status') || ''), + voltage: (read_int(bat + '/voltage_now') || 0) / 1000000.0, + current: (read_int(bat + '/current_now') || 0) / 1000000.0, + capacity: read_int(bat + '/capacity') + }; + } + + // ── Charger ── + let chg = '/sys/class/power_supply/charger'; + if (access(chg)) { + result.charger = { + online: read_int(chg + '/online') + }; + } + + // ── CPU policies ── + let cpu = {}; + let policies = glob('/sys/devices/system/cpu/cpufreq/policy*'); + for (let dir in policies) { + let parts = split(dir, '/'); + let name = parts[length(parts) - 1]; + cpu[name] = { + governor: trim(readfile(dir + '/scaling_governor') || ''), + governors: split(trim(readfile(dir + '/scaling_available_governors') || ''), ' '), + cur_freq: (read_int(dir + '/scaling_cur_freq') || 0) / 1000.0, + max_freq: (read_int(dir + '/scaling_max_freq') || 0) / 1000.0, + min_freq: (read_int(dir + '/scaling_min_freq') || 0) / 1000.0 + }; + } + result.cpu = cpu; + + // ── Fan config from UCI ── + let uci = cursor(); + uci.load('photonicat'); + result.fan_config = { + mode: uci.get('photonicat', 'fan', 'mode') || 'auto', + manual_level: +(uci.get('photonicat', 'fan', 'manual_level') || '3'), + min_temp: +(uci.get('photonicat', 'fan', 'min_temp') || '45'), + max_temp: +(uci.get('photonicat', 'fan', 'max_temp') || '85'), + hysteresis: +(uci.get('photonicat', 'fan', 'hysteresis') || '3') + }; + uci.unload(); + + return result; + } + }, + + set_fan_mode: { + args: { mode: '' }, + call: function(req) { + let mode = req.args.mode; + if (mode != 'auto' && mode != 'manual' && mode != 'off') + return { error: 'Invalid mode: must be auto, manual, or off' }; + + let uci = cursor(); + uci.load('photonicat'); + uci.set('photonicat', 'fan', 'mode', mode); + uci.commit('photonicat'); + uci.unload(); + return { success: true }; + } + }, + + set_fan_level: { + args: { level: 0 }, + call: function(req) { + let level = +req.args.level; + if (level < 0 || level > 9) + return { error: 'Invalid level: must be 0-9' }; + + let uci = cursor(); + uci.load('photonicat'); + uci.set('photonicat', 'fan', 'manual_level', '' + level); + uci.commit('photonicat'); + uci.unload(); + return { success: true }; + } + }, + + set_fan_curve: { + args: { min_temp: 0, max_temp: 0, hysteresis: 0 }, + call: function(req) { + let min_t = +req.args.min_temp; + let max_t = +req.args.max_temp; + let hyst = +req.args.hysteresis; + + if (min_t < 20 || min_t > 80) + return { error: 'min_temp must be 20-80' }; + if (max_t < 50 || max_t > 110) + return { error: 'max_temp must be 50-110' }; + if (max_t <= min_t) + return { error: 'max_temp must be greater than min_temp' }; + if (hyst < 1 || hyst > 15) + return { error: 'hysteresis must be 1-15' }; + + let uci = cursor(); + uci.load('photonicat'); + uci.set('photonicat', 'fan', 'min_temp', '' + min_t); + uci.set('photonicat', 'fan', 'max_temp', '' + max_t); + uci.set('photonicat', 'fan', 'hysteresis', '' + hyst); + uci.commit('photonicat'); + uci.unload(); + return { success: true }; + } + }, + + set_cpu_governor: { + args: { governor: '' }, + call: function(req) { + let governor = req.args.governor; + let valid = ['powersave', 'performance', 'schedutil']; + if (index(valid, governor) < 0) + return { error: 'Invalid governor: must be powersave, performance, or schedutil' }; + + // Apply immediately to all CPU frequency policies + let policies = glob('/sys/devices/system/cpu/cpufreq/policy*'); + for (let dir in policies) + writefile(dir + '/scaling_governor', governor + '\n'); + + // Persist in UCI for boot + let uci = cursor(); + uci.load('photonicat'); + uci.set('photonicat', 'cpu', 'governor', governor); + uci.commit('photonicat'); + uci.unload(); + + return { success: true }; + } + }, + + get_display_config: { + call: function() { + let uci = cursor(); + uci.load('photonicat'); + + let result = { + backlight: +(uci.get('photonicat', 'display', 'backlight') || '1'), + refresh: +(uci.get('photonicat', 'display', 'refresh') || '5'), + theme: uci.get('photonicat', 'display', 'theme') || 'dark', + font_scale: +(uci.get('photonicat', 'display', 'font_scale') || '1.0'), + pages: uci.get('photonicat', 'display', 'pages') || ['clock','battery','network','wifi','thermal','system'] + }; + + // Custom parameters + let params = []; + for (let i = 0; i < 10; i++) { + let sect = 'display_param_' + i; + let label = uci.get('photonicat', sect, 'label'); + if (!label) break; + push(params, { + label: label, + source: uci.get('photonicat', sect, 'source') || '', + unit: uci.get('photonicat', sect, 'unit') || '', + divide: +(uci.get('photonicat', sect, 'divide') || '0') + }); + } + result.custom_params = params; + + uci.unload(); + return result; + } + }, + + set_display_config: { + args: { backlight: 0, refresh: 0, theme: '', font_scale: 0.0, pages: [] }, + call: function(req) { + let a = req.args; + + let valid_themes = ['dark', 'light', 'green', 'cyan', 'amber']; + if (a.theme && index(valid_themes, a.theme) < 0) + return { error: 'Invalid theme' }; + + let valid_pages = ['dashboard', 'clock', 'battery', 'network', 'wifi', 'thermal', 'system', 'custom']; + + let uci = cursor(); + uci.load('photonicat'); + + // Ensure display section exists + if (!uci.get('photonicat', 'display')) { + uci.set('photonicat', 'display', 'display'); + } + + if (a.backlight != null) + uci.set('photonicat', 'display', 'backlight', '' + (a.backlight ? 1 : 0)); + + if (a.refresh != null && a.refresh >= 1 && a.refresh <= 60) + uci.set('photonicat', 'display', 'refresh', '' + a.refresh); + + if (a.theme) + uci.set('photonicat', 'display', 'theme', a.theme); + + if (a.font_scale != null && +a.font_scale >= 1.0 && +a.font_scale <= 2.0) + uci.set('photonicat', 'display', 'font_scale', sprintf('%.1f', +a.font_scale)); + + if (a.pages && length(a.pages) > 0) { + // Validate pages + let filtered = []; + for (let p in a.pages) { + if (index(valid_pages, p) >= 0) + push(filtered, p); + } + if (length(filtered) > 0) + uci.set('photonicat', 'display', 'pages', filtered); + } + + uci.commit('photonicat'); + uci.unload(); + + // Signal display daemon to reload config + let ph = popen('killall -HUP pcat2-display 2>/dev/null'); + if (ph) ph.close(); + + return { success: true }; + } + } +}; + +return { 'luci.photonicat': methods };