diff --git a/luci-app-thermal-guard/Makefile b/luci-app-thermal-guard/Makefile
new file mode 100644
index 000000000..1d304247a
--- /dev/null
+++ b/luci-app-thermal-guard/Makefile
@@ -0,0 +1,26 @@
+#
+# Copyright (C) 2026 SyntaxError NullPointer
+#
+# This is free software, licensed under the GNU General Public License v2.
+# See /LICENSE for more information.
+#
+
+include $(TOPDIR)/rules.mk
+
+PKG_VERSION:=2.0.0
+PKG_RELEASE:=1
+
+PKG_LICENSE:=GPL-2.0-only
+PKG_MAINTAINER:=SyntaxError NullPointer <287466119+SyntaxError-NullPointer@users.noreply.github.com>
+
+LUCI_TITLE:=LuCI support for thermal-guard
+LUCI_DESCRIPTION:=Monitor and configuration pages for thermal-guard: temperatures, fan, protection stages and thresholds.
+LUCI_DEPENDS:=+luci-base +thermal-guard
+LUCI_PKGARCH:=all
+
+include $(TOPDIR)/feeds/luci/luci.mk
+
+# No own postinst: luci.mk's default drops the menu caches and reloads rpcd,
+# without which the new ACL is not loaded and the pages are refused.
+
+# call BuildPackage - OpenWrt buildroot signature
diff --git a/luci-app-thermal-guard/htdocs/luci-static/resources/view/status/include/11_thermal-guard.js b/luci-app-thermal-guard/htdocs/luci-static/resources/view/status/include/11_thermal-guard.js
new file mode 100644
index 000000000..a5818f86b
--- /dev/null
+++ b/luci-app-thermal-guard/htdocs/luci-static/resources/view/status/include/11_thermal-guard.js
@@ -0,0 +1,113 @@
+// SPDX-License-Identifier: GPL-2.0-only
+// Adds a temperature row to Status -> Overview. Reads the status file the daemon
+// writes, so the overview never talks to the modem itself.
+'use strict';
+'require baseclass';
+'require fs';
+
+var STATUS_FILE = '/var/run/thermal-guard.status';
+
+function parseStatus(text) {
+ var st = {};
+ (text || '').split('\n').forEach(function(line) {
+ var i = line.indexOf('=');
+ if (i > 0)
+ st[line.substring(0, i)] = line.substring(i + 1).trim();
+ });
+ return st;
+}
+
+function num(v) {
+ var n = parseInt(v, 10);
+ return isNaN(n) ? null : n;
+}
+
+// Sensor names come straight from the kernel. Translate the ones people actually
+// recognise, leave the rest as reported rather than guessing.
+function sensorLabel(name) {
+ var m = name.match(/^(?:mt79\d\d|mtwifi)_phy(\d)/);
+ if (m)
+ return _('Wi-Fi %d').format(parseInt(m[1], 10) + 1);
+ if (name.indexOf('mdio_bus') === 0 || name.indexOf('phy') === 0)
+ return _('Ethernet');
+ if (name === 'nvme')
+ return _('SSD');
+ return name;
+}
+
+function extraSensors(st) {
+ var out = [];
+ for (var k in st) {
+ if (k.indexOf('sensor.') === 0 && st[k] !== '')
+ out.push([ sensorLabel(k.substring(7)), parseInt(st[k], 10) ]);
+ }
+ return out.sort(function(a, b) { return a[0] > b[0] ? 1 : -1; });
+}
+
+function temp(value, warn, crit) {
+ if (value === null)
+ return E('em', {}, _('no reading'));
+
+ var colour = '';
+ if (crit !== null && value >= crit)
+ colour = 'color:#f44336;font-weight:bold';
+ else if (warn !== null && value >= warn)
+ colour = 'color:#ff9800;font-weight:bold';
+
+ return E('span', { 'style': colour }, '%d °C'.format(value));
+}
+
+return baseclass.extend({
+ title: _('Temperatures'),
+
+ load: function() {
+ return fs.read(STATUS_FILE).catch(function() { return null; });
+ },
+
+ render: function(text) {
+ if (text === null)
+ return null;
+
+ var st = parseStatus(text);
+ var stage = num(st.stage);
+ var rows = [
+ [ _('Processor'), temp(num(st.cpu_c), num(st.cpu_warn), num(st.cpu_crit)) ]
+ ];
+
+ if (st.modem_src || st.modem_c)
+ rows.push([ _('Modem'), temp(num(st.modem_c), num(st.modem_warn), num(st.modem_crit)) ]);
+
+ extraSensors(st).forEach(function(s) {
+ rows.push([ s[0], temp(s[1], null, null) ]);
+ });
+
+ // fan_view is what can be read, even where fan_mode leaves the fan to
+ // another controller. Older status files only carry fan_mode.
+ var fanView = st.fan_view || st.fan_mode;
+ if (fanView && fanView !== 'none') {
+ var state = num(st.fan_state), max = num(st.fan_max), rpm = num(st.fan_rpm),
+ percent = num(st.fan_percent);
+ var label = (percent !== null)
+ ? '%d %%'.format(percent)
+ : (state === null ? '-'
+ : (max ? _('fan step %d of %d').format(state, max) : String(state)));
+ if (rpm !== null && rpm > 0)
+ label += ' (%d rpm)'.format(rpm);
+ rows.push([ _('Fan'), label ]);
+ }
+
+ if (stage !== null && stage >= 0)
+ rows.push([
+ _('Thermal Guard'),
+ E('span', { 'style': 'color:#f44336;font-weight:bold' },
+ _('protection stage %d active').format(stage + 1))
+ ]);
+
+ return E('table', { 'class': 'table' }, rows.map(function(row) {
+ return E('tr', { 'class': 'tr' }, [
+ E('td', { 'class': 'td left', 'width': '33%' }, row[0]),
+ E('td', { 'class': 'td left' }, row[1])
+ ]);
+ }));
+ }
+});
diff --git a/luci-app-thermal-guard/htdocs/luci-static/resources/view/thermal-guard/config.js b/luci-app-thermal-guard/htdocs/luci-static/resources/view/thermal-guard/config.js
new file mode 100644
index 000000000..3fcf4b600
--- /dev/null
+++ b/luci-app-thermal-guard/htdocs/luci-static/resources/view/thermal-guard/config.js
@@ -0,0 +1,431 @@
+// SPDX-License-Identifier: GPL-2.0-only
+'use strict';
+'require view';
+'require form';
+'require uci';
+'require fs';
+'require ui';
+
+var STATUS_FILE = '/var/run/thermal-guard.status';
+
+function parseStatus(text) {
+ var st = {};
+ (text || '').split('\n').forEach(function(line) {
+ var i = line.indexOf('=');
+ if (i > 0)
+ st[line.substring(0, i)] = line.substring(i + 1).trim();
+ });
+ return st;
+}
+
+// The daemon reports why it cannot manage the trips as a fixed code.
+function tripReasonText(code) {
+ switch (code) {
+ case 'no_cdev': return _('the kernel knows no fan on this device');
+ case 'no_zone': return _('no thermal zone drives the fan');
+ case 'zone_path': return _('the fan\'s thermal zone is not under /sys/class/thermal');
+ case 'no_active_trip': return _('the fan is not bound to any switching temperature');
+ case 'not_writable': return _('the switching temperatures cannot be changed');
+ default: return code || _('unknown reason');
+ }
+}
+
+function tripList(v) {
+ return (v || '').split('/').filter(function(x) { return x !== ''; });
+}
+
+// The daemon's defaults, from tg_cfg_default_thresholds. An empty field means
+// the daemon uses these, so the order is checked against them as well.
+var THRESHOLD_DEFAULTS = {
+ cpu_warn: 70, cpu_crit: 80, cpu_emergency: 90, modem_warn: 80, modem_crit: 88
+};
+
+// The value the daemon will use: the default when empty, NaN when it is not
+// a whole number.
+function thresholdValue(option, value) {
+ if (value == null || value === '')
+ return THRESHOLD_DEFAULTS[option];
+ return /^[0-9]+$/.test(value) ? parseInt(value, 10) : NaN;
+}
+
+// Thresholds have to stay ordered, otherwise the service falls back to its
+// defaults and the protection silently stops doing what the form says. The
+// neighbours are read from the form, not from the saved configuration, so a
+// change made next to this one counts.
+function checkOrder(section, section_id, option, value, below, above, belowLabel, aboveLabel) {
+ var v = thresholdValue(option, value);
+ if (isNaN(v))
+ return _('Enter a whole number');
+
+ if (below) {
+ var b = thresholdValue(below, section.formvalue(section_id, below));
+ if (!isNaN(b) && v <= b)
+ return _('Must be above %s (%d)').format(belowLabel, b);
+ }
+ if (above) {
+ var a = thresholdValue(above, section.formvalue(section_id, above));
+ if (!isNaN(a) && v >= a)
+ return _('Must be below %s (%d)').format(aboveLabel, a);
+ }
+ return true;
+}
+
+// Mirrors the guards in backends.sh. The service rejects anything outside these
+// subtrees anyway, so catching it here saves a silent fallback after Apply.
+function checkPathPrefix(value, prefixes) {
+ if (!value)
+ return true;
+ if (value.charAt(0) !== '/' || value.indexOf('..') >= 0)
+ return _('Must be an absolute path without ".."');
+ for (var i = 0; i < prefixes.length; i++)
+ if (value.indexOf(prefixes[i]) === 0)
+ return true;
+ return _('Path must start with %s').format(prefixes.join(', '));
+}
+
+return view.extend({
+ load: function() {
+ return fs.read(STATUS_FILE).catch(function() { return null; });
+ },
+
+ render: function(statusText) {
+ var m, s, o,
+ st = parseStatus(statusText),
+ tripDt = tripList(st.trip_dt),
+ tripLimit = parseInt(st.trip_limit, 10);
+
+ m = new form.Map('thermal-guard', _('Thermal Guard'),
+ _('Monitors the temperature of the main processor and the modem. If the device gets too hot, it intervenes in up to three protection stages. Stages stay active until you clear them on the status page.'));
+
+ s = m.section(form.NamedSection, 'main', 'thermal-guard');
+ s.addremove = false;
+ s.tab('general', _('General'));
+ s.tab('thresholds', _('Thresholds'),
+ _('Temperatures at which each protection stage starts. What actually happens at stage 2 and 3 is set on the Actions tab.'));
+ s.tab('fan', _('Fan'));
+ s.tab('modem', _('Modem'));
+ s.tab('actions', _('Actions'),
+ _('What happens when a stage starts. Stage 1 of 3 warns and, if this service drives the fan (Fan tab), sets it to full speed. That cannot be changed.'));
+
+ // ---- general ----
+ o = s.taboption('general', form.Flag, 'enabled', _('Enabled'),
+ _('Switching this off pauses the temperature checks. Protection stages that are already active stay applied and the fan is not handed back, so a broken fan cannot be hidden by a checkbox. Use Clear on the status page for that.'));
+ o.default = '1';
+ o.rmempty = false;
+
+ o = s.taboption('general', form.Value, 'interval', _('Interval'),
+ _('Seconds between two temperature readings. Values below five seconds are treated as five.'));
+ o.datatype = 'and(uinteger,min(5))';
+ o.placeholder = '20';
+
+ o = s.taboption('general', form.Value, 'cpu_temp_path', _('Processor temperature source'),
+ _('System file the processor temperature is read from. The default fits most devices; change it only if your device reports its temperature elsewhere.'));
+ o.placeholder = '/sys/class/thermal/thermal_zone0/temp';
+ o.validate = function(id, v) {
+ return checkPathPrefix(v, ['/sys/class/thermal/', '/sys/class/hwmon/', '/sys/devices/']);
+ };
+
+ // ---- thresholds ----
+ o = s.taboption('thresholds', form.Value, 'cpu_warn', _('Processor warning (stage 1 of 3)'),
+ _('Stage 1 of 3 starts when either temperature reaches its warning level: a warning, and full fan speed if this service drives the fan.'));
+ o.placeholder = '70';
+ o.validate = function(id, v) {
+ return checkOrder(this.section, id, 'cpu_warn', v, null, 'cpu_crit', null, _('Processor critical'));
+ };
+
+ o = s.taboption('thresholds', form.Value, 'cpu_crit', _('Processor critical (stage 2 of 3)'));
+ o.placeholder = '80';
+ o.validate = function(id, v) {
+ return checkOrder(this.section, id, 'cpu_crit', v, 'cpu_warn', 'cpu_emergency', _('Processor warning'), _('Processor emergency'));
+ };
+
+ o = s.taboption('thresholds', form.Value, 'cpu_emergency', _('Processor emergency (stage 3 of 3)'));
+ o.placeholder = '90';
+ o.validate = function(id, v) {
+ return checkOrder(this.section, id, 'cpu_emergency', v, 'cpu_crit', null, _('Processor critical'), null);
+ };
+
+ o = s.taboption('thresholds', form.Value, 'modem_warn', _('Modem warning (stage 1 of 3)'));
+ o.placeholder = '80';
+ o.validate = function(id, v) {
+ return checkOrder(this.section, id, 'modem_warn', v, null, 'modem_crit', null, _('Modem critical'));
+ };
+
+ o = s.taboption('thresholds', form.Value, 'modem_crit', _('Modem critical (stage 2 of 3)'));
+ o.placeholder = '88';
+ o.validate = function(id, v) {
+ return checkOrder(this.section, id, 'modem_crit', v, 'modem_warn', null, _('Modem warning'), null);
+ };
+
+ o = s.taboption('thresholds', form.Value, 'hold_minutes', _('Hold time'),
+ _('Minutes a stage has to last before the next one starts on time alone: stage 2 of 3 if the temperature has not dropped, stage 3 of 3 if it is still rising.'));
+ o.datatype = 'uinteger';
+ o.placeholder = '3';
+
+ o = s.taboption('thresholds', form.Value, 'hysteresis', _('Hysteresis'),
+ _('How many degrees the temperature has to fall below the warning level before the device counts as cooled down.'));
+ o.datatype = 'uinteger';
+ o.placeholder = '10';
+
+ // ---- fan ----
+ o = s.taboption('fan', form.ListValue, 'fan_mode', _('Fan control method'),
+ _('Leave the fan to the regulator the device already has, unless it has none. Automatic uses the kernel fan control when the device offers it and otherwise leaves the fan alone. It never picks direct control, which needs the fan\'s polarity.'));
+ o.value('none', _('Leave the fan alone'));
+ o.value('auto', _('Automatic'));
+ o.value('cooling_device', _('Kernel fan control'));
+ o.value('pwm', _('Direct fan control (PWM)'));
+ o.default = 'none';
+
+ o = s.taboption('fan', form.Value, 'fan_cooling_device_type', _('Fan identifier'),
+ _('Name the kernel uses for the fan. Change it only if the fan is not found automatically.'));
+ o.placeholder = 'pwm-fan';
+ o.depends('fan_mode', 'auto');
+ o.depends('fan_mode', 'cooling_device');
+
+ o = s.taboption('fan', form.ListValue, 'fan_governor', _('Control mode to restore'),
+ _('Control mode handed back to the kernel when the stages are cleared and no earlier control mode was recorded. The default fits almost every device.'));
+ // The daemon's list, kernel names and not translated. Not user_space:
+ // nothing would regulate the fan after a reset.
+ [ 'step_wise', 'fair_share', 'bang_bang', 'power_allocator' ].forEach(function(g) {
+ o.value(g);
+ });
+ o.default = 'step_wise';
+ o.depends('fan_mode', 'auto');
+ o.depends('fan_mode', 'cooling_device');
+
+ o = s.taboption('fan', form.Value, 'pwm_path', _('Fan speed file'),
+ _('Only needed for direct fan control.'));
+ o.placeholder = '/sys/class/hwmon/hwmon0/pwm1';
+ o.depends('fan_mode', 'pwm');
+ o.validate = function(id, v) { return checkPathPrefix(v, ['/sys/class/hwmon/', '/sys/devices/']); };
+
+ o = s.taboption('fan', form.Value, 'pwm_enable_path', _('Fan control file'),
+ _('Only needed for direct fan control. Switches the fan chip to manual control.'));
+ o.placeholder = '/sys/class/hwmon/hwmon0/pwm1_enable';
+ o.depends('fan_mode', 'pwm');
+ o.validate = function(id, v) { return checkPathPrefix(v, ['/sys/class/hwmon/', '/sys/devices/']); };
+
+ o = s.taboption('fan', form.Value, 'pwm_full', _('Value for full speed'),
+ _('Required for direct fan control. The raw value that makes this fan spin fastest. Some boards wire the signal inverted, where 1 is full speed and 255 stops the fan. Without this value and the slowest one, the fan is left alone.'));
+ o.datatype = 'uinteger';
+ o.depends('fan_mode', 'pwm');
+ o.rmempty = false;
+
+ o = s.taboption('fan', form.Value, 'pwm_idle', _('Value for the slowest setting'),
+ _('Required for direct fan control. The opposite end of the same scale. Together with full speed it states the polarity, and it shows the fan speed as a percentage. Never written to the hardware.'));
+ o.datatype = 'uinteger';
+ o.depends('fan_mode', 'pwm');
+ o.rmempty = false;
+
+ // ---- switching temperatures of the kernel fan control ----
+ o = s.taboption('fan', form.DummyValue, '_trip_state', _('Kernel fan control'));
+ o.cfgvalue = function() {
+ switch (st.trip_state) {
+ case 'active':
+ return _('Managed by this service, switching temperatures now %s °C').format(tripList(st.trip_now).join(', '));
+ case 'inactive':
+ return _('Not possible on this device: %s').format(tripReasonText(st.trip_reason));
+ default:
+ return _('Not managed by this service');
+ }
+ };
+
+ o = s.taboption('fan', form.ListValue, 'trip_boost', _('Speed the fan up for the modem'),
+ _('Where the kernel drives the fan, its switching temperatures are lowered while the modem runs hotter than the processor, so the fan speeds up for the modem as well. They only ever go down, never above the base values. A missing modem reading keeps them where they are.'));
+ o.value('off', _('Off'));
+ o.value('modem', _('Follow the modem temperature'));
+ o.default = 'off';
+
+ o = s.taboption('fan', form.Value, 'trip_boost_offset', _('Modem allowance'),
+ _('Degrees the modem may run above the processor before the switching temperatures are lowered.'));
+ o.datatype = 'range(0,40)';
+ o.placeholder = '15';
+ o.depends('trip_boost', 'modem');
+
+ o = s.taboption('fan', form.DynamicList, 'trip_active', _('Fan switching temperatures'),
+ tripDt.length
+ ? _('Base values in degrees at which the kernel moves the fan one step up, lowest first, one value per step. Empty uses the values of the installed firmware: %s °C.').format(tripDt.join(', '))
+ : _('Base values in degrees at which the kernel moves the fan one step up, lowest first, one value per step. Empty uses the values of the installed firmware.'));
+ // Same rules as the service, which falls back to the firmware values
+ // on anything it rejects.
+ o.validate = function(section_id, value) {
+ var list = L.toArray(this.formvalue(section_id)), prev = null;
+ for (var i = 0; i < list.length; i++) {
+ var raw = String(list[i]).trim(), v = parseInt(raw, 10);
+ if (isNaN(v) || String(v) !== raw)
+ return _('Enter a whole number');
+ if (v < 20 || v > 95)
+ return _('Must be between 20 and 95');
+ if (prev !== null && v <= prev)
+ return _('Each value must be higher than the one before');
+ if (!isNaN(tripLimit) && v >= tripLimit)
+ return _('Must be below %d, the temperature at which the kernel itself intervenes').format(tripLimit);
+ prev = v;
+ }
+ if (list.length && tripDt.length && list.length !== tripDt.length)
+ return _('Needs exactly %d values, one per fan step').format(tripDt.length);
+ return true;
+ };
+
+ if (st.trip_dt_changed === '1' && tripDt.length) {
+ o = s.taboption('fan', form.DummyValue, '_trip_dt_hint', _('New firmware values'));
+ o.cfgvalue = function() {
+ return _('The installed firmware now uses %s °C. Your own base values were set for an earlier firmware and still apply.').format(tripDt.join(', '));
+ };
+
+ // The daemon keeps the firmware values trip_active was made for in
+ // a file of its own; trips-keep records the ones of this boot.
+ // Resolves to true when that worked.
+ var keepTrips = function() {
+ var failed = function(why) {
+ ui.addNotification(null, E('p', {}, _('Recording the firmware values failed: %s').format(why)), 'error');
+ return false;
+ };
+ return fs.exec('/usr/sbin/thermal-guard', [ 'trips-keep' ]).then(function(res) {
+ if (res.code !== 0)
+ return failed(res.stderr || res.stdout || res.code);
+ ui.addNotification(null, E('p', {}, _('Firmware values recorded. The notice goes away with the next check.')), 'info');
+ return true;
+ }).catch(failed);
+ };
+
+ o = s.taboption('fan', form.Button, '_trip_dt_adopt', ' ');
+ o.inputtitle = _('Use the firmware values');
+ // Parse the form first, then set the values, otherwise the form
+ // writes the old list back over them. Apply reloads the page, so
+ // it comes last.
+ o.onclick = function() {
+ return m.save(function() {
+ uci.set('thermal-guard', 'main', 'trip_active', tripDt);
+ }).then(keepTrips).then(function(ok) {
+ if (ok)
+ return ui.changes.apply(true);
+ });
+ };
+
+ o = s.taboption('fan', form.Button, '_trip_dt_keep', ' ');
+ o.inputtitle = _('Keep my values');
+ o.onclick = keepTrips;
+ }
+
+ if (st.fan_levels) {
+ o = s.taboption('fan', form.DummyValue, '_fan_levels', _('Fan steps of the firmware'),
+ _('Raw value per fan step from the device tree, with the duty cycle of the signal. They belong to the fan that is fitted and change only with a new firmware image.'));
+ o.cfgvalue = function() {
+ return st.fan_levels.split(' ').map(function(v, i) {
+ return _('step %d: %d (%d %%)').format(i, parseInt(v, 10), Math.round(parseInt(v, 10) * 100 / 255));
+ }).join(', ');
+ };
+ }
+
+ // ---- modem ----
+ o = s.taboption('modem', form.ListValue, 'modem_source', _('Modem temperature source'),
+ _('Automatic tries the known modem commands in turn. Select your manufacturer if detection fails.'));
+ o.value('auto', _('Automatic'));
+ o.value('quectel', _('Quectel (AT+QTEMP)'));
+ o.value('fibocom', _('Fibocom (AT+GTSENRDTEMP)'));
+ o.value('file', _('File'));
+ o.value('command', _('Command'));
+ o.value('none', _('No modem'));
+ o.default = 'auto';
+
+ o = s.taboption('modem', form.Value, 'modem_at_port', _('Modem control port'),
+ _('Serial port the commands are sent to. Automatic checks the usual ports.'));
+ o.placeholder = 'auto';
+ o.depends('modem_source', 'auto');
+ o.depends('modem_source', 'quectel');
+ o.depends('modem_source', 'fibocom');
+ o.validate = function(id, v) {
+ if (v === 'auto')
+ return true;
+ return checkPathPrefix(v, ['/dev/']);
+ };
+
+ o = s.taboption('modem', form.Value, 'at_lock', _('Lock file for modem access'),
+ _('Prevents this service and other programs from sending commands to the modem at the same time. Point those programs at the same file.'));
+ o.placeholder = '/var/lock/modem-at.lock';
+ o.depends('modem_source', 'auto');
+ o.depends('modem_source', 'quectel');
+ o.depends('modem_source', 'fibocom');
+ o.validate = function(id, v) { return checkPathPrefix(v, ['/var/lock/', '/var/run/', '/tmp/']); };
+
+ o = s.taboption('modem', form.Value, 'modem_temp_file', _('Temperature file'));
+ o.depends('modem_source', 'file');
+ o.validate = function(id, v) { return checkPathPrefix(v, ['/var/run/', '/tmp/', '/sys/']); };
+
+ o = s.taboption('modem', form.DummyValue, '_modem_temp_hook', _('Own temperature source'),
+ _('Reads /etc/thermal-guard/hooks/modem-temp, which has to print the temperature in degrees as a whole number. Place the file over SSH; it runs as root and is deliberately not editable from here.'));
+ o.depends('modem_source', 'command');
+
+ // ---- actions ----
+ o = s.taboption('actions', form.ListValue, 'stage1_action', _('Action at stage 2 of 3'));
+ o.value('modem_radio_off', _('Switch the modem radio off'));
+ o.value('wifi_off', _('Switch Wi-Fi off'));
+ o.value('interface_down', _('Take a network interface down'));
+ o.value('command', _('Run own script'));
+ o.value('none', _('Nothing'));
+ o.default = 'modem_radio_off';
+
+ o = s.taboption('actions', form.DummyValue, '_stage1_hook', _('Script at stage 2 of 3'),
+ _('Runs /etc/thermal-guard/hooks/stage1. Place the file over SSH and make it executable.'));
+ o.depends('stage1_action', 'command');
+
+ o = s.taboption('actions', form.ListValue, 'stage2_action', _('Action at stage 3 of 3'));
+ o.value('wifi_off', _('Switch Wi-Fi off'));
+ o.value('modem_radio_off', _('Switch the modem radio off'));
+ o.value('interface_down', _('Take a network interface down'));
+ o.value('command', _('Run own script'));
+ o.value('none', _('Nothing'));
+ o.default = 'wifi_off';
+
+ o = s.taboption('actions', form.DummyValue, '_stage2_hook', _('Script at stage 3 of 3'),
+ _('Runs /etc/thermal-guard/hooks/stage2. Place the file over SSH and make it executable.'));
+ o.depends('stage2_action', 'command');
+
+ o = s.taboption('actions', form.ListValue, 'modem_radio_off_at', _('How the modem is switched off'),
+ _('Flight mode suits most modems. Use minimum function for a modem that does not accept flight mode. Either way the modem is asked afterwards (AT+CFUN?), and a modem that does not confirm counts as a failed stage.'));
+ o.value('AT+CFUN=4', _('Flight mode (AT+CFUN=4)'));
+ o.value('AT+CFUN=0', _('Minimum function (AT+CFUN=0)'));
+ o.default = 'AT+CFUN=4';
+ o.depends('stage1_action', 'modem_radio_off');
+ o.depends('stage2_action', 'modem_radio_off');
+
+ o = s.taboption('actions', form.Value, 'action_interface', _('Interface to take down'),
+ _('Network interface taken down by the interface action and brought back up when the stages are cleared. Empty uses the modem network interface.'));
+ o.datatype = 'uciname';
+ o.depends('stage1_action', 'interface_down');
+ o.depends('stage2_action', 'interface_down');
+
+ o = s.taboption('actions', form.Value, 'notify_url', _('Notification address'),
+ _('The warning text is posted here. Works with ntfy, Gotify, Matrix, Slack and anything else that accepts a plain POST body.'));
+ o.placeholder = 'https://ntfy.sh/my-topic';
+ // Anchored at both ends, and quotes, backslashes and control characters
+ // are refused: the service writes this into a curl config file, where
+ // any of them would turn the rest of the value into directives.
+ o.validate = function(id, v) {
+ if (!v)
+ return true;
+ if (!/^https?:\/\/[\x21-\x7e]+$/.test(v) || /["\\]/.test(v))
+ return _('Must be an http:// or https:// address without quotes or spaces');
+ return true;
+ };
+
+ o = s.taboption('actions', form.Value, 'notify_header', _('Additional header'),
+ _('Sent with the notification, for example an authorisation token. Needs curl installed; the smaller uclient-fetch cannot send headers.'));
+ o.placeholder = 'Authorization: Bearer ...';
+ o.password = true;
+ o.validate = function(id, v) {
+ if (!v)
+ return true;
+ if (!/^[\x20-\x7e]+$/.test(v) || /["\\]/.test(v))
+ return _('Must be printable text without quotes or backslashes');
+ return true;
+ };
+
+ o = s.taboption('actions', form.Value, 'uplink_interface', _('Modem network interface'),
+ _('Lets the service tell whether another internet connection exists, so a message from stage 2 of 3 on goes out or waits in the queue. Also the interface the interface action takes when none is set.'));
+ o.datatype = 'uciname';
+
+ return m.render();
+ }
+});
diff --git a/luci-app-thermal-guard/htdocs/luci-static/resources/view/thermal-guard/monitor.js b/luci-app-thermal-guard/htdocs/luci-static/resources/view/thermal-guard/monitor.js
new file mode 100644
index 000000000..be98eb476
--- /dev/null
+++ b/luci-app-thermal-guard/htdocs/luci-static/resources/view/thermal-guard/monitor.js
@@ -0,0 +1,298 @@
+// SPDX-License-Identifier: GPL-2.0-only
+'use strict';
+'require view';
+'require poll';
+'require dom';
+'require fs';
+'require ui';
+
+var STATUS_FILE = '/var/run/thermal-guard.status';
+
+function parseStatus(text) {
+ var st = {};
+ (text || '').split('\n').forEach(function(line) {
+ var i = line.indexOf('=');
+ if (i > 0)
+ st[line.substring(0, i)] = line.substring(i + 1).trim();
+ });
+ return st;
+}
+
+function num(v) {
+ var n = parseInt(v, 10);
+ return isNaN(n) ? null : n;
+}
+
+// Colour follows the configured thresholds, not a fixed scale, so the bar means
+// the same thing on a passively cooled box and on one with a fan.
+function tempClass(value, warn, crit) {
+ if (value === null)
+ return '';
+ if (crit !== null && value >= crit)
+ return 'tg-crit';
+ if (warn !== null && value >= warn)
+ return 'tg-warn';
+ return 'tg-ok';
+}
+
+function gauge(value, warn, crit, max) {
+ var pct = (value === null) ? 0 : Math.max(0, Math.min(100, Math.round(value * 100 / max)));
+ return E('div', { 'class': 'tg-gauge' }, [
+ E('div', {
+ 'class': 'tg-fill ' + tempClass(value, warn, crit),
+ 'style': 'width:%d%%'.format(pct)
+ })
+ ]);
+}
+
+// Sensor names come straight from the kernel. Translate the ones people actually
+// recognise, leave the rest as reported rather than guessing.
+function sensorLabel(name) {
+ var m = name.match(/^(?:mt79\d\d|mtwifi)_phy(\d)/);
+ if (m)
+ return _('Wi-Fi %d').format(parseInt(m[1], 10) + 1);
+ if (name.indexOf('mdio_bus') === 0 || name.indexOf('phy') === 0)
+ return _('Ethernet');
+ if (name === 'nvme')
+ return _('SSD');
+ return name;
+}
+
+function extraSensors(st) {
+ var out = [];
+ for (var k in st) {
+ if (k.indexOf('sensor.') === 0 && st[k] !== '')
+ out.push([ sensorLabel(k.substring(7)), parseInt(st[k], 10) ]);
+ }
+ return out.sort(function(a, b) { return a[0] > b[0] ? 1 : -1; });
+}
+
+function row(label, valueNode, gaugeNode) {
+ return E('div', { 'class': 'tg-row' }, [
+ E('div', { 'class': 'tg-label' }, label),
+ gaugeNode || E('div', { 'class': 'tg-gauge tg-empty' }, []),
+ E('div', { 'class': 'tg-value' }, valueNode)
+ ]);
+}
+
+// Stage 2 and 3 do whatever the Actions tab says, so the banner has to read the
+// configured action instead of naming the default.
+function actionLabel(action) {
+ switch (action) {
+ case 'modem_radio_off': return _('modem radio off');
+ case 'wifi_off': return _('Wi-Fi off');
+ case 'interface_down': return _('network interface down');
+ case 'command': return _('custom command');
+ case 'none': return _('no action');
+ default: return action || _('unknown action');
+ }
+}
+
+function stageText(stage, st) {
+ switch (stage) {
+ case 0: return st.fan_mode === 'none'
+ ? _('Stage 1 of 3 active: warning, fan left to its own controller')
+ : _('Stage 1 of 3 active: fan at full speed');
+ case 1: return _('Stage 2 of 3 active: %s').format(actionLabel(st.stage1_action));
+ case 2: return _('Stage 3 of 3 active: %s').format(actionLabel(st.stage2_action));
+ default: return _('No intervention, temperatures within range');
+ }
+}
+
+// Why the page might be showing something other than a live reading. The daemon
+// stamps every write with "now": a file that stopped being written means the
+// service is gone, while a fresh file holding an old reading means it is
+// running but not measuring.
+function serviceState(st) {
+ var written = parseInt(st.now, 10),
+ interval = parseInt(st.interval, 10) || 30,
+ age = written ? Math.floor(Date.now() / 1000) - written : null;
+
+ // Three missed writes, with a floor so a short interval is not jumpy.
+ if (age !== null && age > Math.max(90, interval * 3))
+ return 'stopped';
+ if (st.enabled === '0')
+ return 'paused';
+ return 'running';
+}
+
+function fmtAge(ts) {
+ var n = parseInt(ts, 10);
+ if (!n)
+ return '-';
+ var age = Math.max(0, Math.floor(Date.now() / 1000) - n);
+ if (age < 90)
+ return _('%d s ago').format(age);
+ return _('%d min ago').format(Math.round(age / 60));
+}
+
+return view.extend({
+ load: function() {
+ return fs.read(STATUS_FILE).catch(function() { return null; });
+ },
+
+ render: function(text) {
+ var style = E('style', {}, [
+ '.tg-row{display:flex;align-items:center;gap:.6em;margin:.35em 0}',
+ '.tg-label{flex:0 0 12em}',
+ '.tg-value{flex:0 0 7em;text-align:right;font-weight:bold}',
+ '.tg-gauge{flex:1 1 auto;height:1.1em;background:rgba(128,128,128,.25);border-radius:.55em;overflow:hidden;min-width:6em}',
+ '.tg-gauge.tg-empty{background:transparent}',
+ '.tg-fill{height:100%;border-radius:.55em;transition:width .4s}',
+ '.tg-ok{background:#4caf50}.tg-warn{background:#ff9800}.tg-crit{background:#f44336}',
+ '.tg-banner{padding:.6em .8em;border-radius:.3em;margin-bottom:1em}',
+ '.tg-banner.tg-active{background:rgba(244,67,54,.15);border-left:4px solid #f44336}',
+ '.tg-banner.tg-idle{background:rgba(76,175,80,.12);border-left:4px solid #4caf50}',
+ '.tg-banner.tg-warn-banner{background:rgba(255,152,0,.15);border-left:4px solid #ff9800}',
+ '.tg-meta{opacity:.7;font-size:90%;margin-top:.8em}'
+ ].join('\n'));
+
+ var body = E('div', { 'id': 'tg-body' }, []);
+ var container = E('div', { 'class': 'cbi-map' }, [
+ style,
+ E('h2', {}, _('Thermal Guard')),
+ E('div', { 'class': 'cbi-map-descr' },
+ _('Current temperatures and protection state. Thresholds and actions are configured under System, Thermal Guard.')),
+ body
+ ]);
+
+ this.update(body, text);
+
+ poll.add(L.bind(function() {
+ return fs.read(STATUS_FILE)
+ .catch(function() { return null; })
+ .then(L.bind(function(t) { this.update(body, t); }, this));
+ }, this), 5);
+
+ return container;
+ },
+
+ update: function(body, text) {
+ if (text === null) {
+ dom.content(body, E('div', { 'class': 'alert-message warning' },
+ _('No data yet. Check whether the Thermal Guard service is running.')));
+ return;
+ }
+
+ var st = parseStatus(text);
+ var cpu = num(st.cpu_c), modem = num(st.modem_c), stage = num(st.stage);
+ var cpuWarn = num(st.cpu_warn), cpuCrit = num(st.cpu_crit);
+ var modemWarn = num(st.modem_warn), modemCrit = num(st.modem_crit);
+ var fanState = num(st.fan_state), fanMax = num(st.fan_max), rpm = num(st.fan_rpm);
+
+ var rows = [
+ row(_('Processor temperature'),
+ cpu === null ? '-' : '%d °C'.format(cpu),
+ gauge(cpu, cpuWarn, cpuCrit, num(st.cpu_emergency) || 100))
+ ];
+
+ if (st.modem_src !== '' || modem !== null)
+ rows.push(row(_('Modem temperature'),
+ modem === null ? _('no reading') : '%d °C'.format(modem),
+ gauge(modem, modemWarn, modemCrit, (num(st.modem_crit) || 90) + 10)));
+
+ extraSensors(st).forEach(function(s) {
+ rows.push(row(s[0], '%d °C'.format(s[1]), gauge(s[1], null, null, 100)));
+ });
+
+ // fan_view is what can be read, even where fan_mode leaves the fan to
+ // another controller. Older status files only carry fan_mode.
+ var fanView = st.fan_view || st.fan_mode;
+ if (fanView && fanView !== 'none') {
+ // The daemon computes the percentage because only it knows which way
+ // the raw scale runs; an inverted board reports full speed as 0.
+ var fanPercent = num(st.fan_percent);
+ var fanLabel = (fanPercent !== null)
+ ? '%d %%'.format(fanPercent)
+ : (fanState === null ? '-'
+ : (fanMax ? _('fan step %d of %d').format(fanState, fanMax) : String(fanState)));
+ if (rpm !== null && rpm > 0)
+ fanLabel += ' (%d rpm)'.format(rpm);
+ rows.push(row(_('Fan'), fanLabel,
+ (fanPercent !== null) ? gauge(fanPercent, null, null, 100)
+ : ((fanState !== null && fanMax) ? gauge(fanState, null, null, fanMax) : null)));
+
+ if (st.fan_foreign === '1')
+ rows.push(row(_('Fan control'),
+ _('Another service is controlling this fan'), null));
+ }
+
+ // Only shown where this service manages the kernel's switching
+ // temperatures; elsewhere they are none of its business.
+ if (st.trip_state === 'active') {
+ var tripDelta = num(st.trip_delta);
+ rows.push(row(_('Fan switching temperatures'),
+ (st.trip_now || '-').replace(/\//g, ' / ') + ' °C', null));
+ if (tripDelta)
+ rows.push(row('', _('lowered by %d K for the modem').format(tripDelta), null));
+ }
+
+ var active = (stage !== null && stage >= 0),
+ state = serviceState(st),
+ noSensor = (st.no_sensor === '1'),
+ banner = [ E('strong', {}, noSensor
+ ? _('This device does not report any temperature')
+ : stageText(stage, st)) ];
+
+ // Not a fault to chase: some boards expose no thermal zone and no hwmon
+ // temperature input at all. Saying so beats a page full of dashes.
+ if (noSensor)
+ banner.push(E('div', {},
+ _('No thermal sensor was found, so the processor is not watched on this hardware. The modem thresholds still apply while the modem reports a temperature.')));
+
+ // A stage that is still applied while nothing is watching is the one
+ // thing an operator has to be told about, so it comes before the rest.
+ if (state !== 'running' && !noSensor)
+ banner.push(E('div', {}, state === 'stopped'
+ ? (active
+ ? _('The service is not running. Protection stages stay applied and the fan is not handed back.')
+ : _('The service is not running. Temperatures are not being watched.'))
+ : (active
+ ? _('The service is paused. Protection stages stay applied and the fan is not handed back.')
+ : _('The service is paused. Temperatures are not being watched.'))));
+
+ if (st.trip_dt_changed === '1')
+ banner.push(E('div', {},
+ _('The firmware brings new fan switching temperatures (%s °C). Your own values still apply; review them under System, Thermal Guard, Fan.').format((st.trip_dt || '').replace(/\//g, ', '))));
+
+ if (active)
+ banner.push(E('div', {}, [
+ E('div', {}, _('Protection stages stay active until you clear them. Clear them once the cause is fixed, for example a replaced fan.')),
+ E('button', {
+ 'class': 'btn cbi-button cbi-button-reset',
+ 'style': 'margin-top:.5em',
+ 'click': ui.createHandlerFn(this, 'handleClear')
+ }, _('Clear protection stages'))
+ ]));
+
+ dom.content(body, [
+ E('div', {
+ 'class': 'tg-banner ' + ((state !== 'running' || noSensor) ? 'tg-warn-banner'
+ : (active ? 'tg-active' : 'tg-idle'))
+ }, banner),
+ E('div', {}, rows),
+ E('div', { 'class': 'tg-meta' }, [
+ _('Last reading: %s').format(fmtAge(st.ts)),
+ st.modem_src ? ' · ' + _('modem source: %s').format(st.modem_src) : '',
+ st.fan_mode ? ' · ' + _('fan mode: %s').format(st.fan_mode) : '',
+ st.version ? ' · ' + _('version %s').format(st.version) : ''
+ ].join(''))
+ ]);
+ },
+
+ // Runs "thermal-guard reset": hands the fan back, radio on, stage cleared.
+ handleClear: function() {
+ return fs.exec('/usr/sbin/thermal-guard', [ 'reset' ]).then(function(res) {
+ if (res.code !== 0)
+ ui.addNotification(null, E('p', {}, _('Clearing failed: %s').format(res.stderr || res.stdout || res.code)), 'error');
+ else
+ ui.addNotification(null, E('p', {}, _('Protection stages cleared.')), 'info');
+ }).catch(function(err) {
+ ui.addNotification(null, E('p', {}, _('Clearing failed: %s').format(err)), 'error');
+ });
+ },
+
+ handleSave: null,
+ handleSaveApply: null,
+ handleReset: null
+});
diff --git a/luci-app-thermal-guard/po/de/thermal-guard.po b/luci-app-thermal-guard/po/de/thermal-guard.po
new file mode 100644
index 000000000..6f7da04dc
--- /dev/null
+++ b/luci-app-thermal-guard/po/de/thermal-guard.po
@@ -0,0 +1,509 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: luci-app-thermal-guard\n"
+"PO-Revision-Date: 2026-09-22\n"
+"Last-Translator: SyntaxError NullPointer <287466119+SyntaxError-NullPointer@users.noreply.github.com>\n"
+"Language-Team: German\n"
+"Language: de\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+msgid "Enter a whole number"
+msgstr "Ganze Zahl eingeben"
+
+msgid "Must be above %s (%d)"
+msgstr "Muss über %s (%d) liegen"
+
+msgid "Must be below %s (%d)"
+msgstr "Muss unter %s (%d) liegen"
+
+msgid "Must be an absolute path without \"..\""
+msgstr "Muss ein vollständiger Pfad ohne „..\" sein"
+
+msgid "Path must start with %s"
+msgstr "Pfad muss mit %s beginnen"
+
+msgid "Thermal Guard"
+msgstr "Thermal Guard"
+
+msgid "Monitors the temperature of the main processor and the modem. If the device gets too hot, it intervenes in up to three protection stages. Stages stay active until you clear them on the status page."
+msgstr "Überwacht die Temperatur des Hauptprozessors und des Modems. Wird das Gerät zu heiß, greift der Dienst in bis zu drei Schutzstufen ein. Die Stufen bleiben aktiv, bis Sie sie auf der Statusseite zurücksetzen."
+
+msgid "General"
+msgstr "Allgemein"
+
+msgid "Thresholds"
+msgstr "Schwellen"
+
+msgid "Temperatures at which each protection stage starts. What actually happens at stage 2 and 3 is set on the Actions tab."
+msgstr "Temperaturen, ab denen die jeweilige Schutzstufe einsetzt. Was bei Stufe 2 und 3 geschieht, legen Sie im Reiter „Aktionen\" fest."
+
+msgid "Fan"
+msgstr "Lüfter"
+
+msgid "Modem"
+msgstr "Modem"
+
+msgid "Actions"
+msgstr "Aktionen"
+
+msgid "What happens when a stage starts. Stage 1 of 3 warns and, if this service drives the fan (Fan tab), sets it to full speed. That cannot be changed."
+msgstr "Was beim Einsetzen einer Stufe geschieht. Stufe 1 von 3 warnt und setzt den Lüfter auf volle Drehzahl, sofern dieser Dienst ihn ansteuert (Reiter „Lüfter\"). Das ist nicht änderbar."
+
+msgid "Enabled"
+msgstr "Aktiv"
+
+msgid "Switching this off pauses the temperature checks. Protection stages that are already active stay applied and the fan is not handed back, so a broken fan cannot be hidden by a checkbox. Use Clear on the status page for that."
+msgstr "Durch Deaktivieren werden die Temperaturmessungen angehalten. Bereits aktive Schutzstufen bleiben bestehen und der Lüfter wird nicht freigegeben. Ein defekter Lüfter lässt sich dadurch nicht verbergen. Nutzen Sie dafür die Funktion „Schutzstufen zurücksetzen\" auf der Statusseite."
+
+msgid "Interval"
+msgstr "Intervall"
+
+msgid "Seconds between two temperature readings. Values below five seconds are treated as five."
+msgstr "Sekunden zwischen zwei Temperaturmessungen. Werte unter fünf Sekunden werden als fünf behandelt."
+
+msgid "Processor temperature source"
+msgstr "Quelle der Prozessortemperatur"
+
+msgid "System file the processor temperature is read from. The default fits most devices; change it only if your device reports its temperature elsewhere."
+msgstr "Systemdatei, aus der die Prozessortemperatur gelesen wird. Die Vorgabe passt für die meisten Geräte und muss nur geändert werden, wenn Ihr Gerät die Temperatur an anderer Stelle meldet."
+
+msgid "Processor warning (stage 1 of 3)"
+msgstr "Prozessor Warnung (Stufe 1 von 3)"
+
+msgid "Stage 1 of 3 starts when either temperature reaches its warning level: a warning, and full fan speed if this service drives the fan."
+msgstr "Stufe 1 von 3 setzt ein, sobald eine der beiden Temperaturen ihre Warnschwelle erreicht: eine Warnung und, sofern dieser Dienst den Lüfter ansteuert, volle Drehzahl."
+
+msgid "Processor critical"
+msgstr "Prozessor kritisch"
+
+msgid "Processor critical (stage 2 of 3)"
+msgstr "Prozessor kritisch (Stufe 2 von 3)"
+
+msgid "Processor warning"
+msgstr "Prozessor Warnung"
+
+msgid "Processor emergency"
+msgstr "Prozessor Notfall"
+
+msgid "Processor emergency (stage 3 of 3)"
+msgstr "Prozessor Notfall (Stufe 3 von 3)"
+
+msgid "Modem warning (stage 1 of 3)"
+msgstr "Modem Warnung (Stufe 1 von 3)"
+
+msgid "Modem critical"
+msgstr "Modem kritisch"
+
+msgid "Modem critical (stage 2 of 3)"
+msgstr "Modem kritisch (Stufe 2 von 3)"
+
+msgid "Modem warning"
+msgstr "Modem Warnung"
+
+msgid "Hold time"
+msgstr "Haltezeit"
+
+msgid "Minutes a stage has to last before the next one starts on time alone: stage 2 of 3 if the temperature has not dropped, stage 3 of 3 if it is still rising."
+msgstr "Minuten, die eine Stufe andauern muss, bevor allein nach Zeit die nächste einsetzt: Stufe 2 von 3, wenn die Temperatur nicht gesunken ist, Stufe 3 von 3, wenn sie weiter steigt."
+
+msgid "Hysteresis"
+msgstr "Hysterese"
+
+msgid "How many degrees the temperature has to fall below the warning level before the device counts as cooled down."
+msgstr "Um wie viel Grad die Temperatur unter die Warnschwelle fallen muss, damit das Gerät als abgekühlt gilt."
+
+msgid "Fan control method"
+msgstr "Art der Lüftersteuerung"
+
+msgid "Leave the fan to the regulator the device already has, unless it has none. Automatic uses the kernel fan control when the device offers it and otherwise leaves the fan alone. It never picks direct control, which needs the fan's polarity."
+msgstr "Überlassen Sie den Lüfter der Regelung, die das Gerät bereits hat, sofern es eine gibt. „Automatisch\" nutzt die Kernel-Lüfterregelung, wenn das Gerät sie anbietet, und lässt den Lüfter sonst unangetastet. Die direkte Steuerung wählt es nie, denn dafür muss die Polarität des Lüfters bekannt sein."
+
+msgid "Automatic"
+msgstr "Automatisch"
+
+msgid "Kernel fan control"
+msgstr "Kernel-Lüfterregelung"
+
+msgid "Direct fan control (PWM)"
+msgstr "Direkte Lüftersteuerung (PWM)"
+
+msgid "Leave the fan alone"
+msgstr "Lüfter nicht ansteuern"
+
+msgid "Fan identifier"
+msgstr "Kennung des Lüfters"
+
+msgid "Name the kernel uses for the fan. Change it only if the fan is not found automatically."
+msgstr "Name, unter dem der Kernel den Lüfter führt. Nur ändern, wenn der Lüfter nicht automatisch gefunden wird."
+
+msgid "Control mode to restore"
+msgstr "Wiederherzustellendes Regelverfahren"
+
+msgid "Control mode handed back to the kernel when the stages are cleared and no earlier control mode was recorded. The default fits almost every device."
+msgstr "Regelverfahren, das beim Zurücksetzen der Schutzstufen an den Kernel übergeben wird, wenn kein früheres festgehalten wurde. Die Vorgabe passt für nahezu alle Geräte."
+
+msgid "Fan speed file"
+msgstr "Datei für die Lüfterdrehzahl"
+
+msgid "Only needed for direct fan control."
+msgstr "Nur bei direkter Lüftersteuerung nötig."
+
+msgid "Fan control file"
+msgstr "Datei zum Aktivieren der Steuerung"
+
+msgid "Only needed for direct fan control. Switches the fan chip to manual control."
+msgstr "Nur bei direkter Lüftersteuerung nötig. Schaltet den Lüfterbaustein auf manuelle Steuerung um."
+
+msgid "Value for full speed"
+msgstr "Wert für volle Drehzahl"
+
+msgid "Required for direct fan control. The raw value that makes this fan spin fastest. Some boards wire the signal inverted, where 1 is full speed and 255 stops the fan. Without this value and the slowest one, the fan is left alone."
+msgstr "Erforderlich für die direkte Lüftersteuerung. Der Rohwert, bei dem der Lüfter am schnellsten dreht. Auf manchen Platinen ist das Signal invertiert: 1 bedeutet dann volle Drehzahl und 255 Stillstand. Fehlt dieser Wert oder der für die langsamste Stufe, bleibt der Lüfter unangetastet."
+
+msgid "Value for the slowest setting"
+msgstr "Wert für die niedrigste Drehzahl"
+
+msgid "Required for direct fan control. The opposite end of the same scale. Together with full speed it states the polarity, and it shows the fan speed as a percentage. Never written to the hardware."
+msgstr "Erforderlich für die direkte Lüftersteuerung. Der Gegenwert am anderen Ende derselben Skala. Zusammen mit dem Wert für volle Drehzahl legt er die Polarität fest und dient zur Anzeige der Drehzahl in Prozent. Wird nie an die Hardware geschrieben."
+
+msgid "Modem temperature source"
+msgstr "Quelle der Modemtemperatur"
+
+msgid "Automatic tries the known modem commands in turn. Select your manufacturer if detection fails."
+msgstr "„Automatisch\" probiert die bekannten Modem-Kommandos nacheinander. Wählen Sie Ihren Hersteller, falls die Erkennung fehlschlägt."
+
+msgid "Quectel (AT+QTEMP)"
+msgstr "Quectel (AT+QTEMP)"
+
+msgid "Fibocom (AT+GTSENRDTEMP)"
+msgstr "Fibocom (AT+GTSENRDTEMP)"
+
+msgid "File"
+msgstr "Datei"
+
+msgid "Command"
+msgstr "Kommando"
+
+msgid "No modem"
+msgstr "Kein Modem"
+
+msgid "Modem control port"
+msgstr "Steuerport des Modems"
+
+msgid "Serial port the commands are sent to. Automatic checks the usual ports."
+msgstr "Serieller Anschluss, über den die Kommandos an das Modem gehen. „Automatisch\" sucht die üblichen Anschlüsse ab."
+
+msgid "Lock file for modem access"
+msgstr "Sperrdatei für Modemzugriffe"
+
+msgid "Prevents this service and other programs from sending commands to the modem at the same time. Point those programs at the same file."
+msgstr "Verhindert, dass dieser Dienst und andere Programme gleichzeitig Kommandos an das Modem senden. Richten Sie diese Programme auf dieselbe Datei aus."
+
+msgid "Temperature file"
+msgstr "Temperaturdatei"
+
+msgid "Own temperature source"
+msgstr "Eigene Temperaturquelle"
+
+msgid "Reads /etc/thermal-guard/hooks/modem-temp, which has to print the temperature in degrees as a whole number. Place the file over SSH; it runs as root and is deliberately not editable from here."
+msgstr "Liest /etc/thermal-guard/hooks/modem-temp aus. Das Skript muss die Temperatur in Grad als ganze Zahl ausgeben. Legen Sie die Datei per SSH an. Sie wird als root ausgeführt und ist bewusst nicht über diese Oberfläche bearbeitbar."
+
+msgid "Action at stage 2 of 3"
+msgstr "Aktion bei Stufe 2 von 3"
+
+msgid "Switch the modem radio off"
+msgstr "Mobilfunk abschalten"
+
+msgid "Switch Wi-Fi off"
+msgstr "WLAN abschalten"
+
+msgid "Take a network interface down"
+msgstr "Netzwerkschnittstelle herunterfahren"
+
+msgid "Run own script"
+msgstr "Eigenes Skript ausführen"
+
+msgid "Nothing"
+msgstr "Nichts"
+
+msgid "Script at stage 2 of 3"
+msgstr "Skript bei Stufe 2 von 3"
+
+msgid "Runs /etc/thermal-guard/hooks/stage1. Place the file over SSH and make it executable."
+msgstr "Führt /etc/thermal-guard/hooks/stage1 aus. Legen Sie die Datei per SSH an und machen Sie sie ausführbar."
+
+msgid "Action at stage 3 of 3"
+msgstr "Aktion bei Stufe 3 von 3"
+
+msgid "Script at stage 3 of 3"
+msgstr "Skript bei Stufe 3 von 3"
+
+msgid "How the modem is switched off"
+msgstr "Art der Modem-Abschaltung"
+
+msgid "Flight mode suits most modems. Use minimum function for a modem that does not accept flight mode. Either way the modem is asked afterwards (AT+CFUN?), and a modem that does not confirm counts as a failed stage."
+msgstr "Der Flugmodus passt für die meisten Modems. Minimalbetrieb ist für Modems gedacht, die den Flugmodus nicht annehmen. In beiden Fällen wird das Modem anschließend abgefragt (AT+CFUN?); bestätigt es den Zustand nicht, gilt die Stufe als fehlgeschlagen."
+
+msgid "Flight mode (AT+CFUN=4)"
+msgstr "Flugmodus (AT+CFUN=4)"
+
+msgid "Minimum function (AT+CFUN=0)"
+msgstr "Minimalbetrieb (AT+CFUN=0)"
+
+msgid "Interface to take down"
+msgstr "Herunterzufahrende Schnittstelle"
+
+msgid "Network interface taken down by the interface action and brought back up when the stages are cleared. Empty uses the modem network interface."
+msgstr "Netzwerkschnittstelle, die die Schnittstellen-Aktion herunterfährt und beim Zurücksetzen der Stufen wieder startet. Leer bedeutet: die Netzwerkschnittstelle des Modems."
+
+msgid "Runs /etc/thermal-guard/hooks/stage2. Place the file over SSH and make it executable."
+msgstr "Führt /etc/thermal-guard/hooks/stage2 aus. Legen Sie die Datei per SSH an und machen Sie sie ausführbar."
+
+msgid "Notification address"
+msgstr "Benachrichtigungsadresse"
+
+msgid "The warning text is posted here. Works with ntfy, Gotify, Matrix, Slack and anything else that accepts a plain POST body."
+msgstr "An diese Adresse wird der Warntext gesendet. Funktioniert mit ntfy, Gotify, Matrix, Slack und jedem anderen Dienst, der einen einfachen POST-Inhalt entgegennimmt."
+
+msgid "Must be an http:// or https:// address without quotes or spaces"
+msgstr "Muss eine http:// oder https://-Adresse ohne Anführungszeichen und Leerzeichen sein"
+
+msgid "Additional header"
+msgstr "Zusätzlicher Header"
+
+msgid "Sent with the notification, for example an authorisation token. Needs curl installed; the smaller uclient-fetch cannot send headers."
+msgstr "Wird mit der Benachrichtigung mitgesendet, zum Beispiel ein Autorisierungstoken. Erfordert ein installiertes curl; das kleinere uclient-fetch kann keine Header senden."
+
+msgid "Must be printable text without quotes or backslashes"
+msgstr "Muss druckbarer Text ohne Anführungszeichen und Backslashes sein"
+
+msgid "Modem network interface"
+msgstr "Netzwerkschnittstelle des Modems"
+
+msgid "Lets the service tell whether another internet connection exists, so a message from stage 2 of 3 on goes out or waits in the queue. Also the interface the interface action takes when none is set."
+msgstr "Damit erkennt der Dienst, ob eine andere Internetverbindung besteht, und entscheidet ab Stufe 2 von 3, ob eine Nachricht sofort hinausgeht oder in der Warteschlange wartet. Außerdem die Netzwerkschnittstelle, die die Schnittstellen-Aktion verwendet, wenn keine eingestellt ist."
+
+msgid "Wi-Fi %d"
+msgstr "WLAN %d"
+
+msgid "Ethernet"
+msgstr "Ethernet"
+
+msgid "SSD"
+msgstr "SSD"
+
+msgid "modem radio off"
+msgstr "Mobilfunk abgeschaltet"
+
+msgid "Wi-Fi off"
+msgstr "WLAN abgeschaltet"
+
+msgid "network interface down"
+msgstr "Netzwerkschnittstelle heruntergefahren"
+
+msgid "custom command"
+msgstr "eigenes Skript"
+
+msgid "no action"
+msgstr "keine Aktion"
+
+msgid "unknown action"
+msgstr "unbekannte Aktion"
+
+msgid "Stage 1 of 3 active: fan at full speed"
+msgstr "Stufe 1 von 3 aktiv: Lüfter auf voller Drehzahl"
+
+msgid "Stage 1 of 3 active: warning, fan left to its own controller"
+msgstr "Stufe 1 von 3 aktiv: Warnung, Lüfter bleibt bei seiner eigenen Regelung"
+
+msgid "Stage 2 of 3 active: %s"
+msgstr "Stufe 2 von 3 aktiv: %s"
+
+msgid "Stage 3 of 3 active: %s"
+msgstr "Stufe 3 von 3 aktiv: %s"
+
+msgid "No intervention, temperatures within range"
+msgstr "Kein Eingriff, Temperaturen im normalen Bereich"
+
+msgid "%d s ago"
+msgstr "vor %d s"
+
+msgid "%d min ago"
+msgstr "vor %d min"
+
+msgid "Current temperatures and protection state. Thresholds and actions are configured under System, Thermal Guard."
+msgstr "Aktuelle Temperaturen und Schutzzustand. Schwellen und Aktionen werden unter System, Thermal Guard eingestellt."
+
+msgid "No data yet. Check whether the Thermal Guard service is running."
+msgstr "Noch keine Daten. Prüfen Sie, ob der Dienst Thermal Guard läuft."
+
+msgid "Processor temperature"
+msgstr "Prozessortemperatur"
+
+msgid "Modem temperature"
+msgstr "Modemtemperatur"
+
+msgid "no reading"
+msgstr "kein Messwert"
+
+msgid "fan step %d of %d"
+msgstr "Lüfterstufe %d von %d"
+
+msgid "Fan control"
+msgstr "Lüftersteuerung"
+
+msgid "Another service is controlling this fan"
+msgstr "Der Lüfter wird von einem anderen Dienst gesteuert"
+
+msgid "This device does not report any temperature"
+msgstr "Dieses Gerät meldet keine Temperatur"
+
+msgid "No thermal sensor was found, so the processor is not watched on this hardware. The modem thresholds still apply while the modem reports a temperature."
+msgstr "Es wurde kein Temperatursensor gefunden, der Prozessor wird auf dieser Hardware daher nicht überwacht. Die Modemschwellen gelten weiter, solange das Modem eine Temperatur meldet."
+
+msgid "The service is not running. Protection stages stay applied and the fan is not handed back."
+msgstr "Der Dienst läuft nicht. Aktive Schutzstufen bleiben bestehen und der Lüfter wird nicht freigegeben."
+
+msgid "The service is not running. Temperatures are not being watched."
+msgstr "Der Dienst läuft nicht. Die Temperaturen werden nicht überwacht."
+
+msgid "The service is paused. Protection stages stay applied and the fan is not handed back."
+msgstr "Der Dienst ist angehalten. Aktive Schutzstufen bleiben bestehen und der Lüfter wird nicht freigegeben."
+
+msgid "The service is paused. Temperatures are not being watched."
+msgstr "Der Dienst ist angehalten. Die Temperaturen werden nicht überwacht."
+
+msgid "Protection stages stay active until you clear them. Clear them once the cause is fixed, for example a replaced fan."
+msgstr "Schutzstufen bleiben aktiv, bis Sie sie zurücksetzen. Setzen Sie sie zurück, sobald die Ursache behoben ist, etwa nach dem Austausch des Lüfters."
+
+msgid "Clear protection stages"
+msgstr "Schutzstufen zurücksetzen"
+
+msgid "Last reading: %s"
+msgstr "Letzte Messung: %s"
+
+msgid "modem source: %s"
+msgstr "Modemquelle: %s"
+
+msgid "fan mode: %s"
+msgstr "Lüftersteuerung: %s"
+
+msgid "version %s"
+msgstr "Version %s"
+
+msgid "Clearing failed: %s"
+msgstr "Zurücksetzen fehlgeschlagen: %s"
+
+msgid "Protection stages cleared."
+msgstr "Schutzstufen zurückgesetzt."
+
+msgid "Temperatures"
+msgstr "Temperaturen"
+
+msgid "Processor"
+msgstr "Prozessor"
+
+msgid "protection stage %d active"
+msgstr "Schutzstufe %d aktiv"
+
+msgid "Fan switching temperatures"
+msgstr "Schalttemperaturen des Lüfters"
+
+msgid "lowered by %d K for the modem"
+msgstr "für das Modem um %d K abgesenkt"
+
+msgid "The firmware brings new fan switching temperatures (%s °C). Your own values still apply; review them under System, Thermal Guard, Fan."
+msgstr "Die Firmware bringt neue Schalttemperaturen für den Lüfter mit (%s °C). Ihre eigenen Werte gelten weiter; prüfen Sie sie unter System, Thermal Guard, Lüfter."
+
+msgid "the kernel knows no fan on this device"
+msgstr "der Kernel kennt auf diesem Gerät keinen Lüfter"
+
+msgid "no thermal zone drives the fan"
+msgstr "keine Thermalzone steuert den Lüfter"
+
+msgid "the fan's thermal zone is not under /sys/class/thermal"
+msgstr "die Thermalzone des Lüfters liegt nicht unter /sys/class/thermal"
+
+msgid "the fan is not bound to any switching temperature"
+msgstr "der Lüfter ist an keine Schalttemperatur gebunden"
+
+msgid "the switching temperatures cannot be changed"
+msgstr "die Schalttemperaturen lassen sich nicht ändern"
+
+msgid "unknown reason"
+msgstr "unbekannter Grund"
+
+msgid "Managed by this service, switching temperatures now %s °C"
+msgstr "Von diesem Dienst verwaltet, Schalttemperaturen derzeit %s °C"
+
+msgid "Not possible on this device: %s"
+msgstr "Auf diesem Gerät nicht möglich: %s"
+
+msgid "Not managed by this service"
+msgstr "Nicht von diesem Dienst verwaltet"
+
+msgid "Speed the fan up for the modem"
+msgstr "Lüfter für das Modem hochregeln"
+
+msgid "Where the kernel drives the fan, its switching temperatures are lowered while the modem runs hotter than the processor, so the fan speeds up for the modem as well. They only ever go down, never above the base values. A missing modem reading keeps them where they are."
+msgstr "Wo der Kernel den Lüfter steuert, werden seine Schalttemperaturen abgesenkt, solange das Modem wärmer ist als der Prozessor. So dreht der Lüfter auch für das Modem hoch. Die Werte werden nur abgesenkt, nie über die Grundwerte angehoben. Fehlt ein Messwert des Modems, bleiben sie, wo sie sind."
+
+msgid "Off"
+msgstr "Aus"
+
+msgid "Follow the modem temperature"
+msgstr "Der Modemtemperatur folgen"
+
+msgid "Modem allowance"
+msgstr "Spielraum für das Modem"
+
+msgid "Degrees the modem may run above the processor before the switching temperatures are lowered."
+msgstr "Grad, um die das Modem wärmer sein darf als der Prozessor, bevor die Schalttemperaturen abgesenkt werden."
+
+msgid "Base values in degrees at which the kernel moves the fan one step up, lowest first, one value per step. Empty uses the values of the installed firmware: %s °C."
+msgstr "Grundwerte in Grad, bei denen der Kernel den Lüfter eine Stufe höher schaltet, der niedrigste zuerst, ein Wert je Stufe. Leer gelten die Werte der installierten Firmware: %s °C."
+
+msgid "Base values in degrees at which the kernel moves the fan one step up, lowest first, one value per step. Empty uses the values of the installed firmware."
+msgstr "Grundwerte in Grad, bei denen der Kernel den Lüfter eine Stufe höher schaltet, der niedrigste zuerst, ein Wert je Stufe. Leer gelten die Werte der installierten Firmware."
+
+msgid "Must be between 20 and 95"
+msgstr "Muss zwischen 20 und 95 liegen"
+
+msgid "Each value must be higher than the one before"
+msgstr "Jeder Wert muss höher sein als der vorige"
+
+msgid "Must be below %d, the temperature at which the kernel itself intervenes"
+msgstr "Muss unter %d liegen, der Temperatur, bei der der Kernel selbst eingreift"
+
+msgid "Needs exactly %d values, one per fan step"
+msgstr "Genau %d Werte nötig, einer je Lüfterstufe"
+
+msgid "New firmware values"
+msgstr "Neue Werte der Firmware"
+
+msgid "The installed firmware now uses %s °C. Your own base values were set for an earlier firmware and still apply."
+msgstr "Die installierte Firmware verwendet jetzt %s °C. Ihre eigenen Grundwerte wurden für eine frühere Firmware eingestellt und gelten weiter."
+
+msgid "Use the firmware values"
+msgstr "Werte der Firmware übernehmen"
+
+msgid "Keep my values"
+msgstr "Eigene Werte behalten"
+
+msgid "Recording the firmware values failed: %s"
+msgstr "Die Firmware-Werte konnten nicht festgehalten werden: %s"
+
+msgid "Firmware values recorded. The notice goes away with the next check."
+msgstr "Firmware-Werte festgehalten. Der Hinweis verschwindet mit der nächsten Prüfung."
+
+msgid "Fan steps of the firmware"
+msgstr "Lüfterstufen der Firmware"
+
+msgid "Raw value per fan step from the device tree, with the duty cycle of the signal. They belong to the fan that is fitted and change only with a new firmware image."
+msgstr "Rohwert je Lüfterstufe aus dem Device Tree, mit dem Tastverhältnis des Signals. Sie gehören zum eingebauten Lüfter und ändern sich nur mit einer neuen Firmware."
+
+msgid "step %d: %d (%d %%)"
+msgstr "Stufe %d: %d (%d %%)"
diff --git a/luci-app-thermal-guard/po/templates/thermal-guard.pot b/luci-app-thermal-guard/po/templates/thermal-guard.pot
new file mode 100644
index 000000000..3edd45ede
--- /dev/null
+++ b/luci-app-thermal-guard/po/templates/thermal-guard.pot
@@ -0,0 +1,504 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: luci-app-thermal-guard\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+
+msgid "Enter a whole number"
+msgstr ""
+
+msgid "Must be above %s (%d)"
+msgstr ""
+
+msgid "Must be below %s (%d)"
+msgstr ""
+
+msgid "Must be an absolute path without \"..\""
+msgstr ""
+
+msgid "Path must start with %s"
+msgstr ""
+
+msgid "Thermal Guard"
+msgstr ""
+
+msgid "Monitors the temperature of the main processor and the modem. If the device gets too hot, it intervenes in up to three protection stages. Stages stay active until you clear them on the status page."
+msgstr ""
+
+msgid "General"
+msgstr ""
+
+msgid "Thresholds"
+msgstr ""
+
+msgid "Temperatures at which each protection stage starts. What actually happens at stage 2 and 3 is set on the Actions tab."
+msgstr ""
+
+msgid "Fan"
+msgstr ""
+
+msgid "Modem"
+msgstr ""
+
+msgid "Actions"
+msgstr ""
+
+msgid "What happens when a stage starts. Stage 1 of 3 warns and, if this service drives the fan (Fan tab), sets it to full speed. That cannot be changed."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Switching this off pauses the temperature checks. Protection stages that are already active stay applied and the fan is not handed back, so a broken fan cannot be hidden by a checkbox. Use Clear on the status page for that."
+msgstr ""
+
+msgid "Interval"
+msgstr ""
+
+msgid "Seconds between two temperature readings. Values below five seconds are treated as five."
+msgstr ""
+
+msgid "Processor temperature source"
+msgstr ""
+
+msgid "System file the processor temperature is read from. The default fits most devices; change it only if your device reports its temperature elsewhere."
+msgstr ""
+
+msgid "Processor warning (stage 1 of 3)"
+msgstr ""
+
+msgid "Stage 1 of 3 starts when either temperature reaches its warning level: a warning, and full fan speed if this service drives the fan."
+msgstr ""
+
+msgid "Processor critical"
+msgstr ""
+
+msgid "Processor critical (stage 2 of 3)"
+msgstr ""
+
+msgid "Processor warning"
+msgstr ""
+
+msgid "Processor emergency"
+msgstr ""
+
+msgid "Processor emergency (stage 3 of 3)"
+msgstr ""
+
+msgid "Modem warning (stage 1 of 3)"
+msgstr ""
+
+msgid "Modem critical"
+msgstr ""
+
+msgid "Modem critical (stage 2 of 3)"
+msgstr ""
+
+msgid "Modem warning"
+msgstr ""
+
+msgid "Hold time"
+msgstr ""
+
+msgid "Minutes a stage has to last before the next one starts on time alone: stage 2 of 3 if the temperature has not dropped, stage 3 of 3 if it is still rising."
+msgstr ""
+
+msgid "Hysteresis"
+msgstr ""
+
+msgid "How many degrees the temperature has to fall below the warning level before the device counts as cooled down."
+msgstr ""
+
+msgid "Fan control method"
+msgstr ""
+
+msgid "Leave the fan to the regulator the device already has, unless it has none. Automatic uses the kernel fan control when the device offers it and otherwise leaves the fan alone. It never picks direct control, which needs the fan's polarity."
+msgstr ""
+
+msgid "Automatic"
+msgstr ""
+
+msgid "Kernel fan control"
+msgstr ""
+
+msgid "Direct fan control (PWM)"
+msgstr ""
+
+msgid "Leave the fan alone"
+msgstr ""
+
+msgid "Fan identifier"
+msgstr ""
+
+msgid "Name the kernel uses for the fan. Change it only if the fan is not found automatically."
+msgstr ""
+
+msgid "Control mode to restore"
+msgstr ""
+
+msgid "Control mode handed back to the kernel when the stages are cleared and no earlier control mode was recorded. The default fits almost every device."
+msgstr ""
+
+msgid "Fan speed file"
+msgstr ""
+
+msgid "Only needed for direct fan control."
+msgstr ""
+
+msgid "Fan control file"
+msgstr ""
+
+msgid "Only needed for direct fan control. Switches the fan chip to manual control."
+msgstr ""
+
+msgid "Value for full speed"
+msgstr ""
+
+msgid "Required for direct fan control. The raw value that makes this fan spin fastest. Some boards wire the signal inverted, where 1 is full speed and 255 stops the fan. Without this value and the slowest one, the fan is left alone."
+msgstr ""
+
+msgid "Value for the slowest setting"
+msgstr ""
+
+msgid "Required for direct fan control. The opposite end of the same scale. Together with full speed it states the polarity, and it shows the fan speed as a percentage. Never written to the hardware."
+msgstr ""
+
+msgid "Modem temperature source"
+msgstr ""
+
+msgid "Automatic tries the known modem commands in turn. Select your manufacturer if detection fails."
+msgstr ""
+
+msgid "Quectel (AT+QTEMP)"
+msgstr ""
+
+msgid "Fibocom (AT+GTSENRDTEMP)"
+msgstr ""
+
+msgid "File"
+msgstr ""
+
+msgid "Command"
+msgstr ""
+
+msgid "No modem"
+msgstr ""
+
+msgid "Modem control port"
+msgstr ""
+
+msgid "Serial port the commands are sent to. Automatic checks the usual ports."
+msgstr ""
+
+msgid "Lock file for modem access"
+msgstr ""
+
+msgid "Prevents this service and other programs from sending commands to the modem at the same time. Point those programs at the same file."
+msgstr ""
+
+msgid "Temperature file"
+msgstr ""
+
+msgid "Own temperature source"
+msgstr ""
+
+msgid "Reads /etc/thermal-guard/hooks/modem-temp, which has to print the temperature in degrees as a whole number. Place the file over SSH; it runs as root and is deliberately not editable from here."
+msgstr ""
+
+msgid "Action at stage 2 of 3"
+msgstr ""
+
+msgid "Switch the modem radio off"
+msgstr ""
+
+msgid "Switch Wi-Fi off"
+msgstr ""
+
+msgid "Take a network interface down"
+msgstr ""
+
+msgid "Run own script"
+msgstr ""
+
+msgid "Nothing"
+msgstr ""
+
+msgid "Script at stage 2 of 3"
+msgstr ""
+
+msgid "Runs /etc/thermal-guard/hooks/stage1. Place the file over SSH and make it executable."
+msgstr ""
+
+msgid "Action at stage 3 of 3"
+msgstr ""
+
+msgid "Script at stage 3 of 3"
+msgstr ""
+
+msgid "How the modem is switched off"
+msgstr ""
+
+msgid "Flight mode suits most modems. Use minimum function for a modem that does not accept flight mode. Either way the modem is asked afterwards (AT+CFUN?), and a modem that does not confirm counts as a failed stage."
+msgstr ""
+
+msgid "Flight mode (AT+CFUN=4)"
+msgstr ""
+
+msgid "Minimum function (AT+CFUN=0)"
+msgstr ""
+
+msgid "Interface to take down"
+msgstr ""
+
+msgid "Network interface taken down by the interface action and brought back up when the stages are cleared. Empty uses the modem network interface."
+msgstr ""
+
+msgid "Runs /etc/thermal-guard/hooks/stage2. Place the file over SSH and make it executable."
+msgstr ""
+
+msgid "Notification address"
+msgstr ""
+
+msgid "The warning text is posted here. Works with ntfy, Gotify, Matrix, Slack and anything else that accepts a plain POST body."
+msgstr ""
+
+msgid "Must be an http:// or https:// address without quotes or spaces"
+msgstr ""
+
+msgid "Additional header"
+msgstr ""
+
+msgid "Sent with the notification, for example an authorisation token. Needs curl installed; the smaller uclient-fetch cannot send headers."
+msgstr ""
+
+msgid "Must be printable text without quotes or backslashes"
+msgstr ""
+
+msgid "Modem network interface"
+msgstr ""
+
+msgid "Lets the service tell whether another internet connection exists, so a message from stage 2 of 3 on goes out or waits in the queue. Also the interface the interface action takes when none is set."
+msgstr ""
+
+msgid "Wi-Fi %d"
+msgstr ""
+
+msgid "Ethernet"
+msgstr ""
+
+msgid "SSD"
+msgstr ""
+
+msgid "modem radio off"
+msgstr ""
+
+msgid "Wi-Fi off"
+msgstr ""
+
+msgid "network interface down"
+msgstr ""
+
+msgid "custom command"
+msgstr ""
+
+msgid "no action"
+msgstr ""
+
+msgid "unknown action"
+msgstr ""
+
+msgid "Stage 1 of 3 active: fan at full speed"
+msgstr ""
+
+msgid "Stage 1 of 3 active: warning, fan left to its own controller"
+msgstr ""
+
+msgid "Stage 2 of 3 active: %s"
+msgstr ""
+
+msgid "Stage 3 of 3 active: %s"
+msgstr ""
+
+msgid "No intervention, temperatures within range"
+msgstr ""
+
+msgid "%d s ago"
+msgstr ""
+
+msgid "%d min ago"
+msgstr ""
+
+msgid "Current temperatures and protection state. Thresholds and actions are configured under System, Thermal Guard."
+msgstr ""
+
+msgid "No data yet. Check whether the Thermal Guard service is running."
+msgstr ""
+
+msgid "Processor temperature"
+msgstr ""
+
+msgid "Modem temperature"
+msgstr ""
+
+msgid "no reading"
+msgstr ""
+
+msgid "fan step %d of %d"
+msgstr ""
+
+msgid "Fan control"
+msgstr ""
+
+msgid "Another service is controlling this fan"
+msgstr ""
+
+msgid "This device does not report any temperature"
+msgstr ""
+
+msgid "No thermal sensor was found, so the processor is not watched on this hardware. The modem thresholds still apply while the modem reports a temperature."
+msgstr ""
+
+msgid "The service is not running. Protection stages stay applied and the fan is not handed back."
+msgstr ""
+
+msgid "The service is not running. Temperatures are not being watched."
+msgstr ""
+
+msgid "The service is paused. Protection stages stay applied and the fan is not handed back."
+msgstr ""
+
+msgid "The service is paused. Temperatures are not being watched."
+msgstr ""
+
+msgid "Protection stages stay active until you clear them. Clear them once the cause is fixed, for example a replaced fan."
+msgstr ""
+
+msgid "Clear protection stages"
+msgstr ""
+
+msgid "Last reading: %s"
+msgstr ""
+
+msgid "modem source: %s"
+msgstr ""
+
+msgid "fan mode: %s"
+msgstr ""
+
+msgid "version %s"
+msgstr ""
+
+msgid "Clearing failed: %s"
+msgstr ""
+
+msgid "Protection stages cleared."
+msgstr ""
+
+msgid "Temperatures"
+msgstr ""
+
+msgid "Processor"
+msgstr ""
+
+msgid "protection stage %d active"
+msgstr ""
+
+msgid "Fan switching temperatures"
+msgstr ""
+
+msgid "lowered by %d K for the modem"
+msgstr ""
+
+msgid "The firmware brings new fan switching temperatures (%s °C). Your own values still apply; review them under System, Thermal Guard, Fan."
+msgstr ""
+
+msgid "the kernel knows no fan on this device"
+msgstr ""
+
+msgid "no thermal zone drives the fan"
+msgstr ""
+
+msgid "the fan's thermal zone is not under /sys/class/thermal"
+msgstr ""
+
+msgid "the fan is not bound to any switching temperature"
+msgstr ""
+
+msgid "the switching temperatures cannot be changed"
+msgstr ""
+
+msgid "unknown reason"
+msgstr ""
+
+msgid "Managed by this service, switching temperatures now %s °C"
+msgstr ""
+
+msgid "Not possible on this device: %s"
+msgstr ""
+
+msgid "Not managed by this service"
+msgstr ""
+
+msgid "Speed the fan up for the modem"
+msgstr ""
+
+msgid "Where the kernel drives the fan, its switching temperatures are lowered while the modem runs hotter than the processor, so the fan speeds up for the modem as well. They only ever go down, never above the base values. A missing modem reading keeps them where they are."
+msgstr ""
+
+msgid "Off"
+msgstr ""
+
+msgid "Follow the modem temperature"
+msgstr ""
+
+msgid "Modem allowance"
+msgstr ""
+
+msgid "Degrees the modem may run above the processor before the switching temperatures are lowered."
+msgstr ""
+
+msgid "Base values in degrees at which the kernel moves the fan one step up, lowest first, one value per step. Empty uses the values of the installed firmware: %s °C."
+msgstr ""
+
+msgid "Base values in degrees at which the kernel moves the fan one step up, lowest first, one value per step. Empty uses the values of the installed firmware."
+msgstr ""
+
+msgid "Must be between 20 and 95"
+msgstr ""
+
+msgid "Each value must be higher than the one before"
+msgstr ""
+
+msgid "Must be below %d, the temperature at which the kernel itself intervenes"
+msgstr ""
+
+msgid "Needs exactly %d values, one per fan step"
+msgstr ""
+
+msgid "New firmware values"
+msgstr ""
+
+msgid "The installed firmware now uses %s °C. Your own base values were set for an earlier firmware and still apply."
+msgstr ""
+
+msgid "Use the firmware values"
+msgstr ""
+
+msgid "Keep my values"
+msgstr ""
+
+msgid "Recording the firmware values failed: %s"
+msgstr ""
+
+msgid "Firmware values recorded. The notice goes away with the next check."
+msgstr ""
+
+msgid "Fan steps of the firmware"
+msgstr ""
+
+msgid "Raw value per fan step from the device tree, with the duty cycle of the signal. They belong to the fan that is fitted and change only with a new firmware image."
+msgstr ""
+
+msgid "step %d: %d (%d %%)"
+msgstr ""
diff --git a/luci-app-thermal-guard/root/usr/share/luci/menu.d/luci-app-thermal-guard.json b/luci-app-thermal-guard/root/usr/share/luci/menu.d/luci-app-thermal-guard.json
new file mode 100644
index 000000000..bf331aa56
--- /dev/null
+++ b/luci-app-thermal-guard/root/usr/share/luci/menu.d/luci-app-thermal-guard.json
@@ -0,0 +1,26 @@
+{
+ "admin/status/thermal-guard": {
+ "title": "Thermal Guard",
+ "order": 60,
+ "action": {
+ "type": "view",
+ "path": "thermal-guard/monitor"
+ },
+ "depends": {
+ "acl": [ "luci-app-thermal-guard" ]
+ }
+ },
+
+ "admin/system/thermal-guard": {
+ "title": "Thermal Guard",
+ "order": 62,
+ "action": {
+ "type": "view",
+ "path": "thermal-guard/config"
+ },
+ "depends": {
+ "acl": [ "luci-app-thermal-guard" ],
+ "uci": { "thermal-guard": true }
+ }
+ }
+}
diff --git a/luci-app-thermal-guard/root/usr/share/rpcd/acl.d/luci-app-thermal-guard.json b/luci-app-thermal-guard/root/usr/share/rpcd/acl.d/luci-app-thermal-guard.json
new file mode 100644
index 000000000..6e3af4b7e
--- /dev/null
+++ b/luci-app-thermal-guard/root/usr/share/rpcd/acl.d/luci-app-thermal-guard.json
@@ -0,0 +1,25 @@
+{
+ "luci-app-thermal-guard": {
+ "description": "Read the thermal-guard status, edit its configuration, clear stages and record firmware trips",
+ "read": {
+ "file": {
+ "/var/run/thermal-guard.status": [ "read" ],
+ "/tmp/run/thermal-guard.status": [ "read" ]
+ },
+ "ubus": {
+ "file": [ "read" ]
+ },
+ "uci": [ "thermal-guard" ]
+ },
+ "write": {
+ "file": {
+ "/usr/sbin/thermal-guard reset": [ "exec" ],
+ "/usr/sbin/thermal-guard trips-keep": [ "exec" ]
+ },
+ "ubus": {
+ "file": [ "exec" ]
+ },
+ "uci": [ "thermal-guard" ]
+ }
+ }
+}
diff --git a/thermal-guard/Makefile b/thermal-guard/Makefile
new file mode 100644
index 000000000..2fc672c0b
--- /dev/null
+++ b/thermal-guard/Makefile
@@ -0,0 +1,68 @@
+#
+# Copyright (C) 2026 SyntaxError NullPointer
+#
+# This is free software, licensed under the GNU General Public License v2.
+# See /LICENSE for more information.
+#
+
+include $(TOPDIR)/rules.mk
+
+PKG_NAME:=thermal-guard
+PKG_VERSION:=2.0.0
+PKG_RELEASE:=1
+
+PKG_LICENSE:=GPL-2.0-only
+PKG_LICENSE_FILES:=LICENSE
+PKG_MAINTAINER:=SyntaxError NullPointer <287466119+SyntaxError-NullPointer@users.noreply.github.com>
+
+include $(INCLUDE_DIR)/package.mk
+
+define Package/thermal-guard
+ SECTION:=utils
+ CATEGORY:=Utilities
+ TITLE:=Overheating protection with staged mitigation
+ DEPENDS:=+libubox +ubus +coreutils-timeout +jsonfilter
+ PKGARCH:=all
+endef
+
+define Package/thermal-guard/description
+ Watches SoC and modem temperature and escalates in stages when a router runs
+ hot, typically after a fan failure: fan to full speed, modem radio off, Wi-Fi
+ off. Stages do not roll back on their own, clearing them is an operator
+ decision, and they survive a reboot. Fan control works either through the
+ kernel thermal zone or through sysfs PWM, modem temperature through Quectel or
+ Fibocom AT commands, a file or a hook script. Warnings are posted to a URL.
+ Where the kernel drives the fan, it can lower the fan's trip points while the
+ modem runs hotter than the CPU.
+endef
+
+define Package/thermal-guard/conffiles
+/etc/config/thermal-guard
+endef
+
+# procd keeps an instance whose command line did not change, so without this
+# an upgrade leaves the old daemon running. A service that was stopped stays so.
+define Package/thermal-guard/postinst
+#!/bin/sh
+[ "$${PKG_UPGRADE}" = 1 ] && [ -z "$${IPKG_INSTROOT}" ] &&
+ /etc/init.d/thermal-guard running && /etc/init.d/thermal-guard restart
+exit 0
+endef
+
+define Build/Compile
+endef
+
+define Package/thermal-guard/install
+ $(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/libexec $(1)/usr/share/thermal-guard $(1)/etc/config $(1)/etc/init.d $(1)/etc/uci-defaults $(1)/etc/thermal-guard/hooks $(1)/lib/upgrade/keep.d
+ $(INSTALL_DATA) ./files/lib/upgrade/keep.d/thermal-guard $(1)/lib/upgrade/keep.d/thermal-guard
+ $(INSTALL_DATA) ./files/etc/thermal-guard/hooks/README $(1)/etc/thermal-guard/hooks/README
+ $(INSTALL_BIN) ./files/usr/sbin/thermal-guard $(1)/usr/sbin/thermal-guard
+ $(INSTALL_DATA) ./files/usr/share/thermal-guard/backends.sh $(1)/usr/share/thermal-guard/backends.sh
+ $(INSTALL_DATA) ./files/usr/share/thermal-guard/trips.sh $(1)/usr/share/thermal-guard/trips.sh
+ $(INSTALL_CONF) ./files/etc/config/thermal-guard $(1)/etc/config/thermal-guard
+ $(INSTALL_BIN) ./files/etc/init.d/thermal-guard $(1)/etc/init.d/thermal-guard
+ $(INSTALL_BIN) ./files/etc/uci-defaults/90-thermal-guard-trip-base $(1)/etc/uci-defaults/
+ $(INSTALL_BIN) ./files/usr/libexec/thermal-guard-collectd $(1)/usr/libexec/thermal-guard-collectd
+endef
+
+$(eval $(call BuildPackage,thermal-guard))
diff --git a/thermal-guard/files/etc/config/thermal-guard b/thermal-guard/files/etc/config/thermal-guard
new file mode 100644
index 000000000..fc1a54a8a
--- /dev/null
+++ b/thermal-guard/files/etc/config/thermal-guard
@@ -0,0 +1,80 @@
+
+config thermal-guard 'main'
+ option enabled '1'
+ # seconds between readings, minimum 5
+ option interval '20'
+
+ # stage thresholds in degrees celsius, must be ordered warn < crit < emergency
+ option cpu_warn '70'
+ option cpu_crit '80'
+ option cpu_emergency '90'
+ option modem_warn '80'
+ option modem_crit '88'
+
+ # minutes a stage has to persist before the next one can trigger on time alone
+ option hold_minutes '3'
+ # degrees below the warn thresholds that count as cooled down
+ option hysteresis '10'
+
+ option cpu_temp_path '/sys/class/thermal/thermal_zone0/temp'
+ # also report the other temperature sensors of the board, Wi-Fi chips and so on
+ option extra_sensors '1'
+
+ # none, cooling_device (kernel thermal zone), pwm (sysfs) or auto.
+ # none leaves the fan to whatever regulates it already. auto takes the cooling
+ # device if there is one and otherwise stays off the fan; it never picks pwm.
+ option fan_mode 'none'
+ # type of the fan's cooling device, for cooling_device, auto and trip
+ # management; empty means pwm-fan
+ option fan_cooling_device_type 'pwm-fan'
+ # governor the zone is handed back to on reset when none was recorded
+ # before: step_wise, fair_share, bang_bang or power_allocator. Not
+ # user_space, nothing would regulate then.
+ option fan_governor 'step_wise'
+ # pwm mode. Leave the paths empty to look the fan chip up by hwmon name.
+ #option pwm_path '/sys/class/hwmon/hwmon2/pwm1'
+ #option pwm_enable_path '/sys/class/hwmon/hwmon2/pwm1_enable'
+ # raw value for fastest and for slowest, both required for pwm mode. Some
+ # boards run these the other way round, 1 full speed and 255 stopped
+ #option pwm_full '255'
+ #option pwm_idle '0'
+
+ # auto, quectel (AT+QTEMP), fibocom (AT+GTSENRDTEMP), file, command or none
+ option modem_source 'auto'
+ option modem_at_port 'auto'
+ #option modem_temp_file '/var/run/modem_temp'
+ # "command" reads /etc/thermal-guard/hooks/modem-temp
+ # shared lock so concurrent AT users do not talk over each other
+ option at_lock '/var/lock/modem-at.lock'
+
+ # Lower the fan's active trips while the modem runs hotter than the CPU, so
+ # the kernel spins the fan up for it. off or modem. Needs a thermal zone that
+ # drives the fan; nothing is written while this is off and trip_active is unset.
+ # Unset means off, so an image can switch it on for its board at first boot
+ #option trip_boost 'modem'
+ # how much warmer than the CPU the modem may run before trips are lowered, 0-40
+ #option trip_boost_offset '15'
+ # baseline for the active trips in degrees, ascending, one value per trip.
+ # Unset means the device tree values of the running image
+ #list trip_active '50'
+ #list trip_active '60'
+ #list trip_active '70'
+
+ # modem_radio_off, wifi_off, interface_down, command or none. "command" runs
+ # /etc/thermal-guard/hooks/stage1 and .../stage2
+ option stage1_action 'modem_radio_off'
+ option stage2_action 'wifi_off'
+ # AT+CFUN=4 (flight mode) or AT+CFUN=0 (minimum function), nothing else.
+ # Read back with AT+CFUN? either way
+ option modem_radio_off_at 'AT+CFUN=4'
+ # interface taken down by interface_down, the uplink interface when unset
+ #option action_interface 'wwan'
+
+ # the message is posted here. Works with ntfy, Gotify, Matrix, Slack and
+ # anything else that takes a plain POST body. For a delivery HTTP cannot
+ # express, put an executable at /etc/thermal-guard/hooks/notify instead
+ #option notify_url 'https://ntfy.sh/my-topic'
+ # only sent when curl is installed, uclient-fetch cannot set headers
+ #option notify_header 'Authorization: Bearer secret'
+ # network interface of the modem, lets the daemon tell whether another uplink exists
+ #option uplink_interface 'wwan'
diff --git a/thermal-guard/files/etc/init.d/thermal-guard b/thermal-guard/files/etc/init.d/thermal-guard
new file mode 100755
index 000000000..9179b7b5d
--- /dev/null
+++ b/thermal-guard/files/etc/init.d/thermal-guard
@@ -0,0 +1,38 @@
+#!/bin/sh /etc/rc.common
+# shellcheck shell=busybox
+# shellcheck disable=SC2034 # rc.common reads START, STOP and USE_PROCD
+# SPDX-License-Identifier: GPL-2.0-only
+#
+# Stopping or crashing leaves a forced fan forced, on purpose. Only
+# "thermal-guard reset" hands it back. Do not add a stop_service that releases
+# it: dying mid incident with the fan running is the safe direction.
+
+START=95
+STOP=10
+USE_PROCD=1
+
+PROG=/usr/sbin/thermal-guard
+
+# The instance runs whatever "enabled" says. The daemon reads the option every
+# cycle and pauses on its own, which keeps it writing the status file so the
+# web interface can say "paused, stage still active" instead of showing a
+# snapshot that quietly stopped ageing.
+start_service() {
+ procd_open_instance
+ procd_set_param command "$PROG" daemon
+ # Respawn on crash. A forced fan stays forced meanwhile, which is the safe direction.
+ procd_set_param respawn 3600 10 0
+ procd_set_param stderr 1
+ procd_close_instance
+}
+
+service_triggers() {
+ procd_add_reload_trigger thermal-guard
+}
+
+# Nothing to do. tg_cfg runs at the top of every cycle, so a changed option is
+# live within one interval. Restarting here would drop a stage action that is
+# in flight, which is a real risk for no gain. Use restart if you want that.
+reload_service() {
+ return 0
+}
diff --git a/thermal-guard/files/etc/thermal-guard/hooks/README b/thermal-guard/files/etc/thermal-guard/hooks/README
new file mode 100644
index 000000000..54279ab54
--- /dev/null
+++ b/thermal-guard/files/etc/thermal-guard/hooks/README
@@ -0,0 +1,35 @@
+Hooks for thermal-guard
+=======================
+
+Executables placed here run as root when the matching action fires. They are
+files rather than configuration options on purpose: the LuCI page may edit
+every option in /etc/config/thermal-guard, but it cannot write into this
+directory, so nothing changed through the web interface can introduce code
+that runs as root.
+
+Put a hook here over SSH and make it executable (chmod 0755). A hook that is
+missing or not executable is skipped: for stage1 and stage2 the daemon logs
+that the action could not be carried out, a missing notify hook means no
+delivery that way, and a missing modem-temp hook means no modem reading.
+
+ notify Receives the message text on stdin. Only needed for a delivery
+ that notify_url cannot express, a local mailer for example.
+ When notify_url is set as well, it is tried first and this hook
+ is the fallback.
+
+ stage1 Runs when stage 2 of 3 starts and stage1_action is 'command'.
+ stage2 Runs when stage 3 of 3 starts and stage2_action is 'command'.
+
+ modem-temp Runs when modem_source is 'command'. Has to print the modem
+ temperature in whole degrees Celsius on stdout, nothing else.
+
+Every hook runs under a time limit: 15 seconds for notify, stage1 and stage2,
+5 seconds for modem-temp, which runs every cycle. A hook still running then
+gets TERM, and KILL 5 seconds later. It counts as failed: a stage action is
+reported as not carried out, a modem-temp hook as no reading. Keep hooks short;
+a mail or HTTP call inside one needs its own, shorter time limit.
+
+Example, a notify hook for a local mailer:
+
+ #!/bin/sh
+ exec mail -s "thermal-guard" root
diff --git a/thermal-guard/files/etc/uci-defaults/90-thermal-guard-trip-base b/thermal-guard/files/etc/uci-defaults/90-thermal-guard-trip-base
new file mode 100755
index 000000000..1aede57fa
--- /dev/null
+++ b/thermal-guard/files/etc/uci-defaults/90-thermal-guard-trip-base
@@ -0,0 +1,25 @@
+#!/bin/sh
+# shellcheck shell=busybox
+# SPDX-License-Identifier: GPL-2.0-only
+# Moves trip_dt out of /etc/config/thermal-guard. The daemon keeps the device
+# tree trips that trip_active was made for in /etc/thermal-guard/trip_base now.
+# Safe to run again; always exits 0, so it runs once and is removed.
+
+dir="${PERSIST_DIR:-/etc/thermal-guard}"
+f="$dir/trip_base"
+v=$(uci -q get thermal-guard.main.trip_dt)
+[ -n "$v" ] || exit 0
+
+# Whole degrees only. Anything else is nothing worth moving, only removing.
+case "$v" in
+ *[!0-9\ ]*) v="" ;;
+esac
+if [ -n "$v" ] && [ ! -e "$f" ]; then
+ # A failed write keeps the option, so the value is not lost with it.
+ mkdir -p "$dir" && echo "$v" > "$f.tmp" && chmod 644 "$f.tmp" &&
+ mv "$f.tmp" "$f" || exit 0
+ sync
+fi
+uci -q delete thermal-guard.main.trip_dt
+uci -q commit thermal-guard
+exit 0
diff --git a/thermal-guard/files/lib/upgrade/keep.d/thermal-guard b/thermal-guard/files/lib/upgrade/keep.d/thermal-guard
new file mode 100644
index 000000000..75ee83345
--- /dev/null
+++ b/thermal-guard/files/lib/upgrade/keep.d/thermal-guard
@@ -0,0 +1 @@
+/etc/thermal-guard/
diff --git a/thermal-guard/files/usr/libexec/thermal-guard-collectd b/thermal-guard/files/usr/libexec/thermal-guard-collectd
new file mode 100755
index 000000000..070a85efa
--- /dev/null
+++ b/thermal-guard/files/usr/libexec/thermal-guard-collectd
@@ -0,0 +1,67 @@
+#!/bin/sh
+# shellcheck shell=busybox
+# SPDX-License-Identifier: GPL-2.0-only
+#
+# collectd exec plugin feed. Reads the status file the daemon writes and prints
+# PUTVAL lines, so temperatures end up in RRD without anyone else touching the
+# modem. Add to /etc/collectd.conf:
+#
+# LoadPlugin exec
+#
+# Exec "nobody:nogroup" "/usr/libexec/thermal-guard-collectd"
+#
+#
+# The status file is world readable, so an unprivileged user is enough.
+
+set -u
+
+STATUS_FILE="${STATUS_FILE:-/var/run/thermal-guard.status}"
+INTERVAL="${COLLECTD_INTERVAL:-30}"
+HOSTNAME="${COLLECTD_HOSTNAME:-$(cat /proc/sys/kernel/hostname 2>/dev/null || echo localhost)}"
+
+# collectd wants whole seconds and refuses an interval of zero
+INTERVAL=${INTERVAL%%.*}
+[ "$INTERVAL" -ge 1 ] 2>/dev/null || INTERVAL=30
+
+# The status file is written by the daemon, but a truncated write or a stale
+# file from an older version can still hand us something that is not a number.
+# Anything that is not a plain integer becomes empty, and empty is not emitted.
+tg_num() {
+ case "$1" in
+ ''|*[!0-9-]*|-|?*-*) echo "" ;;
+ *) echo "$1" ;;
+ esac
+}
+
+putval() { # $1 type-instance, $2 value
+ [ -n "$2" ] || return 0
+ printf 'PUTVAL "%s/thermal_guard/temperature-%s" interval=%s N:%s\n' \
+ "$HOSTNAME" "$1" "$INTERVAL" "$2"
+}
+
+putgauge() { # $1 type-instance, $2 value
+ [ -n "$2" ] || return 0
+ printf 'PUTVAL "%s/thermal_guard/gauge-%s" interval=%s N:%s\n' \
+ "$HOSTNAME" "$1" "$INTERVAL" "$2"
+}
+
+while :; do
+ if [ -r "$STATUS_FILE" ]; then
+ cpu=""; modem=""; stage=""; fan=""
+ while IFS='=' read -r k v; do
+ case "$k" in
+ cpu_c) cpu=$(tg_num "$v") ;;
+ modem_c) modem=$(tg_num "$v") ;;
+ stage) stage=$(tg_num "$v") ;;
+ fan_state) fan=$(tg_num "$v") ;;
+ esac
+ done < "$STATUS_FILE"
+
+ putval soc "$cpu"
+ putval modem "$modem"
+ putgauge fan_step "$fan"
+ # -1 means idle, the graph then shows anything above zero as an incident
+ putgauge stage "$stage"
+ fi
+ sleep "$INTERVAL"
+done
diff --git a/thermal-guard/files/usr/sbin/thermal-guard b/thermal-guard/files/usr/sbin/thermal-guard
new file mode 100755
index 000000000..3842fd05c
--- /dev/null
+++ b/thermal-guard/files/usr/sbin/thermal-guard
@@ -0,0 +1,2179 @@
+#!/bin/sh
+# shellcheck shell=busybox
+# SPDX-License-Identifier: GPL-2.0-only
+#
+# thermal-guard: last line of defence when a router cooks itself, typically after
+# a fan failure. Watches SoC and modem temperature and escalates in stages:
+#
+# stage 0 warning, fan to full speed unless fan_mode is none
+# stage 1 modem radio off
+# stage 2 Wi-Fi off
+#
+# Stages never roll back on their own. Cooling down is reported, but the operator
+# decides when the box is safe again ("thermal-guard reset"). That is deliberate:
+# a fan that failed once will fail again, and a silent recovery hides it.
+#
+# Usage: thermal-guard daemon|status|reset|test |selftest|-V
+#
+# "test" is a dry run: it reports what the current state plus those readings
+# would decide, and runs and writes nothing. Safe on a live box.
+
+set -u
+
+THERMAL_GUARD_VERSION="2.0.0"
+TAG=thermal-guard
+STATE_DIR="${STATE_DIR:-/var/run/thermal-guard}"
+STATE="$STATE_DIR/state"
+# /var/run is tmpfs, so the working state dies with a reboot. Stages must not:
+# a box that rebooted still has the broken fan it rebooted with. Mirror them to
+# the overlay, but only on a stage change, which is a handful of writes per
+# incident rather than one per cycle.
+PERSIST_DIR="${PERSIST_DIR:-/etc/thermal-guard}"
+PERSIST="$PERSIST_DIR/state"
+OUTBOX="$STATE_DIR/outbox"
+EVLOG="$STATE_DIR/log"
+STATUS_FILE="${STATUS_FILE:-/var/run/thermal-guard.status}"
+LIB_DIR="${LIB_DIR:-/usr/share/thermal-guard}"
+
+# Tests point these at a planted tree. Environment only, UCI cannot move them.
+THERMAL_ROOT="${THERMAL_ROOT:-/sys/class/thermal}"
+DT_ROOT="${DT_ROOT:-/sys/firmware/devicetree/base}"
+NET_ROOT="${NET_ROOT:-/sys/class/net}"
+HWMON_ROOT="${HWMON_ROOT:-/sys/class/hwmon}"
+TG_FUNCTIONS="${TG_FUNCTIONS:-/lib/functions.sh}"
+# Only for tests, which point it at a stand-in so no line reaches the host's
+# syslog. A BusyBox that prefers its applets would bypass one on PATH.
+TG_LOGGER="${TG_LOGGER:-logger}"
+# Seconds for the lock wait plus the query, and for the query alone. Well
+# below the interval, so a hung modem costs one reading, not the loop.
+AT_WAIT="${AT_WAIT:-10}"; AT_TIMEOUT="${AT_TIMEOUT:-5}"; AT_NO_TIMEOUT_TOLD=0
+# Seconds a notification post without curl may take, DNS lookup included.
+HTTP_TIMEOUT="${HTTP_TIMEOUT:-20}"
+# Seconds for wifi, ifdown and ifup, and for a hook. A hook that ignores TERM
+# is killed 5 s later.
+CMD_TIMEOUT="${CMD_TIMEOUT:-15}"; HOOK_TIMEOUT="${HOOK_TIMEOUT:-15}"
+# Seconds for a ubus call. netifd answers late while wifi down runs, and the
+# readback must not hang on exactly what it checks.
+UBUS_TIMEOUT="${UBUS_TIMEOUT:-5}"
+# Seconds between attempts when an action is read back.
+CFUN_SETTLE=2
+# Wi-Fi restarts hostapd per radio, which takes longer than a modem answers.
+WIFI_SETTLE="${WIFI_SETTLE:-5}"; WIFI_TRIES=5
+# What the fan is read from, set by tg_fan_detect. FAN_MODE says what may be
+# written; with none this can still name something to show.
+FAN_VIEW=none
+
+# ---- configuration defaults, overridden from UCI ---------------------------
+# Applied again before every read of UCI, so a deleted option is back at its
+# default within one cycle.
+tg_cfg_defaults() {
+ ENABLED=1; INTERVAL=20
+ tg_cfg_default_thresholds
+ HOLD_MINUTES=3; HYSTERESIS=10
+ CPU_TEMP_PATH="$THERMAL_ROOT/thermal_zone0/temp"
+ # Hands off the fan unless told otherwise: most boards that have one also
+ # have a regulator for it, and this is not meant to compete.
+ FAN_MODE=none; FAN_CDEV_TYPE=pwm-fan; FAN_GOVERNOR=step_wise
+ # Not options: found by type and by the zone that links to the device,
+ # again every cycle, since their numbers can change between boots.
+ FAN_CDEV=""; FAN_ZONE=""
+ # PWM_FULL is the raw value that means full speed, PWM_IDLE the one that
+ # means slowest. Several boards wire the fan inverted, where full speed is 1
+ # and 255 stops it, so the pair carries the polarity. No default: a guessed
+ # polarity stops an inverted fan in exactly the emergency it was forced for.
+ PWM_PATH=""; PWM_ENABLE_PATH=""; PWM_FULL=""; PWM_IDLE=""
+ MODEM_SOURCE=auto; MODEM_AT_PORT=auto; MODEM_TEMP_FILE=""
+ AT_LOCK=/var/lock/modem-at.lock
+ # Trip management: off, or modem to lower the fan's trips for a hot modem.
+ # TRIP_ACTIVE is the baseline in whole degrees, empty means the device tree's.
+ TRIP_BOOST=off; TRIP_ACTIVE=""; TRIP_BOOST_OFFSET=15
+ STAGE1_ACTION=modem_radio_off; STAGE2_ACTION=wifi_off
+ # Only AT+CFUN=4 or =0: the page can write every option, and a free AT
+ # command would let it reset or brick the modem.
+ MODEM_RADIO_OFF_AT='AT+CFUN=4'
+ # Interface for interface_down, the uplink interface when empty.
+ ACTION_INTERFACE=""
+ NOTIFY_URL=""; NOTIFY_HEADER=""; UPLINK_INTERFACE=""; EXTRA_SENSORS=1
+}
+
+tg_cfg_default_thresholds() {
+ CPU_WARN=70; CPU_CRIT=80; CPU_EMERGENCY=90; MODEM_WARN=80; MODEM_CRIT=88
+}
+
+tg_cfg_defaults
+
+# Executables an administrator drops in over ssh. Not reachable through the
+# configuration, which is the point: the web interface can edit every UCI option
+# but cannot write here, so nothing it changes ends up running as root.
+HOOK_DIR="${HOOK_DIR:-/etc/thermal-guard/hooks}"
+# Last sensor tg_cpu_detect settled on, so the daemon reports a substitution
+# once rather than once per cycle. NO_SENSOR marks a board that reports no
+# temperature at all, which the status file passes on so the page can say so.
+CPU_SENSOR_LAST=""; NO_SENSOR=0
+# Set once the missing pwm polarity has been reported, for the same reason.
+FAN_POLARITY_TOLD=0
+# Last pwm path change reported as "old>new". tg_cfg puts PWM_PATH back to
+# the configured value every cycle, so detection would repeat it otherwise.
+PWM_PATH_TOLD=""
+# Why the notification settings were last rejected, so each reason is said once.
+NOTIFY_TOLD=""
+# The fan_governor value last rejected, so it is said once until it changes.
+GOVERNOR_TOLD=""
+# Set once wifi_off has been checked against netifd, cleared when no stage
+# uses it any more, so adding it again checks again.
+WIFI_CHECKED=0
+# Set once a failed persist has been reported, until the next one succeeds.
+PERSIST_TOLD=0
+# Set once a missing ip or flock has been reported.
+IP_TOLD=0; FLOCK_TOLD=0
+# The fetcher that was told it cannot send notify_header.
+NOTIFY_HEADER_TOLD=""
+# Set by tg_daemon. Only the long running process narrates the configuration;
+# status, reset and test run the same checks but stay quiet about them.
+DAEMON=0
+
+# shellcheck source=thermal-guard/files/usr/share/thermal-guard/backends.sh
+. "$LIB_DIR/backends.sh"
+# shellcheck source=thermal-guard/files/usr/share/thermal-guard/trips.sh
+. "$LIB_DIR/trips.sh"
+
+# ---- state -----------------------------------------------------------------
+tg_state_clear() {
+ STAGE=-1; T0=0; T1=0; T2=0
+ # Readings at stage entry, empty where that reading was missing.
+ REF_CPU0=""; REF_MODEM0=""; REF_CPU1=""; REF_MODEM1=""
+ PENDING=-1; PENDING_N=0; COOLED=0
+ LAST_CPU=""; LAST_MODEM=""; LAST_MODEM_SRC=""; LAST_TS=0
+ # What the fan was doing before we took it over, so the release can put it
+ # back. Lives in the state so it survives a daemon restart. POLICY is the
+ # thermal zone governor for cooling_device mode, the other two are the pwm
+ # chip's manual mode and duty cycle.
+ FAN_SAVED_ENABLE=""; FAN_SAVED_PWM=""; FAN_SAVED_POLICY=""
+ # Counts resets. The daemon compares it to spot a reset that happened while
+ # it was busy deciding. tg_reset restores the old value after clearing.
+ GEN=0
+}
+tg_state_clear
+DECISION=none
+FROM_STAGE=0
+DRY=0
+MODEM_VAL=""
+MODEM_SRC=""
+
+# ---- decision logic, pure: touches variables only, no side effects ---------
+#
+# Candidate is the highest stage above the current one whose condition holds.
+# A stage only triggers after two consecutive hits, which filters sensor glitches.
+tg_candidate() {
+ local cpu="$1" modem="$2" now="$3" hold=$((HOLD_MINUTES * 60)) c=-1 down
+
+ if [ "$STAGE" -lt 0 ]; then
+ { [ -n "$cpu" ] && [ "$cpu" -ge "$CPU_WARN" ]; } ||
+ { [ -n "$modem" ] && [ "$modem" -ge "$MODEM_WARN" ]; } && c=0
+ fi
+
+ if [ "$STAGE" -lt 1 ]; then
+ if { [ -n "$cpu" ] && [ "$cpu" -ge "$CPU_CRIT" ]; } ||
+ { [ -n "$modem" ] && [ "$modem" -ge "$MODEM_CRIT" ]; }; then
+ c=1
+ elif [ "$STAGE" -eq 0 ] && [ $((now - T0)) -ge "$hold" ]; then
+ # "no drop since stage 0": no reading is below where we started. A
+ # channel counts only with a reading and a reference.
+ down=0
+ [ -n "$cpu" ] && [ -n "$REF_CPU0" ] && [ "$cpu" -lt "$REF_CPU0" ] && down=1
+ [ -n "$modem" ] && [ -n "$REF_MODEM0" ] && [ "$modem" -lt "$REF_MODEM0" ] && down=1
+ [ "$down" = 0 ] && c=1
+ fi
+ fi
+
+ if [ "$STAGE" -lt 2 ]; then
+ if [ -n "$cpu" ] && [ "$cpu" -ge "$CPU_EMERGENCY" ]; then
+ c=2
+ elif [ "$STAGE" -eq 1 ] && [ $((now - T1)) -ge "$hold" ]; then
+ # "still climbing": some reading is above where stage 1 started,
+ # counted only where both the reading and the reference exist
+ { [ -n "$cpu" ] && [ -n "$REF_CPU1" ] && [ "$cpu" -gt "$REF_CPU1" ]; } ||
+ { [ -n "$modem" ] && [ -n "$REF_MODEM1" ] && [ "$modem" -gt "$REF_MODEM1" ]; } &&
+ c=2
+ fi
+ fi
+
+ echo "$c"
+}
+
+tg_enter_stage() { # $1 stage, $2 cpu, $3 modem, $4 now
+ case "$1" in
+ 0) T0=$4; REF_CPU0=${2:-}; REF_MODEM0=${3:-} ;;
+ 1) T1=$4; REF_CPU1=${2:-}; REF_MODEM1=${3:-} ;;
+ 2) T2=$4 ;;
+ esac
+ STAGE=$1
+}
+
+tg_decide() { # $1 cpu, $2 modem, $3 now -> DECISION
+ local cpu="$1" modem="$2" now="$3" c s ok
+ DECISION=none
+ # A reference missing because its reading was missing at stage entry is
+ # taken from the first reading after the gap. The channel then compares
+ # again, as "risen since it came back".
+ if [ "$STAGE" -ge 0 ]; then
+ [ -n "$REF_CPU0" ] || REF_CPU0=$cpu; [ -n "$REF_MODEM0" ] || REF_MODEM0=$modem
+ fi
+ if [ "$STAGE" -ge 1 ]; then
+ [ -n "$REF_CPU1" ] || REF_CPU1=$cpu; [ -n "$REF_MODEM1" ] || REF_MODEM1=$modem
+ fi
+ c=$(tg_candidate "$cpu" "$modem" "$now")
+
+ if [ "$c" -gt "$STAGE" ]; then
+ if [ "$c" -eq "$PENDING" ]; then
+ PENDING_N=$((PENDING_N + 1))
+ else
+ PENDING=$c; PENDING_N=1
+ fi
+ if [ "$PENDING_N" -ge 2 ]; then
+ s=$((STAGE + 1))
+ while [ "$s" -le "$c" ]; do
+ tg_enter_stage "$s" "$cpu" "$modem" "$now"
+ s=$((s + 1))
+ done
+ PENDING=-1; PENDING_N=0; COOLED=0
+ DECISION="stage:$c"
+ fi
+ return 0
+ fi
+
+ PENDING=-1; PENDING_N=0
+
+ # Cooling down is reported once. Nothing is taken back, a missing reading counts as cool.
+ if [ "$STAGE" -ge 0 ] && [ "$COOLED" = 0 ]; then
+ ok=1
+ [ -n "$cpu" ] && [ "$cpu" -gt $((CPU_WARN - HYSTERESIS)) ] && ok=0
+ [ -n "$modem" ] && [ "$modem" -gt $((MODEM_WARN - HYSTERESIS)) ] && ok=0
+ [ "$ok" = 1 ] && { COOLED=1; DECISION=cooled; }
+ fi
+}
+
+# ---- logging and notification ----------------------------------------------
+# $1 text, $2 syslog priority: crit for a stage or a failed action, warning
+# for a rejected option or a missing tool, notice for cooled, reset and
+# restore, info (the default) for the rest. The event log line does not carry
+# it. An unknown priority is a typo at the call site and ends up as info.
+tg_log() {
+ local prio="${2:-info}"
+ case "$prio" in
+ crit|warning|notice|info) ;;
+ *) prio=info ;;
+ esac
+ # Never let logging fail a caller, not even where logger is missing.
+ "$TG_LOGGER" -t "$TAG" -p "daemon.$prio" "$1" 2>/dev/null
+ mkdir -p "$STATE_DIR"
+ printf '%s %s\n' "$(date '+%F %T')" "$1" >> "$EVLOG"
+ [ "$(wc -l < "$EVLOG")" -gt 50 ] && { tail -n 50 "$EVLOG" > "$EVLOG.tmp" && mv "$EVLOG.tmp" "$EVLOG"; }
+ return 0
+}
+
+# Complaints about the configuration, which only the daemon should report.
+# Every entry point runs the same validation, so a one-shot command logging
+# them repeats the same lines on every call: three "thermal-guard status" runs
+# produced three identical entries on a board without a sensor. The event log
+# keeps 50 lines, so a monitoring check calling status in a loop would flush
+# out the record of an actual incident within the hour.
+# $2 as for tg_log, warning by default: nearly all of them reject something.
+tg_cfg_log() {
+ [ "$DAEMON" = 1 ] && tg_log "$1" "${2:-warning}"
+ return 0
+}
+
+# True when there is any way to deliver a message at all.
+tg_notify_configured() {
+ [ -n "$NOTIFY_URL" ] || [ -x "$HOOK_DIR/notify" ]
+}
+
+# Try HTTP first, fall back to the hook. Either may be configured on its own.
+tg_notify_send() { # $1 text
+ tg_notify_http "$1" && return 0
+ tg_notify_hook "$1"
+}
+
+# $1 the stage (0 to 2, also for a restore), blind, cooled or reset; the
+# syslog priority follows from it. $2 text.
+tg_notify() {
+ local text prio=crit
+ case "$1" in
+ blind) prio=warning ;;
+ cooled|reset) prio=notice ;;
+ esac
+ # A dry run touches neither syslog nor the event log nor the outbox. It
+ # says what it would send, with the priority a remote alert would see.
+ if [ "$DRY" = 1 ]; then
+ echo "would notify ($prio): $2"
+ return 0
+ fi
+ text="[$(cat /proc/sys/kernel/hostname 2>/dev/null)] $TAG: $2"
+ tg_log "$2" "$prio"
+ tg_notify_configured || return 0
+ mkdir -p "$STATE_DIR"
+
+ # From stage 1 on we may be about to switch off the only uplink. Queue the
+ # message instead of losing it, the daemon retries while a route exists.
+ case "$1" in
+ 0|blind|reset|cooled) ;;
+ *) tg_alt_uplink || {
+ printf '%s %s\n' "$(date '+%F %T')" "$2" >> "$OUTBOX"
+ tg_log "no alternative uplink, message queued"
+ return 0
+ } ;;
+ esac
+
+ tg_notify_send "$text" && return 0
+ printf '%s %s\n' "$(date '+%F %T')" "$2" >> "$OUTBOX"
+ return 0
+}
+
+LAST_FLUSH=0
+tg_flush_outbox() {
+ local now
+ [ -s "$OUTBOX" ] || return 0
+ tg_notify_configured || return 0
+ if [ "$STAGE" -ge 0 ]; then tg_alt_uplink || return 0; else tg_any_uplink || return 0; fi
+ now=$(date +%s)
+ [ $((now - LAST_FLUSH)) -ge 120 ] || return 0
+ LAST_FLUSH=$now
+ if tg_notify_send "$(printf '[%s] %s queued messages:\n%s' \
+ "$(cat /proc/sys/kernel/hostname 2>/dev/null)" "$TAG" "$(cat "$OUTBOX")")"; then
+ : > "$OUTBOX"
+ tg_log "queued messages delivered"
+ fi
+ return 0
+}
+
+# ---- actions ---------------------------------------------------------------
+# Returns what the action returned. A stage that could not do what it promised
+# has to be visible: on a box with a dead fan, "modem off" quietly failing is
+# the difference between a warm modem and a dead one.
+tg_run_action() { # $1 action name, $2 hook name for the "command" action
+ case "$1" in
+ modem_radio_off) tg_action_modem_radio_off ;;
+ wifi_off) tg_action_wifi_off ;;
+ interface_down) tg_action_interface_down ;;
+ command) tg_run_hook "$2" ;;
+ none|'') return 0 ;;
+ *) tg_log "unknown action '$1', doing nothing" warning; return 1 ;;
+ esac
+}
+
+# The reverse of a stage action, on reset. A hook has no reverse; the operator
+# who wrote it knows what to undo.
+tg_undo_action() { # $1 action name
+ case "$1" in
+ modem_radio_off) tg_action_modem_radio_on || tg_log "could not switch the modem back on" crit ;;
+ wifi_off) tg_action_wifi_on ;;
+ interface_down) tg_action_interface_up || tg_log "could not bring the interface back up" crit ;;
+ esac
+ return 0
+}
+
+# Runs an executable from the hook directory. Says so when the action asks for
+# a hook that is not there, because a stage that silently does nothing is the
+# failure this daemon exists to avoid.
+tg_run_hook() { # $1 hook name
+ local hook="$HOOK_DIR/$1"
+ if [ ! -x "$hook" ]; then
+ tg_log "action is set to 'command' but $hook is missing or not executable" warning
+ return 1
+ fi
+ timeout -k 5 "$HOOK_TIMEOUT" "$hook" >/dev/null 2>&1
+}
+
+tg_act() { # $1 stage, $2 cpu, $3 modem
+ case "$1" in
+ 0)
+ if [ "$FAN_MODE" = none ]; then
+ tg_notify 0 "stage 0: cpu ${2:-?} C, modem ${3:-?} C, fan left to its own controller. Check the fan."
+ return 0
+ fi
+ tg_notify 0 "stage 0: cpu ${2:-?} C, modem ${3:-?} C, forcing fan to full speed. Check the fan."
+ [ "$DRY" = 1 ] && return 0
+ tg_fan_full || tg_log "could not force the fan (mode $FAN_MODE)" crit
+ ;;
+ 1)
+ tg_notify 1 "stage 1: cpu ${2:-?} C, modem ${3:-?} C, running $STAGE1_ACTION."
+ [ "$DRY" = 1 ] && return 0
+ tg_run_action "$STAGE1_ACTION" stage1 ||
+ tg_notify 1 "stage 1 action '$STAGE1_ACTION' failed, the box is still hot"
+ ;;
+ 2)
+ tg_notify 2 "stage 2: cpu ${2:-?} C, modem ${3:-?} C, running $STAGE2_ACTION."
+ [ "$DRY" = 1 ] && return 0
+ tg_run_action "$STAGE2_ACTION" stage2 ||
+ tg_notify 2 "stage 2 action '$STAGE2_ACTION' failed, the box is still hot"
+ ;;
+ esac
+}
+
+# Read GEN straight from the file, without disturbing the decision we are
+# holding in memory. tg_state_load would overwrite all of it.
+tg_state_gen() {
+ local g
+ [ -r "$STATE" ] || { echo "$GEN"; return; }
+ g=$(tg_int "$(sed -n 's/^GEN=//p' "$STATE" | head -n1)")
+ echo "${g:-0}"
+}
+
+# True while no reset happened since $1 was taken. On a reset it adopts the
+# cleared state and refreshes the status file, so the UI shows what the
+# operator did rather than our stale view.
+tg_state_unchanged() { # $1 GEN snapshot, empty skips the check
+ [ -n "$1" ] || return 0
+ [ "$(tg_state_gen)" = "$1" ] && return 0
+ tg_log "reset during this cycle, dropping the decision" notice
+ tg_state_load
+ tg_status_write
+ return 1
+}
+
+tg_cycle() { # $1 cpu, $2 modem, $3 now, $4 GEN when the state was loaded
+ local d s gen="${4:-}"
+ tg_decide "$1" "$2" "$3"
+ LAST_CPU=$1; LAST_MODEM=$2; LAST_TS=$3
+ LAST_MODEM_SRC=$MODEM_SRC
+ [ -n "$2" ] || LAST_MODEM_SRC=""
+
+ # Check before acting, not just before saving. Reading the modem takes
+ # seconds, and a Clear pressed in that window has already handed the fan
+ # back. Acting now would force it again while the state says idle.
+ tg_state_unchanged "$gen" || return 0
+
+ case "$DECISION" in
+ stage:*)
+ d=${DECISION#stage:}
+ # run skipped stages too, a jump from idle to emergency still needs the fan
+ s=$FROM_STAGE
+ while [ "$s" -le "$d" ]; do
+ tg_act "$s" "$1" "$2"
+ s=$((s + 1))
+ done
+ ;;
+ cooled)
+ tg_notify cooled "cooled down: cpu ${1:-?} C, modem ${2:-?} C. Stage $STAGE stays active, clear it with 'thermal-guard reset'."
+ ;;
+ esac
+
+ # And again: the actions above can take a while too.
+ tg_state_unchanged "$gen" || return 0
+
+ # A dry run reports and returns. It must not leave behind a stage that no
+ # action was taken for: "thermal-guard test 95 95" on a live box would
+ # otherwise persist stage 2, and the next boot would act on it.
+ [ "$DRY" = 1 ] && return 0
+
+ tg_state_save
+ # Only stage changes reach the overlay, see tg_state_persist.
+ case "$DECISION" in stage:*) tg_state_persist ;; esac
+ tg_status_write
+}
+
+# ---- persistence -----------------------------------------------------------
+tg_state_save() {
+ mkdir -p "$STATE_DIR"
+ tg_state_write "$STATE"
+}
+
+# Mirror to the overlay. Called on stage changes only, so the flash sees a
+# handful of writes per incident instead of one every interval.
+tg_state_persist() {
+ if mkdir -p "$PERSIST_DIR" 2>/dev/null && tg_state_write "$PERSIST" 2>/dev/null; then
+ # A stage that only sits in the page cache is lost to a power cut.
+ sync
+ PERSIST_TOLD=0
+ return 0
+ fi
+ if [ "$PERSIST_TOLD" = 0 ]; then
+ tg_log "could not persist stage $STAGE to $PERSIST, it will not survive a reboot" crit
+ [ "$DAEMON" = 1 ] && PERSIST_TOLD=1
+ fi
+ return 1
+}
+
+# Returns the status of the rename, so a caller knows whether the file landed.
+tg_state_write() { # $1 target file
+ cat > "$1.tmp" <<-EOF || return 1
+ STAGE=$STAGE
+ T0=$T0
+ T1=$T1
+ T2=$T2
+ REF_CPU0=$REF_CPU0
+ REF_MODEM0=$REF_MODEM0
+ REF_CPU1=$REF_CPU1
+ REF_MODEM1=$REF_MODEM1
+ PENDING=$PENDING
+ PENDING_N=$PENDING_N
+ COOLED=$COOLED
+ LAST_CPU=$LAST_CPU
+ LAST_MODEM=$LAST_MODEM
+ LAST_MODEM_SRC=$LAST_MODEM_SRC
+ LAST_TS=$LAST_TS
+ FAN_SAVED_ENABLE=$FAN_SAVED_ENABLE
+ FAN_SAVED_PWM=$FAN_SAVED_PWM
+ FAN_SAVED_POLICY=$FAN_SAVED_POLICY
+ GEN=$GEN
+ EOF
+ mv "$1.tmp" "$1"
+}
+
+# Only known keys with integer or empty values, the file is also written by "reset".
+tg_state_load() {
+ tg_state_clear
+ tg_state_read "$STATE"
+}
+
+tg_state_read() { # $1 source file
+ [ -r "$1" ] || return 0
+ while IFS='=' read -r k v; do
+ case "$k" in
+ STAGE|T0|T1|T2|REF_CPU0|REF_MODEM0|REF_CPU1|REF_MODEM1|PENDING|PENDING_N|COOLED|LAST_TS|GEN)
+ [ -n "$(tg_int "$v")" ] && eval "$k=\$v" ;;
+ LAST_CPU|LAST_MODEM|FAN_SAVED_ENABLE|FAN_SAVED_PWM)
+ eval "$k=\$(tg_int \"\$v\")" ;;
+ LAST_MODEM_SRC)
+ case "$v" in file|command|at|test) LAST_MODEM_SRC=$v ;; *) LAST_MODEM_SRC="" ;; esac ;;
+ # Governor names, not free text: this value is written back to sysfs.
+ FAN_SAVED_POLICY)
+ case "$v" in
+ step_wise|fair_share|bang_bang|user_space|power_allocator)
+ FAN_SAVED_POLICY=$v ;;
+ *) FAN_SAVED_POLICY="" ;;
+ esac ;;
+ esac
+ done < "$1"
+}
+
+# After a reboot /var/run is empty while the overlay may still hold a stage. The
+# box did not get a new fan while it was down, so the mitigations go back on.
+# Restoring the stage without reapplying would be the worst of both: the daemon
+# would consider itself fully escalated and stop reacting, while nothing is
+# actually in force.
+tg_state_restore() {
+ local s what failed="" msg
+ [ -r "$STATE" ] && return 0
+ [ -r "$PERSIST" ] || return 0
+
+ tg_state_clear
+ tg_state_read "$PERSIST"
+ [ "$STAGE" -ge 0 ] || return 0
+
+ # Reapplying can take over a minute (Wi-Fi retries). Should the daemon die
+ # in between, the event log and the history of reset show it had begun.
+ tg_log "restoring stage $STAGE after a reboot" notice
+ s=0
+ while [ "$s" -le "$STAGE" ]; do
+ if ! tg_reapply "$s"; then
+ case "$s" in
+ 0) what="forcing the fan" ;;
+ 1) what="stage 1 action '$STAGE1_ACTION'" ;;
+ *) what="stage 2 action '$STAGE2_ACTION'" ;;
+ esac
+ failed="$failed${failed:+, }$what"
+ fi
+ s=$((s + 1))
+ done
+ # One message, after the actions, so it can say which of them failed.
+ if [ -n "$failed" ]; then
+ msg="restored stage $STAGE after a reboot, but reapplying failed for: $failed."
+ msg="$msg The box may be without that protection."
+ else
+ msg="restored stage $STAGE after a reboot and reapplied its actions."
+ fi
+ tg_notify "$STAGE" "$msg Clear it with 'thermal-guard reset' once the cause is fixed."
+ tg_state_save
+}
+
+# The effects of a stage without its announcement, for the reboot path where one
+# message covers the whole restore.
+# $1 stage. A failure is logged as crit and returns 1, so the restore can
+# name it in its message.
+tg_reapply() {
+ case "$1" in
+ 0)
+ [ "$FAN_MODE" = none ] && return 0
+ tg_fan_full && return 0
+ tg_log "could not force the fan (mode $FAN_MODE)" crit
+ ;;
+ 1)
+ tg_run_action "$STAGE1_ACTION" stage1 && return 0
+ tg_log "stage 1 action '$STAGE1_ACTION' failed after the reboot" crit
+ ;;
+ 2)
+ tg_run_action "$STAGE2_ACTION" stage2 && return 0
+ tg_log "stage 2 action '$STAGE2_ACTION' failed after the reboot" crit
+ ;;
+ *) return 0 ;;
+ esac
+ return 1
+}
+
+# Machine readable snapshot for the LuCI page, so the UI never talks to the modem.
+tg_status_write() {
+ cat > "$STATUS_FILE.tmp" <<-EOF
+ version=$THERMAL_GUARD_VERSION
+ ts=$LAST_TS
+ now=$(date +%s)
+ interval=$INTERVAL
+ enabled=$ENABLED
+ stage=$STAGE
+ cooled=$COOLED
+ cpu_c=$LAST_CPU
+ cpu_src=$CPU_TEMP_PATH
+ no_sensor=$NO_SENSOR
+ modem_c=$LAST_MODEM
+ modem_src=$LAST_MODEM_SRC
+ cpu_warn=$CPU_WARN
+ cpu_crit=$CPU_CRIT
+ cpu_emergency=$CPU_EMERGENCY
+ modem_warn=$MODEM_WARN
+ modem_crit=$MODEM_CRIT
+ fan_mode=$FAN_MODE
+ fan_view=$FAN_VIEW
+ fan_state=$(tg_fan_state)
+ fan_max=$(tg_fan_max)
+ fan_percent=$(tg_fan_percent)
+ fan_foreign=$FAN_FOREIGN
+ fan_rpm=$(tg_fan_rpm)
+ stage1_action=$STAGE1_ACTION
+ stage2_action=$STAGE2_ACTION
+ trip_state=$TRIP_STATE
+ trip_reason=$TRIP_REASON
+ trip_boost=$TRIP_BOOST
+ trip_zone=$TRIP_ZONE
+ trip_base=$(tg_trip_deg "$TRIP_BASE")
+ trip_now=$(tg_trip_deg "$TRIP_NOW")
+ trip_dt=$(tg_trip_deg "$TRIP_DT")
+ trip_delta=$((TRIP_DELTA / 1000))
+ trip_dt_changed=$TRIP_DT_CHANGED
+ trip_limit=$(tg_trip_deg "$TRIP_LIMIT")
+ fan_levels=$(tg_fan_levels)
+ t0=$T0
+ t1=$T1
+ t2=$T2
+ EOF
+ # One line per extra sensor, prefixed so the UI can pick them out.
+ tg_read_extra_sensors | while IFS='=' read -r n v; do
+ printf 'sensor.%s=%s\n' "$n" "$v"
+ done >> "$STATUS_FILE.tmp"
+ chmod 644 "$STATUS_FILE.tmp" 2>/dev/null
+ mv "$STATUS_FILE.tmp" "$STATUS_FILE"
+}
+
+# ---- configuration ---------------------------------------------------------
+# $1 option, $2 variable. An empty or invalid value leaves the default that
+# tg_cfg_defaults set just before. config_get needs its fourth argument, the
+# original expands it for a missing option and set -u would end the daemon.
+tg_cfg_int() {
+ local v=""
+ config_get v main "$1" ""
+ [ -n "$(tg_int "$v")" ] && eval "$2=\$v"
+ return 0
+}
+
+tg_cfg_str() {
+ local v=""
+ config_get v main "$1" ""
+ [ -n "$v" ] && eval "$2=\$v"
+ return 0
+}
+
+# UCI can name any file. Keep every path inside the subtree it belongs to, fall
+# back to the default where there is one and drop the feature where there is not.
+tg_cfg_validate_paths() {
+ if ! tg_path_sensor "$CPU_TEMP_PATH"; then
+ tg_cfg_log "rejecting cpu_temp_path '$CPU_TEMP_PATH', using default"
+ CPU_TEMP_PATH="$THERMAL_ROOT/thermal_zone0/temp"
+ fi
+ if [ -n "$PWM_PATH" ] && ! tg_path_hwmon "$PWM_PATH"; then
+ tg_cfg_log "rejecting pwm_path '$PWM_PATH'"
+ PWM_PATH=""
+ fi
+ if [ -n "$PWM_ENABLE_PATH" ] && ! tg_path_hwmon "$PWM_ENABLE_PATH"; then
+ tg_cfg_log "rejecting pwm_enable_path '$PWM_ENABLE_PATH'"
+ PWM_ENABLE_PATH=""
+ fi
+ if [ -n "$MODEM_TEMP_FILE" ] && ! tg_path_modem_file "$MODEM_TEMP_FILE"; then
+ tg_cfg_log "rejecting modem_temp_file '$MODEM_TEMP_FILE'"
+ MODEM_TEMP_FILE=""
+ fi
+ if [ "$MODEM_AT_PORT" != auto ] && ! tg_path_dev "$MODEM_AT_PORT"; then
+ tg_cfg_log "rejecting modem_at_port '$MODEM_AT_PORT', using auto"
+ MODEM_AT_PORT=auto
+ fi
+ if ! tg_path_lock "$AT_LOCK"; then
+ tg_cfg_log "rejecting at_lock '$AT_LOCK', using default"
+ AT_LOCK=/var/lock/modem-at.lock
+ fi
+}
+
+# Reads UCI where it exists and always ends with the checks that have to run
+# either way, so a run without uci behaves like one with an empty config.
+tg_cfg() {
+ tg_cfg_from_uci
+ tg_cfg_validate_paths
+ tg_cpu_detect
+ tg_cfg_check_wifi
+}
+
+# wifi_off on a device whose Wi-Fi netifd does not manage cannot work. Say so
+# when the daemon loads the configuration, not when the box is hot. Only the
+# daemon asks netifd; a ubus error is left to the action, and asked again.
+tg_cfg_check_wifi() {
+ local state
+ [ "$DAEMON" = 1 ] || return 0
+ case " $STAGE1_ACTION $STAGE2_ACTION " in
+ *" wifi_off "*) ;;
+ *) WIFI_CHECKED=0; return 0 ;;
+ esac
+ [ "$WIFI_CHECKED" = 1 ] && return 0
+ state=$(tg_wifi_radios)
+ [ -n "$state" ] || return 0
+ WIFI_CHECKED=1
+ [ "${state%% *}" -eq 0 ] &&
+ tg_log "wifi_off is configured but netifd manages no Wi-Fi radio on this device" warning
+ return 0
+}
+
+tg_cfg_from_uci() {
+ local v
+ tg_cfg_defaults
+ tg_have uci || return 0
+ [ -r "$TG_FUNCTIONS" ] || return 0
+ # uci_load clears lists before loading but not plain options, so an option
+ # deleted since the last cycle would still be there.
+ for v in $(set | sed -n 's/^\(CONFIG_main_[A-Za-z0-9_]*\)=.*/\1/p'); do
+ unset "$v"
+ done
+ # functions.sh reads unset variables all over, which set -u turns fatal.
+ set +u
+ # shellcheck source=/dev/null
+ tg_have config_load || . "$TG_FUNCTIONS"
+ # Keeps notify_header and the rest out of the environment of every child.
+ # shellcheck disable=SC2034
+ NO_EXPORT=1
+ config_load thermal-guard
+ set -u
+ tg_cfg_int enabled ENABLED
+ tg_cfg_int interval INTERVAL
+ tg_cfg_int cpu_warn CPU_WARN
+ tg_cfg_int cpu_crit CPU_CRIT
+ tg_cfg_int cpu_emergency CPU_EMERGENCY
+ tg_cfg_int modem_warn MODEM_WARN
+ tg_cfg_int modem_crit MODEM_CRIT
+ tg_cfg_int hold_minutes HOLD_MINUTES
+ tg_cfg_int hysteresis HYSTERESIS
+ tg_cfg_int pwm_full PWM_FULL
+ tg_cfg_int pwm_idle PWM_IDLE
+ tg_cfg_str cpu_temp_path CPU_TEMP_PATH
+ tg_cfg_str fan_mode FAN_MODE
+ tg_cfg_str fan_cooling_device_type FAN_CDEV_TYPE
+ tg_cfg_str fan_governor FAN_GOVERNOR
+ tg_cfg_str pwm_path PWM_PATH
+ tg_cfg_str pwm_enable_path PWM_ENABLE_PATH
+ tg_cfg_str modem_source MODEM_SOURCE
+ tg_cfg_str modem_at_port MODEM_AT_PORT
+ tg_cfg_str modem_temp_file MODEM_TEMP_FILE
+ tg_cfg_str at_lock AT_LOCK
+ tg_cfg_str notify_url NOTIFY_URL
+ tg_cfg_str notify_header NOTIFY_HEADER
+ tg_cfg_str stage1_action STAGE1_ACTION
+ tg_cfg_str stage2_action STAGE2_ACTION
+ tg_cfg_str uplink_interface UPLINK_INTERFACE
+ tg_cfg_str modem_radio_off_at MODEM_RADIO_OFF_AT
+ tg_cfg_str action_interface ACTION_INTERFACE
+ tg_cfg_int extra_sensors EXTRA_SENSORS
+ # As strings, so tg_cfg_validate_trips sees a bad value and says so.
+ tg_cfg_str trip_boost TRIP_BOOST
+ tg_cfg_str trip_active TRIP_ACTIVE
+ tg_cfg_str trip_boost_offset TRIP_BOOST_OFFSET
+ tg_cfg_validate_trips
+ tg_cfg_validate_governor
+
+ # Thresholds out of order would silently disable the guard, so fall back to defaults.
+ if [ "$CPU_WARN" -ge "$CPU_CRIT" ] || [ "$CPU_CRIT" -ge "$CPU_EMERGENCY" ] ||
+ [ "$MODEM_WARN" -ge "$MODEM_CRIT" ]; then
+ tg_cfg_log "implausible thresholds (cpu $CPU_WARN/$CPU_CRIT/$CPU_EMERGENCY, modem $MODEM_WARN/$MODEM_CRIT), using defaults"
+ tg_cfg_default_thresholds
+ fi
+ [ "$INTERVAL" -ge 5 ] || INTERVAL=5
+ tg_cfg_validate_actions
+ tg_cfg_validate_notify
+}
+
+# Checked when the configuration loads, not when a message goes out: a rejected
+# address then behaves like no address at all (the hook is the fallback, nothing
+# queues in an outbox that could never deliver). Called from tg_cfg_from_uci
+# because both options only ever come from UCI. The address itself is never
+# logged, it usually carries the token.
+tg_cfg_validate_notify() {
+ local tool rest what msg reason=""
+ case "$NOTIFY_URL" in
+ ''|http://?*|https://?*) ;;
+ *) reason=scheme ;;
+ esac
+ if [ -z "$reason" ] && [ -n "$NOTIFY_URL" ] && ! tg_http_safe "$NOTIFY_URL"; then
+ reason=chars
+ fi
+ if [ -z "$reason" ] && [ -n "$NOTIFY_HEADER" ] && ! tg_http_safe "$NOTIFY_HEADER"; then
+ reason=header
+ fi
+ # Only curl takes the address from a file; the others put it on the
+ # command line, which every local process can read in /proc.
+ if [ -z "$reason" ] && [ -n "$NOTIFY_URL" ]; then
+ tool=$(tg_fetcher)
+ case "$tool" in
+ uclient-fetch|wget)
+ rest=${NOTIFY_URL#*://}
+ case "$NOTIFY_URL" in *\?*) reason="query:$tool" ;; esac
+ if [ -z "$reason" ]; then
+ case "${rest%%/*}" in *@*) reason="userinfo:$tool" ;; esac
+ fi
+ ;;
+ esac
+ fi
+
+ case "$reason" in
+ '') NOTIFY_TOLD=""; return 0 ;;
+ header) NOTIFY_HEADER="" ;;
+ *) NOTIFY_URL="" ;;
+ esac
+ [ "$NOTIFY_TOLD" = "$reason" ] && return 0
+ case "$reason" in
+ scheme) tg_cfg_log "notify_url is not an http or https address, not sending over HTTP" ;;
+ chars)
+ tg_cfg_log "notify_url contains characters that are not allowed in one, not sending over HTTP"
+ ;;
+ header)
+ tg_cfg_log "notify_header contains characters that are not allowed in one, sending without it"
+ ;;
+ *)
+ what="a query string"
+ [ "${reason%%:*}" = userinfo ] && what="user info"
+ msg="notify_url carries $what, which ${reason#*:} would show in the process list."
+ tg_cfg_log "$msg Not sending over HTTP; install curl or use a hook."
+ ;;
+ esac
+ [ "$DAEMON" = 1 ] && NOTIFY_TOLD=$reason
+ return 0
+}
+
+# The zone goes back to this governor when the stages are cleared and no
+# earlier policy was saved. user_space is not on the list: handing the zone
+# back to user space means nothing regulates the fan after a reset.
+tg_cfg_validate_governor() {
+ case "$FAN_GOVERNOR" in
+ step_wise|fair_share|bang_bang|power_allocator)
+ GOVERNOR_TOLD=""
+ return 0 ;;
+ esac
+ [ "$GOVERNOR_TOLD" = "$FAN_GOVERNOR" ] ||
+ tg_cfg_log "rejecting fan_governor '$FAN_GOVERNOR', using step_wise"
+ [ "$DAEMON" = 1 ] && GOVERNOR_TOLD=$FAN_GOVERNOR
+ FAN_GOVERNOR=step_wise
+ return 0
+}
+
+tg_cfg_validate_trips() {
+ case "${TRIP_BOOST:-off}" in
+ off|modem) TRIP_BOOST=${TRIP_BOOST:-off} ;;
+ *) tg_cfg_log "rejecting trip_boost '$TRIP_BOOST', using off"; TRIP_BOOST=off ;;
+ esac
+ if [ -z "$TRIP_BOOST_OFFSET" ]; then
+ TRIP_BOOST_OFFSET=15
+ elif [ -z "$(tg_int "$TRIP_BOOST_OFFSET")" ] || [ "$TRIP_BOOST_OFFSET" -lt 0 ] ||
+ [ "$TRIP_BOOST_OFFSET" -gt 40 ]; then
+ tg_cfg_log "rejecting trip_boost_offset '$TRIP_BOOST_OFFSET', using 15"
+ TRIP_BOOST_OFFSET=15
+ fi
+}
+
+tg_cfg_validate_actions() {
+ case "$MODEM_RADIO_OFF_AT" in
+ 'AT+CFUN=4'|'AT+CFUN=0') ;;
+ *)
+ tg_cfg_log "rejecting modem_radio_off_at '$MODEM_RADIO_OFF_AT', using AT+CFUN=4"
+ MODEM_RADIO_OFF_AT='AT+CFUN=4'
+ ;;
+ esac
+ if [ -n "$UPLINK_INTERFACE" ] && ! tg_iface_name_ok "$UPLINK_INTERFACE"; then
+ tg_cfg_log "rejecting uplink_interface '$UPLINK_INTERFACE'"
+ UPLINK_INTERFACE=""
+ fi
+ if [ -n "$ACTION_INTERFACE" ] && ! tg_iface_name_ok "$ACTION_INTERFACE"; then
+ tg_cfg_log "rejecting action_interface '$ACTION_INTERFACE'"
+ ACTION_INTERFACE=""
+ fi
+}
+
+# The default path fits mediatek and most ARM targets but not ramips or x86,
+# where the SoC sensor is a hwmon node. Rather than run blind on a board nobody
+# configured by hand, look for a working sensor and say which one we took.
+tg_cpu_detect() {
+ local found
+ NO_SENSOR=0
+ [ -r "$CPU_TEMP_PATH" ] && return 0
+
+ found=$(tg_find_cpu_sensor)
+ if [ -n "$found" ] && tg_path_sensor "$found"; then
+ if [ "$found" != "$CPU_SENSOR_LAST" ]; then
+ tg_cfg_log "cpu_temp_path '$CPU_TEMP_PATH' does not read, using '$found' instead" info
+ CPU_SENSOR_LAST=$found
+ fi
+ CPU_TEMP_PATH=$found
+ return 0
+ fi
+
+ # Nothing to fall back to. Say which of the two cases this is, once, so the
+ # log does not read like a typo when it is a property of the board.
+ tg_has_any_sensor || NO_SENSOR=1
+ if [ "$NO_SENSOR" = 1 ] && [ "$CPU_SENSOR_LAST" != none ]; then
+ tg_cfg_log "this device reports no temperature at all, nothing to watch here"
+ CPU_SENSOR_LAST=none
+ fi
+}
+
+# ---- commands --------------------------------------------------------------
+tg_daemon() {
+ local cpu modem now blind_cpu=0 blind_modem=0 prev_src=""
+ # From here the configuration diagnostics are worth logging: this process
+ # stays, so each of them is said once rather than once per invocation.
+ DAEMON=1
+ mkdir -p "$STATE_DIR"
+ tg_cfg
+ tg_fan_detect
+ # Before the first load, so a reboot does not silently drop the stages.
+ tg_state_restore
+ tg_state_load
+ tg_log "started, version $THERMAL_GUARD_VERSION, stage $STAGE, fan mode $FAN_MODE, modem source $MODEM_SOURCE"
+
+ while :; do
+ # tg_cfg resets FAN_MODE to whatever UCI says, so the detection has to run
+ # again. It only reads sysfs, so doing it every cycle is cheap and also
+ # picks up a fan that appeared after boot.
+ tg_cfg
+ tg_fan_detect
+ # Paused, not stopped. Stages and a forced fan stay exactly as they
+ # are, because releasing them here would hide a broken fan behind a
+ # checkbox. Keep refreshing the status file so the web interface can
+ # say so instead of showing a snapshot that stopped ageing.
+ if [ "$ENABLED" = 0 ]; then
+ tg_state_load
+ tg_status_write
+ sleep "$INTERVAL"
+ continue
+ fi
+
+ cpu=$(tg_read_cpu)
+ tg_read_modem
+ modem=$MODEM_VAL
+ tg_modem_publish
+ now=$(date +%s)
+
+ # Only worth a notification where a sensor exists but does not read.
+ # On a board without any, tg_cpu_detect has already said so once and
+ # there is nothing an operator could fix.
+ if [ -z "$cpu" ] && [ "$blind_cpu" = 0 ] && [ "$NO_SENSOR" = 0 ]; then
+ tg_notify blind "blind: cannot read cpu temperature from $CPU_TEMP_PATH"
+ blind_cpu=1
+ fi
+ [ -n "$cpu" ] && blind_cpu=0
+
+ if [ -z "$modem" ] && [ "$blind_modem" = 0 ] && [ "$MODEM_SOURCE" != none ]; then
+ tg_log "cannot read modem temperature" warning
+ blind_modem=1
+ fi
+ [ -n "$modem" ] && blind_modem=0
+
+ # Log a source change, otherwise a jump in the reading looks like physics.
+ if [ -n "$modem" ] && [ "$MODEM_SRC" != "$prev_src" ]; then
+ [ -n "$prev_src" ] && tg_log "modem reading now from '$MODEM_SRC' (was '$prev_src'): $modem C"
+ prev_src=$MODEM_SRC
+ fi
+
+ # The state file is the single source of truth, "reset" writes it from
+ # outside. Load it after the modem read so a reset in between survives.
+ tg_state_load
+ # Needs the loaded stage to know whether the fan should be forced.
+ tg_fan_check_owner
+ tg_trip_cycle "$cpu" "$modem"
+ FROM_STAGE=$((STAGE + 1))
+ tg_cycle "$cpu" "$modem" "$now" "$GEN"
+ tg_flush_outbox
+ sleep "$INTERVAL"
+ done
+}
+
+tg_status() {
+ tg_cfg
+ tg_fan_detect
+ tg_state_load
+ echo "version=$THERMAL_GUARD_VERSION"
+ echo "stage=$STAGE"
+ echo "cooled=$COOLED"
+ echo "gen=$GEN"
+ echo "last_cpu=$LAST_CPU"
+ echo "cpu_temp_path=$CPU_TEMP_PATH"
+ echo "last_modem=$LAST_MODEM"
+ echo "last_modem_src=$LAST_MODEM_SRC"
+ echo "last_ts=$LAST_TS"
+ echo "t0=$T0"; echo "t1=$T1"; echo "t2=$T2"
+ echo "pending=$PENDING"; echo "pending_n=$PENDING_N"
+ echo "fan_mode=$FAN_MODE"
+ echo "fan_state=$(tg_fan_state)"
+ echo "fan_max=$(tg_fan_max)"
+ echo "outbox_lines=$([ -f "$OUTBOX" ] && wc -l < "$OUTBOX" || echo 0)"
+ echo "alt_uplink=$(tg_alt_uplink && echo 1 || echo 0)"
+}
+
+tg_reset() {
+ local hist gen
+ tg_cfg
+ tg_fan_detect
+ tg_state_load
+ tg_log "reset requested (stage $STAGE)" notice
+
+ [ "$STAGE" -ge 1 ] && tg_undo_action "$STAGE1_ACTION"
+ [ "$STAGE" -ge 2 ] && tg_undo_action "$STAGE2_ACTION"
+ # Unconditional, not "if we think we took the fan". Reset means hand it
+ # back, and the one case where the stage says idle but the fan is still
+ # forced is exactly the one an operator cannot fix any other way.
+ tg_fan_release
+
+ hist=$(tail -n 10 "$EVLOG" 2>/dev/null)
+ gen=$((GEN + 1))
+ tg_state_clear
+ GEN=$gen
+ # Clearing means clearing, including across the next reboot.
+ rm -f "$PERSIST" 2>/dev/null
+ sync
+ tg_state_save
+ tg_status_write
+ tg_log "reset done" notice
+
+ tg_notify_configured || return 0
+ if tg_notify_send "$(printf '[%s] %s: reset done. Recent events:\n%s' \
+ "$(cat /proc/sys/kernel/hostname 2>/dev/null)" "$TAG" "$hist")"; then
+ : > "$OUTBOX" 2>/dev/null
+ else
+ mkdir -p "$STATE_DIR"
+ printf '%s reset done (history: logread -e %s)\n' "$(date '+%F %T')" "$TAG" >> "$OUTBOX"
+ fi
+ return 0
+}
+
+# The operator has seen the new device tree trips and decided, either to use
+# them as trip_active or to keep their own. Both make the set of this boot
+# the one trip_active was made for.
+tg_trips_keep() {
+ local dt
+ dt=$(cat "$STATE_DIR/trip_dt" 2>/dev/null)
+ case "$dt" in
+ ''|*[!0-9\ ]*)
+ echo "no device tree trips recorded for this boot, is trip management on?" >&2
+ return 1 ;;
+ esac
+ if ! tg_trip_base_save "$(tg_trip_deg "$dt" | tr '/' ' ')"; then
+ echo "could not write $PERSIST_DIR/trip_base" >&2
+ return 1
+ fi
+ tg_log "trip_base set to $(tg_trip_deg "$dt") C (trips-keep)" notice
+}
+
+# Two cycles with the same readings, prints what they would trigger.
+tg_test() {
+ local cpu modem now
+ [ -n "$(tg_int "${1:-}")" ] || { echo "usage: thermal-guard test " >&2; exit 2; }
+ tg_cfg
+ # The fan mode the daemon would use: auto and a pwm without polarity turn
+ # into none there. Detection only reads sysfs.
+ tg_fan_detect
+ tg_state_load
+ # Dry run: decide and report, touch no hardware and send nothing. tg_notify
+ # stops at DRY already; clearing the ways out is the second line of defence
+ # should a later path ever send around it.
+ DRY=1; NOTIFY_URL=""; HOOK_DIR=/nonexistent; MODEM_SRC="test"
+ cpu=$1; modem=$(tg_int "${2:-}"); now=$(date +%s)
+ # Every transition needs two readings in a row, so one cycle never shows a
+ # stage. Both at the same time: the hold time does not pass here.
+ FROM_STAGE=$((STAGE + 1)); tg_cycle "$cpu" "$modem" "$now"
+ FROM_STAGE=$((STAGE + 1)); tg_cycle "$cpu" "$modem" "$now"
+ echo "decision=$DECISION stage=$STAGE pending=$PENDING/$PENDING_N cooled=$COOLED"
+}
+
+# ---- self test of the decision logic ---------------------------------------
+tg_selftest() {
+ local fail=0 r dir
+ # Runs on live boards next to the daemon, so nothing of the installation may
+ # be touched: its own state, log and hook directories, and no syslog lines.
+ if ! dir=$(mktemp -d 2>/dev/null); then
+ echo "selftest: cannot create a scratch directory" >&2
+ return 1
+ fi
+ STATE_DIR="$dir/run"; STATE="$STATE_DIR/state"; OUTBOX="$STATE_DIR/outbox"
+ EVLOG="$STATE_DIR/log"; PERSIST_DIR="$dir/persist"; PERSIST="$PERSIST_DIR/state"
+ STATUS_FILE="$dir/status"; HOOK_DIR="$dir/hooks"
+ mkdir -p "$STATE_DIR"
+ # Never the real syslog; the priorities are checked from this file.
+ logger() { echo "$*" >> "$dir/syslog"; }
+ TG_LOGGER="logger"
+ tg_selftest_prio() { # $1 text, prints the priority it was logged with
+ sed -n "s/^-t $TAG -p \(daemon\.[a-z]*\) $1\$/\1/p" "$dir/syslog"
+ }
+ chk() {
+ if [ "$2" = "$3" ]; then
+ echo "ok $1"
+ else
+ echo "FAIL $1: expected [$3], got [$2]"
+ fail=1
+ fi
+ }
+ CPU_WARN=70; CPU_CRIT=80; CPU_EMERGENCY=90; MODEM_WARN=80; MODEM_CRIT=88
+ HOLD_MINUTES=3; HYSTERESIS=10
+
+ tg_state_clear; tg_decide 55 65 1000; chk "idle stays none" "$DECISION/$STAGE" "none/-1"
+ tg_state_clear; tg_decide 72 65 1000; chk "single hit is a glitch" "$DECISION/$STAGE" "none/-1"
+ tg_decide 60 65 1020; tg_decide 72 65 1040; chk "glitch resets the counter" "$DECISION/$PENDING_N" "none/1"
+ tg_decide 72 65 1060; chk "two hits reach stage 0" "$DECISION/$STAGE/$REF_CPU0" "stage:0/0/72"
+ tg_decide 72 65 1080; chk "stage 0 holds" "$DECISION/$STAGE" "none/0"
+ tg_decide 73 66 1200; tg_decide 73 66 1220; chk "before hold no stage 1" "$DECISION/$STAGE" "none/0"
+ tg_decide 73 66 1245; tg_decide 73 66 1265; chk "hold without drop reaches stage 1" "$DECISION/$STAGE/$REF_CPU1" "stage:1/1/73"
+ tg_decide 73 66 1300; tg_decide 73 66 1500; chk "stage 1 steady stays" "$DECISION/$STAGE" "none/1"
+ tg_decide 74 66 1520; tg_decide 74 66 1540; chk "stage 1 plus rise reaches stage 2" "$DECISION/$STAGE" "stage:2/2"
+
+ # A reading missing at stage entry leaves no reference, and no 0 in its
+ # place. The first reading after the gap becomes the reference.
+ tg_state_clear
+ tg_decide 85 '' 1000; tg_decide 85 '' 1020
+ chk "stage 1 entered without a modem reading" "$STAGE/${REF_MODEM1:-empty}" "1/empty"
+ tg_decide 85 70 1040; tg_decide 85 70 1060
+ chk "the modem comes back and becomes the reference" "$DECISION/$STAGE/$REF_MODEM1" "none/1/70"
+ tg_decide 85 70 1240; tg_decide 85 70 1260
+ chk "the same reading after the hold time does not escalate" "$DECISION/$STAGE" "none/1"
+ tg_decide 85 72 1280; tg_decide 85 72 1300
+ chk "a rise over it after the hold time reaches stage 2" "$DECISION/$STAGE" "stage:2/2"
+
+ tg_state_clear; tg_decide 65 66 2000; tg_decide 60 65 2020; tg_decide 62 66 2040
+ tg_decide 61 82 2060; tg_decide 61 82 2080; chk "modem alone reaches stage 0" "$DECISION/$STAGE" "stage:0/0"
+ tg_decide 61 90 2100; tg_decide 61 90 2120; chk "modem crit skips the hold" "$DECISION/$STAGE" "stage:1/1"
+
+ tg_state_clear; tg_decide 91 60 3000; tg_decide 91 60 3020; chk "cpu emergency jumps to stage 2" "$DECISION/$STAGE/$T0/$T1" "stage:2/2/3020/3020"
+
+ tg_state_clear; tg_decide 75 "" 4000; tg_decide 75 "" 4020; chk "cpu only without modem" "$DECISION/$STAGE" "stage:0/0"
+ tg_decide 55 "" 4040; chk "cooled, missing modem counts as cool" "$DECISION/$COOLED" "cooled/1"
+ tg_decide 55 "" 4060; chk "cooled is reported once" "$DECISION/$STAGE" "none/0"
+ tg_decide 72 60 4080; tg_decide 72 60 4100; chk "no rollback, no new transition" "$DECISION/$STAGE" "none/0"
+
+ tg_state_clear; tg_decide 79 87 5000; tg_decide 79 87 5020; chk "just below crit reaches stage 0" "$DECISION/$STAGE" "stage:0/0"
+ tg_decide 61 71 5040; chk "hysteresis not reached (71 > 70)" "$DECISION" "none"
+ tg_decide 60 70 5060; chk "hysteresis reached" "$DECISION" "cooled"
+
+ # Path guards. UCI must not be able to aim the daemon outside these subtrees.
+ chk_path() { # $1 guard, $2 path, $3 accept or reject
+ if "$1" "$2"; then
+ chk "$1 '$2'" accept "$3"
+ else
+ chk "$1 '$2'" reject "$3"
+ fi
+ }
+ chk_path tg_path_thermal /sys/class/thermal/thermal_zone0/temp accept
+ chk_path tg_path_thermal /etc/passwd reject
+ chk_path tg_path_thermal /sys/class/thermal/../../../etc/passwd reject
+ chk_path tg_path_thermal sys/class/thermal/thermal_zone0/temp reject
+ chk_path tg_path_thermal '' reject
+ chk_path tg_path_hwmon /sys/class/hwmon/hwmon0/pwm1 accept
+ chk_path tg_path_hwmon /etc/shadow reject
+ # x86 with coretemp reports the SoC through hwmon, not a thermal zone
+ chk_path tg_path_sensor /sys/class/thermal/thermal_zone0/temp accept
+ chk_path tg_path_sensor /sys/class/hwmon/hwmon0/temp1_input accept
+ chk_path tg_path_sensor /etc/passwd reject
+ chk_path tg_path_modem_file /tmp/modem.temp accept
+ chk_path tg_path_lock /var/lock/modem-at.lock accept
+ chk_path tg_path_lock /var/run/modem-at.lock accept
+ chk_path tg_path_lock /tmpfoo/evil reject
+ chk_path tg_path_dev /dev/ttyUSB2 accept
+ chk_path tg_path_dev /tmp/x reject
+
+ # Reset race. A reset landing mid cycle has to win: the daemon drops its
+ # decision instead of writing the stage it was still holding back.
+ chk_gate() { # $1 label, $2 GEN snapshot, $3 go or stop
+ if tg_state_unchanged "$2"; then
+ chk "$1" go "$3"
+ else
+ chk "$1" stop "$3"
+ fi
+ }
+ tg_state_clear; STAGE=1; GEN=5; tg_state_save
+ # Fan percentage has to read the same on a board whose scale descends.
+ FAN_MODE=pwm; FAN_VIEW=pwm
+ PWM_IDLE=0; PWM_FULL=200
+ chk "rising scale, half speed" "$(tg_fan_percent 100)" "50"
+ PWM_IDLE=200; PWM_FULL=0
+ chk "inverted scale, half speed" "$(tg_fan_percent 100)" "50"
+
+ # The Banana Pi R3 Mini runs this way round: 1 is full speed, 255 stops it.
+ PWM_IDLE=255; PWM_FULL=1
+ chk "inverted board at full speed" "$(tg_fan_percent 1)" "100"
+ chk "inverted board stopped" "$(tg_fan_percent 255)" "0"
+ chk "inverted board, measured value" "$(tg_fan_percent 89)" "65"
+
+ # A value outside the configured span must not leave 0..100.
+ chk "out of range clamps" "$(tg_fan_percent 300)" "0"
+ chk "a garbled reading yields nothing" "$(tg_fan_percent '9;reboot')" ""
+
+ # Polarity is never assumed. pwm without both values must not touch the fan,
+ # and auto must not pick pwm at all, since it cannot know the polarity.
+ printf '100\n' > "$STATE_DIR/pwm1"; printf '1\n' > "$STATE_DIR/pwm1_enable"
+ PWM_PATH="$STATE_DIR/pwm1"; PWM_ENABLE_PATH="$STATE_DIR/pwm1_enable"
+ PWM_FULL=1; PWM_IDLE=255; FAN_MODE=pwm; tg_fan_detect
+ chk "pwm with both values set" "$FAN_MODE" "pwm"
+ PWM_FULL=""; PWM_IDLE=255; FAN_MODE=pwm; tg_fan_detect
+ chk "pwm without pwm_full stays off the fan" "$FAN_MODE" "none"
+ PWM_FULL=1; PWM_IDLE=""; FAN_MODE=pwm; tg_fan_detect
+ chk "pwm without pwm_idle stays off the fan" "$FAN_MODE" "none"
+ PWM_FULL=1; PWM_IDLE=255; FAN_CDEV_TYPE=no-such-cooling-device
+ FAN_CDEV=""; FAN_MODE=auto; tg_fan_detect
+ chk "auto never resolves to pwm" "$FAN_MODE" "none"
+
+ # Reading is separate from writing. With the fan left to another controller
+ # the page still shows it, from the raw pwm output where the polarity is
+ # known: a cooling device's cur_state is derived from cooling-levels, and on
+ # a board whose levels descend it reads full speed as stopped.
+ FAN_CDEV_TYPE=no-such-cooling-device; FAN_CDEV=""
+ PWM_FULL=1; PWM_IDLE=255; FAN_MODE=none; tg_fan_detect
+ chk "none still writes nothing" "$FAN_MODE" "none"
+ chk "none reads the pwm output when the polarity is known" "$FAN_VIEW" "pwm"
+ chk "and shows it as a percentage" "$(tg_fan_percent 1)" "100"
+ PWM_FULL=""; PWM_IDLE=""; FAN_MODE=none; tg_fan_detect
+ chk "without a polarity the raw output is still read" "$FAN_VIEW" "pwm"
+ chk "but not turned into a percentage" "$(tg_fan_percent 1)" ""
+ # Only where the board has no fan chip: on one that does, it is found by
+ # its hwmon name and read, which is the point.
+ if [ -z "$(tg_fan_find_hwmon)" ]; then
+ PWM_PATH=""; PWM_ENABLE_PATH=""; FAN_MODE=none; tg_fan_detect
+ chk "nothing to read, nothing shown" "$FAN_VIEW" "none"
+ fi
+ PWM_PATH="$STATE_DIR/pwm1"; PWM_ENABLE_PATH="$STATE_DIR/pwm1_enable"
+ PWM_FULL=1; PWM_IDLE=255; FAN_MODE=pwm; tg_fan_detect
+ chk "a driven fan is read the way it is written" "$FAN_VIEW" "pwm"
+
+ # tg_cfg puts PWM_PATH back to the configured value every cycle, so the
+ # detection runs again each time, here against a planted fan chip.
+ saved_hwmon=$HWMON_ROOT; HWMON_ROOT="$dir/hwmon-fan"; h="$HWMON_ROOT/hwmon0"
+ mkdir -p "$h"; echo pwmfan > "$h/name"; echo 1 > "$h/pwm1"; echo 1 > "$h/pwm1_enable"
+ DAEMON=1; : > "$EVLOG"
+ for c in 1 2; do
+ FAN_MODE=pwm; PWM_PATH=""; PWM_ENABLE_PATH=""; tg_fan_detect
+ done
+ chk "a detected pwm path is found and reported once across cycles" \
+ "$PWM_PATH/$(grep -c "fan pwm output detected at '$h/pwm1'" "$EVLOG")" "$h/pwm1/1"
+ : > "$EVLOG"
+ for c in 1 2; do
+ FAN_MODE=pwm; PWM_PATH="$HWMON_ROOT/hwmon9/pwm1"; PWM_ENABLE_PATH=""; tg_fan_detect
+ done
+ chk "a replaced pwm_path is replaced and reported once across cycles" \
+ "$PWM_PATH/$(grep -c "fan pwm path '$HWMON_ROOT/hwmon9/pwm1' unusable" "$EVLOG")" "$h/pwm1/1"
+ DAEMON=0; PWM_PATH_TOLD=""; HWMON_ROOT=$saved_hwmon
+
+ FAN_CDEV_TYPE=pwm-fan; PWM_PATH=""; PWM_ENABLE_PATH=""
+ FAN_MODE=none; PWM_IDLE=""; PWM_FULL=""
+
+ # With the fan left to another controller, stage 1 of 3 is a warning. Not
+ # touching the fan is the configuration, not a failure to report.
+ : > "$EVLOG"
+ tg_reapply 0
+ chk "stage 0 with fan_mode none logs no fan failure" \
+ "$(grep -c 'could not force the fan' "$EVLOG")" "0"
+
+ # Hooks. The whole point is that only a file on disk can carry code, so an
+ # action asking for one that is missing or not executable must do nothing
+ # and report failure rather than fall back to something.
+ HOOK_DIR="$STATE_DIR/hooks"
+ mkdir -p "$HOOK_DIR"
+ printf '#!/bin/sh\necho ran > "%s"\n' "$STATE_DIR/hook-ran" > "$HOOK_DIR/stage1"
+
+ rm -f "$STATE_DIR/hook-ran"
+ chmod 0644 "$HOOK_DIR/stage1"
+ # Skipped where the filesystem has no execute bit to clear, which is the
+ # case when the tests run from a Windows checkout.
+ if [ -x "$HOOK_DIR/stage1" ]; then
+ echo "skip a hook without the execute bit (filesystem has no such bit)"
+ else
+ tg_run_hook stage1 >/dev/null 2>&1 && r=ran || r=refused
+ chk "a hook without the execute bit is refused" "$r" "refused"
+ chk "and it really did not run" "$(cat "$STATE_DIR/hook-ran" 2>/dev/null)" ""
+ fi
+
+ chmod 0755 "$HOOK_DIR/stage1"
+ tg_run_hook stage1 >/dev/null 2>&1 && r=ran || r=refused
+ chk "an executable hook runs" "$r" "ran"
+ chk "and it did what it says" "$(cat "$STATE_DIR/hook-ran" 2>/dev/null)" "ran"
+
+ tg_run_hook nosuchhook >/dev/null 2>&1 && r=ran || r=refused
+ chk "a missing hook is refused" "$r" "refused"
+
+ # A hook that ignores TERM as well, so the kill after the grace time counts too
+ printf '#!/bin/sh\ntrap "" TERM\nwhile :; do sleep 1; done\n' > "$HOOK_DIR/stage2"
+ chmod 0755 "$HOOK_DIR/stage2"
+ saved_hook=$HOOK_TIMEOUT; HOOK_TIMEOUT=1
+ t0=$(date +%s)
+ tg_run_hook stage2 >/dev/null 2>&1 && r=ran || r=failed
+ t1=$(date +%s)
+ [ $((t1 - t0)) -le $((HOOK_TIMEOUT + 6)) ] && r="$r/in time" || r="$r/took $((t1 - t0)) s"
+ chk "a hook that hangs is cut off" "$r" "failed/in time"
+ HOOK_TIMEOUT=$saved_hook
+
+ # "command" without a hook must not silently look like success.
+ tg_run_action command nosuchhook >/dev/null 2>&1 && r=ok || r=failed
+ chk "an action with no hook reports failure" "$r" "failed"
+ HOOK_DIR=/nonexistent
+
+ # Notification address. Anything that ends a quoted value in a curl config
+ # file, or starts a new line in one, must never reach it: curl config knows
+ # directives that write files.
+ chk_http() { # $1 label, $2 value, $3 accept or reject
+ if tg_http_safe "$2"; then
+ chk "$1" accept "$3"
+ else
+ chk "$1" reject "$3"
+ fi
+ }
+ chk_http "a plain url" 'https://ntfy.sh/my-topic' accept
+ chk_http "a url with a query" 'https://host/x?auth=abc123%3D' accept
+ chk_http "a bearer header" 'Authorization: Bearer abc.def-ghi_jkl' accept
+ chk_http "a url with a quote" 'https://x/" output = /etc/rc.local' reject
+ # shellcheck disable=SC1003 # a literal backslash is the point of the case
+ chk_http "a url with a backslash" 'https://x/\' reject
+ chk_http "a url with a newline" "$(printf 'https://x/\noutput = /etc/rc.local')" reject
+ chk_http "a url with a tab" "$(printf 'https://x/\ty')" reject
+ chk_http "an empty value" '' reject
+
+ chk "GEN survives save and load" "$(tg_state_gen)" "5"
+ chk_gate "untouched state continues the cycle" 5 go
+
+ # a reset rewrites the file while we still hold stage 1 in memory
+ tg_state_clear; GEN=6; tg_state_save
+ STAGE=1; GEN=5
+ chk_gate "bumped GEN aborts the cycle" 5 stop
+ chk "aborted cycle adopts the reset state" "$STAGE/$GEN" "-1/6"
+
+ # The saved fan state is what the release writes back, so it has to survive
+ # the round trip through the state file like everything else.
+ tg_state_clear; FAN_SAVED_ENABLE=2; FAN_SAVED_PWM=120
+ FAN_SAVED_POLICY=step_wise; tg_state_save
+ tg_state_clear
+ chk "fan state is empty after a clear" "$FAN_SAVED_ENABLE/$FAN_SAVED_PWM" "/"
+ tg_state_load
+ chk "saved fan state survives a reload" "$FAN_SAVED_ENABLE/$FAN_SAVED_PWM" "2/120"
+ chk "saved governor survives a reload" "$FAN_SAVED_POLICY" "step_wise"
+
+ # The policy is written back into sysfs, so only real governor names load.
+ tg_state_clear; FAN_SAVED_POLICY='user_space'; tg_state_save
+ tg_state_clear; tg_state_load
+ chk "another program's user_space is kept" "$FAN_SAVED_POLICY" "user_space"
+ tg_state_clear; FAN_SAVED_POLICY='step_wise; rm -rf /'; tg_state_save
+ tg_state_clear; tg_state_load
+ chk "a junk governor is dropped" "$FAN_SAVED_POLICY" ""
+
+ # Persisting to flash. sync is counted instead of run, the function is new
+ # here, so unset -f afterwards leaves nothing behind.
+ sync() { echo x >> "$dir/sync.calls"; }
+ sync_calls() { [ -f "$dir/sync.calls" ] && wc -l < "$dir/sync.calls" | tr -d ' ' || echo 0; }
+ rm -f "$dir/sync.calls"; tg_state_clear; STAGE=1
+ tg_state_persist && r=ok || r=failed
+ chk "persisting a stage flushes it to flash" "$r/$(sync_calls)/$(sed -n 's/^STAGE=//p' "$PERSIST")" "ok/1/1"
+ rm -f "$dir/sync.calls"; tg_state_clear
+ tg_cycle 50 40 6000 >/dev/null 2>&1
+ chk "a cycle without a stage change saves the state but does not flush" \
+ "$(sed -n 's/^LAST_TS=//p' "$STATE")/$(sync_calls)" "6000/0"
+ saved_pdir=$PERSIST_DIR; saved_persist=$PERSIST
+ : > "$dir/not-a-dir"; PERSIST_DIR="$dir/not-a-dir"; PERSIST="$PERSIST_DIR/state"
+ rm -f "$dir/sync.calls"; : > "$EVLOG"; STAGE=1
+ tg_state_persist && r=ok || r=failed
+ chk "a stage that cannot be persisted is reported and not flushed" \
+ "$r/$(grep -c 'could not persist stage 1' "$EVLOG")/$(sync_calls)" "failed/1/0"
+ PERSIST_DIR=$saved_pdir; PERSIST=$saved_persist
+ unset -f sync sync_calls
+ tg_state_clear
+
+ # A restore applies first and sends one message that names what failed.
+ saved_s1=$STAGE1_ACTION; saved_fan=$FAN_MODE; FAN_MODE=none
+ tg_state_clear; STAGE=1; tg_state_persist; rm -f "$STATE"; : > "$EVLOG"
+ STAGE1_ACTION=none; tg_state_restore
+ chk "a restore is logged before it applies" "$(grep -c 'restoring stage 1 after a reboot' "$EVLOG")" "1"
+ chk "and reports a clean restore in one line" \
+ "$(grep -c 'restored stage 1 after a reboot and reapplied its actions' "$EVLOG")" "1"
+ tg_state_clear; STAGE=1; tg_state_persist; rm -f "$STATE"; : > "$EVLOG"; : > "$dir/syslog"
+ STAGE1_ACTION="command"; tg_state_restore
+ chk "a failed action after a reboot is crit" "$(tg_selftest_prio \
+ "stage 1 action 'command' failed after the reboot")" "daemon.crit"
+ chk "and named in the one restore message" "$(grep -c \
+ "restored stage 1 after a reboot, but reapplying failed for: stage 1 action 'command'" \
+ "$EVLOG")" "1"
+ STAGE1_ACTION=$saved_s1; FAN_MODE=$saved_fan
+ tg_state_clear; rm -f "$PERSIST"
+
+ # Trip management. Only ever against a planted tree: this runs on live
+ # boards, and the real trips belong to the daemon next to it.
+ tg_selftest_tree() { # $1 root, $2 form: bpi, nofan or ro
+ local z="$1/thermal_zone0" c="$1/cooling_device0" i t
+ rm -rf "$1"; mkdir -p "$z" "$c"
+ echo pwm-fan > "$c/type"; echo 3 > "$c/max_state"; echo 0 > "$c/cur_state"
+ echo step_wise > "$z/policy"; echo 45000 > "$z/temp"
+ i=0
+ for t in critical:125000 hot:120000 active:50000 active:60000 active:70000; do
+ echo "${t%%:*}" > "$z/trip_point_${i}_type"
+ echo "${t#*:}" > "$z/trip_point_${i}_temp"
+ i=$((i + 1))
+ done
+ [ "$2" = nofan ] && return 0
+ for i in 0 1 2; do
+ ln -s ../cooling_device0 "$z/cdev$i"
+ echo $((i + 2)) > "$z/cdev${i}_trip_point"
+ done
+ [ "$2" = ro ] && chmod 0444 "$z/trip_point_3_temp"
+ return 0
+ }
+ tg_selftest_trips() { cat "$THERMAL_ROOT"/thermal_zone0/trip_point_*_temp | tr '\n' ' '; }
+ tg_selftest_trip_reset() {
+ tg_selftest_tree "$THERMAL_ROOT" "${1:-bpi}"
+ rm -f "$STATE_DIR/trip_dt"
+ TRIP_DT=""; TRIP_WIN=""; TRIP_DELTA=0; TRIP_WAS_ACTIVE=0
+ TRIP_TOLD_FIND=""; TRIP_TOLD_VALID=""; TRIP_TOLD_DT=""; TRIP_TOLD_WRITE=""
+ TRIP_BOOST=modem; TRIP_ACTIVE=""; TRIP_BOOST_OFFSET=15
+ FAN_ZONE=""; FAN_CDEV_TYPE=pwm-fan; STAGE=-1
+ mkdir -p "$PERSIST_DIR"; echo "50 60 70" > "$PERSIST_DIR/trip_base"
+ }
+ saved_root=$THERMAL_ROOT; saved_hwmon=$HWMON_ROOT
+ THERMAL_ROOT="$dir/thermal"; HWMON_ROOT="$dir/hwmon"
+ case "$THERMAL_ROOT" in "$dir"/*) r=planted ;; *) r=live ;; esac
+ chk "trip tests run against a planted tree" "$r" "planted"
+ # The hwmon guard follows the moved root and still keeps everything else out.
+ chk_path tg_path_hwmon "$HWMON_ROOT/hwmon0/pwm1" accept
+ chk_path tg_path_hwmon /sys/devices/platform/pwm-fan/hwmon/hwmon0/pwm1 accept
+ chk_path tg_path_hwmon /etc/shadow reject
+ chk_path tg_path_hwmon "$HWMON_ROOT/../etc/passwd" reject
+ chk_path tg_path_hwmon /sys/class/hwmon/hwmon0/pwm1 reject
+
+ # cooling-levels as the R3 Mini carries them: 140 100 50 0, big-endian cells
+ saved_dt=$DT_ROOT; DT_ROOT="$dir/dt"
+ mkdir -p "$DT_ROOT/pwm-fan"
+ printf 'pwm-fan\000' > "$DT_ROOT/pwm-fan/compatible"
+ printf '\000\000\000\214\000\000\000\144\000\000\000\062\000\000\000\000' > "$DT_ROOT/pwm-fan/cooling-levels"
+ if command -v hexdump >/dev/null 2>&1; then
+ chk "cooling-levels are read from the device tree" "$(tg_fan_levels)" "140 100 50 0"
+ else
+ echo "skip cooling-levels (no hexdump on this host; BusyBox on the target has it)"
+ fi
+ DT_ROOT=$saved_dt
+
+ # mt_wifi temperatures through iwpriv, against a planted net tree and a
+ # stand-in iwpriv, so the real radios are never asked
+ saved_net=$NET_ROOT; saved_path=$PATH; NET_ROOT="$dir/net"
+ mkdir -p "$NET_ROOT/ra0" "$NET_ROOT/rax0" "$dir/bin"
+ # shellcheck disable=SC2016 # $1 belongs to the stand-in script, not to us
+ printf '#!/bin/sh\ncase "$1" in\n\tra0) echo "CurrentTemperature = 53" ;;\n\trax0) echo "CurrentTemperature = 48;reboot" ;;\nesac\n' > "$dir/bin/iwpriv"
+ chmod 0755 "$dir/bin/iwpriv"
+ PATH="$dir/bin:$PATH"
+ echo 0x1303 > "$NET_ROOT/ra0/flags"; echo 0x1002 > "$NET_ROOT/rax0/flags"
+ chk "mt_wifi: an active radio is read" "$(tg_read_mtwifi)" "mtwifi_phy0=53"
+ echo 0x1303 > "$NET_ROOT/rax0/flags"
+ chk "mt_wifi: a garbled answer is dropped" "$(tg_read_mtwifi)" "mtwifi_phy0=53"
+ echo 0x1002 > "$NET_ROOT/ra0/flags"
+ chk "mt_wifi: a radio that is down is not asked" "$(tg_read_mtwifi)" ""
+ NET_ROOT=$saved_net; PATH=$saved_path
+
+ # Without timeout a hung modem stops the loop, so its absence is a failure
+ tg_have timeout && r=present || r=missing
+ chk "timeout is installed" "$r" "present"
+
+ MODEM_VAL=47; MODEM_SRC="at"; tg_modem_publish
+ r=$(cat "$STATE_DIR/modem-temp" 2>/dev/null)
+ case "$r" in
+ "47 "[0-9]*" at") chk "modem temperature is published" ok ok ;;
+ *) chk "modem temperature is published" "$r" "47