🤞 Sync 2026-09-27 22:31:51

This commit is contained in:
github-actions[bot] committed 2026-09-27 22:31:51 +08:00
1 parent 3355bff186
commit 9a761ba34e
18 files changed
+5738

No files matched your search

+26
View File
@@ -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
@@ -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])
]);
}));
}
});
@@ -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();
}
});
@@ -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
});
@@ -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 %%)"
@@ -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 ""
@@ -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 }
}
}
}
@@ -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" ]
}
}
}
+68
View File
@@ -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))
@@ -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'
+38
View File
@@ -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
}
@@ -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
@@ -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
@@ -0,0 +1 @@
/etc/thermal-guard/
+67
View File
@@ -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
# <Plugin exec>
# Exec "nobody:nogroup" "/usr/libexec/thermal-guard-collectd"
# </Plugin>
#
# 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
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,969 @@
# SPDX-License-Identifier: GPL-2.0-only
# Hardware backends for thermal-guard: fan control, modem temperature, stage actions.
# Sourced by /usr/sbin/thermal-guard, never run on its own.
# shellcheck shell=busybox
# shellcheck disable=SC2034 # MODEM_VAL and MODEM_SRC are read by the caller
# ---- helpers ---------------------------------------------------------------
# Keep integers only so a garbled sysfs read or AT reply cannot reach the logic.
tg_int() {
case "$1" in
''|*[!0-9-]*|-|?*-*) echo "" ;;
*) echo "$1" ;;
esac
}
# Whether a program or function is there. The one place that asks, so the
# selftest can take a tool away without touching the host.
tg_have() {
command -v "$1" >/dev/null 2>&1
}
# Absolute path, no .. and no wildcards. Stops UCI from pointing us at random files.
tg_path_ok() {
case "$1" in
''|*..*|*\**|*\?*|*\\*|*[!\ -~]*) return 1 ;;
/*) return 0 ;;
*) return 1 ;;
esac
}
# $1 path, $2 prefix. The prefix ends in a slash, so a sibling like /tmpfoo
# cannot pass as /tmp/.
tg_path_under() {
tg_path_ok "$1" || return 1
case "$1" in
"$2"*) return 0 ;;
*) return 1 ;;
esac
}
tg_path_thermal() { tg_path_under "$1" "$THERMAL_ROOT/"; }
tg_path_hwmon() {
tg_path_under "$1" "$HWMON_ROOT/" || tg_path_under "$1" /sys/devices/
}
# Boards differ on where the SoC sensor lives: a thermal zone on most targets,
# a hwmon node on x86 with coretemp. Both have to stay reachable.
tg_path_sensor() {
tg_path_thermal "$1" || tg_path_hwmon "$1"
}
tg_path_modem_file() {
tg_path_under "$1" /var/run/ || tg_path_under "$1" /tmp/ || tg_path_under "$1" /sys/
}
tg_path_lock() {
tg_path_under "$1" /var/lock/ || tg_path_under "$1" /var/run/ ||
tg_path_under "$1" /tmp/
}
tg_path_dev() { tg_path_under "$1" /dev/; }
# ---- notification delivery -------------------------------------------------
#
# A message goes out over HTTP, which covers ntfy, Gotify, Matrix, Slack and the
# rest, and needs no shell command from the configuration to do it. curl is
# preferred where present because it can send a header; uclient-fetch ships in
# the OpenWrt base but has no way to set one.
tg_fetcher() {
tg_have curl && { echo curl; return; }
tg_have uclient-fetch && { echo uclient-fetch; return; }
tg_have wget && { echo wget; return; }
}
# Curl reads its config file as directives, and one of them writes a file. A
# quote would end the value and turn the rest into more of them, a newline
# would start one outright. Neither belongs in a url or a header anyway, so
# reject both along with every other non-printable character rather than
# escaping and hoping the escaping is right.
tg_http_safe() {
# The '\' alternative is a literal backslash and is meant to be one.
# shellcheck disable=SC1003
case "$1" in
''|*'"'*|*'\'*|*[!\ -~]*) return 1 ;;
*) return 0 ;;
esac
}
# $1 message. The url and any header go through a config file rather than the
# command line: they routinely carry an access token, and /proc/*/cmdline is
# readable by every local process.
tg_notify_http() {
local tool cfg rc
[ -n "$NOTIFY_URL" ] || return 1
case "$NOTIFY_URL" in
http://?*|https://?*) ;;
*) tg_log "notify_url is not an http or https address, not sending" warning; return 1 ;;
esac
if ! tg_http_safe "$NOTIFY_URL"; then
tg_log "notify_url contains characters that are not allowed in one, not sending" warning
return 1
fi
if [ -n "$NOTIFY_HEADER" ] && ! tg_http_safe "$NOTIFY_HEADER"; then
tg_log "notify_header contains characters that are not allowed in one, not sending" warning
return 1
fi
tool=$(tg_fetcher)
[ -n "$tool" ] || {
tg_log "no curl or wget available, cannot send notifications" warning
return 1
}
if [ "$tool" = curl ]; then
NOTIFY_HEADER_TOLD=""
mkdir -p "$STATE_DIR"
cfg="$STATE_DIR/notify.conf"
: > "$cfg"
chmod 600 "$cfg" 2>/dev/null
printf 'url = "%s"\n' "$NOTIFY_URL" >> "$cfg"
[ -n "$NOTIFY_HEADER" ] && printf 'header = "%s"\n' "$NOTIFY_HEADER" >> "$cfg"
printf '%s\n' "$1" | curl -fsS -m 15 -K "$cfg" --data-binary @- >/dev/null 2>&1
rc=$?
rm -f "$cfg"
return $rc
fi
# Said once per fetcher, not per message and not per retry of the outbox.
if [ -z "$NOTIFY_HEADER" ]; then
NOTIFY_HEADER_TOLD=""
elif [ "$NOTIFY_HEADER_TOLD" != "$tool" ]; then
tg_log "$tool cannot send a header, install curl for authenticated webhooks" warning
NOTIFY_HEADER_TOLD=$tool
fi
# -T bounds the network wait only, the outer limit also covers a hung DNS lookup.
timeout "$HTTP_TIMEOUT" "$tool" -q -T 15 -O /dev/null --post-data="$1" "$NOTIFY_URL" \
>/dev/null 2>&1
}
# The escape hatch for anything HTTP cannot express, a local mailer for example.
# It is a file an administrator puts there over ssh, not a string from the
# configuration, so editing the config cannot introduce code that runs as root.
tg_notify_hook() {
local hook="$HOOK_DIR/notify"
[ -x "$hook" ] || return 1
printf '%s\n' "$1" | timeout -k 5 "$HOOK_TIMEOUT" "$hook" >/dev/null 2>&1
}
# Run an AT command through a lock. Several daemons and LuCI apps share one port
# and the modem answers a collision with nothing at all. Both the wait for the
# lock and the query are bounded: a modem that hangs, or a program that sits on
# the lock, must not stop the whole loop. BusyBox flock has no -w, so the outer
# timeout covers the wait and the inner one the query.
# Without flock the query still runs, the modem reading matters more than the
# lock. But another program on the port can garble it now, so say so once.
tg_at_unlocked() {
[ "$FLOCK_TOLD" = 1 ] && return 0
tg_cfg_log "no flock command, AT queries run without the shared lock"
[ "$DAEMON" = 1 ] && FLOCK_TOLD=1
return 0
}
tg_at() {
local port="$1" cmd="$2" lock=$AT_LOCK
# Checked here as well as at load: the value goes to flock, which creates
# the file it names.
tg_path_lock "$lock" || lock=/var/lock/modem-at.lock
[ -c "$port" ] || return 1
tg_have sms_tool || return 1
if ! tg_have timeout; then
if [ "$AT_NO_TIMEOUT_TOLD" = 0 ]; then
tg_cfg_log "no timeout command, AT queries run without a time limit"
[ "$DAEMON" = 1 ] && AT_NO_TIMEOUT_TOLD=1
fi
if tg_have flock; then
flock "$lock" sms_tool -d "$port" at "$cmd" 2>/dev/null
else
tg_at_unlocked
sms_tool -d "$port" at "$cmd" 2>/dev/null
fi
return
fi
if tg_have flock; then
timeout "$AT_WAIT" flock "$lock" timeout "$AT_TIMEOUT" sms_tool -d "$port" at "$cmd" 2>/dev/null
else
tg_at_unlocked
timeout "$AT_TIMEOUT" sms_tool -d "$port" at "$cmd" 2>/dev/null
fi
}
# Other programs read the modem temperature here instead of asking the modem
# themselves. Written after a good reading only, so its age shows an outage.
tg_modem_publish() {
local f="$STATE_DIR/modem-temp"
[ -n "$MODEM_VAL" ] || return 0
mkdir -p "$STATE_DIR"
printf '%s %s %s\n' "$MODEM_VAL" "$(date +%s)" "$MODEM_SRC" > "$f.tmp" || return 0
chmod 644 "$f.tmp" 2>/dev/null
mv "$f.tmp" "$f"
}
# ---- fan -------------------------------------------------------------------
tg_fan_detect() {
tg_fan_pick
tg_fan_pick_view
}
# Pick a backend. "auto" only ever means the cooling device, whose polarity the
# device tree carries; a raw pwm output needs pwm_full and pwm_idle from the
# operator, and without both this stays off the fan.
tg_fan_pick() {
case "$FAN_MODE" in
cooling_device|pwm|none) ;;
*)
if [ -n "$(tg_fan_find_cdev)" ]; then
FAN_MODE=cooling_device
else
FAN_MODE=none
fi
;;
esac
if [ "$FAN_MODE" = pwm ]; then
if ! tg_fan_polarity_known; then
FAN_MODE=none
[ "$FAN_POLARITY_TOLD" = 1 ] && return 0
tg_cfg_log "fan_mode pwm needs both pwm_full and pwm_idle, leaving the fan alone"
[ "$DAEMON" = 1 ] && FAN_POLARITY_TOLD=1
return 0
fi
tg_fan_resolve_pwm
fi
if [ "$FAN_MODE" = cooling_device ]; then
[ -n "$FAN_CDEV" ] || FAN_CDEV=$(tg_fan_find_cdev)
[ -n "$FAN_CDEV" ] || { FAN_MODE=none; return; }
if ! tg_path_thermal "$FAN_CDEV"; then
tg_cfg_log "rejecting fan cooling device path: $FAN_CDEV"
FAN_CDEV=""; FAN_MODE=none; return
fi
# A zone is optional. It is only needed to take the cooling device away
# from a kernel governor, and plenty of boards ship a pwm-fan cooling
# device that no zone binds. There the state can be written straight
# out, and there is nothing to hand back either.
[ -n "$FAN_ZONE" ] || FAN_ZONE=$(tg_fan_find_zone "$FAN_CDEV")
if [ -n "$FAN_ZONE" ] && ! tg_path_thermal "$FAN_ZONE"; then
tg_cfg_log "rejecting fan thermal zone path: $FAN_ZONE"
FAN_ZONE=""
fi
fi
}
# What the fan is read from, which FAN_MODE does not decide when it is none: the
# fan then belongs to another program, and what it is doing is still the most
# useful thing on the page. The raw pwm output comes first where the polarity is
# known, because a cooling device's cur_state is derived from cooling-levels
# and reads full speed as stopped on a board whose levels descend.
tg_fan_pick_view() {
local cdev
if [ "$FAN_MODE" != none ]; then
FAN_VIEW=$FAN_MODE
return 0
fi
FAN_VIEW=none
tg_fan_resolve_pwm
if [ -n "$PWM_PATH" ] && [ -r "$PWM_PATH" ] && tg_fan_polarity_known; then
FAN_VIEW=pwm
return 0
fi
cdev=$(tg_fan_find_cdev)
if [ -n "$cdev" ] && tg_path_thermal "$cdev"; then
FAN_CDEV=$cdev; FAN_VIEW=cooling_device
return 0
fi
[ -n "$PWM_PATH" ] && [ -r "$PWM_PATH" ] && FAN_VIEW=pwm
return 0
}
tg_fan_polarity_known() {
[ -n "$(tg_int "$PWM_FULL")" ] && [ -n "$(tg_int "$PWM_IDLE")" ]
}
# A cooling device whose max_state is 0 can cool by exactly nothing. Selecting
# it would have tg_fan_full write that 0 and report success, so stage 1 would
# announce a fan at full speed and change nothing at all.
tg_fan_find_cdev() {
local d max
for d in "$THERMAL_ROOT"/cooling_device*; do
[ -r "$d/type" ] || continue
case "$(cat "$d/type" 2>/dev/null)" in
"$FAN_CDEV_TYPE") ;;
*) continue ;;
esac
max=$(tg_int "$(cat "$d/max_state" 2>/dev/null)")
[ -n "$max" ] && [ "$max" -gt 0 ] || continue
echo "$d"
return
done
}
# Fill in the pwm paths from the detected chip. A configured path that works is
# left alone; one that stopped working is replaced, because a renumbered hwmon
# is the likely reason and running without a fan is not an option here.
tg_fan_resolve_pwm() {
local h
[ -n "$PWM_PATH" ] && [ -w "$PWM_PATH" ] && [ -n "$PWM_ENABLE_PATH" ] && return 0
h=$(tg_fan_find_hwmon)
[ -n "$h" ] || return 0
tg_path_hwmon "$h" || return 0
if [ -z "$PWM_PATH" ] || [ ! -w "$PWM_PATH" ]; then
[ -w "$h/pwm1" ] && tg_fan_set_pwm_path "$h/pwm1"
fi
[ -n "$PWM_ENABLE_PATH" ] || [ ! -e "$h/pwm1_enable" ] || PWM_ENABLE_PATH="$h/pwm1_enable"
return 0
}
tg_fan_set_pwm_path() {
local old=$PWM_PATH
[ "$1" = "$old" ] && return 0
PWM_PATH="$1"
[ "$PWM_PATH_TOLD" = "$old>$1" ] && return 0
if [ -z "$old" ]; then
tg_cfg_log "fan pwm output detected at '$1'" info
else
tg_cfg_log "fan pwm path '$old' unusable, using detected '$1'"
fi
[ "$DAEMON" = 1 ] && PWM_PATH_TOLD="$old>$1"
return 0
}
# hwmon numbers are assigned in driver probe order, so hwmon2 today can be
# hwmon1 after adding an NVMe or a kernel update. Find the fan chip by its name
# instead: writing a duty cycle to whatever landed on that number is exactly the
# silent failure this daemon exists to prevent.
tg_fan_find_hwmon() {
local h
for h in "$HWMON_ROOT"/hwmon*; do
[ -r "$h/name" ] || continue
case "$(cat "$h/name" 2>/dev/null)" in
pwmfan|pwm-fan|pwm_fan) echo "$h"; return ;;
esac
done
}
# The zone that drives this cooling device, so we know whose governor to switch.
tg_fan_find_zone() {
local cdev="$1" z c
for z in "$THERMAL_ROOT"/thermal_zone*; do
for c in "$z"/cdev*; do
[ -e "$c" ] || continue
[ "$(readlink -f "$c")" = "$(readlink -f "$cdev")" ] && { echo "$z"; return; }
done
done
}
tg_fan_state() {
case "$FAN_VIEW" in
cooling_device)
tg_path_thermal "$FAN_CDEV" || return
cat "$FAN_CDEV/cur_state" 2>/dev/null
;;
pwm)
tg_path_hwmon "$PWM_PATH" || return
cat "$PWM_PATH" 2>/dev/null
;;
esac
}
tg_fan_max() {
case "$FAN_VIEW" in
cooling_device)
tg_path_thermal "$FAN_CDEV" || return
cat "$FAN_CDEV/max_state" 2>/dev/null
;;
pwm) tg_fan_polarity_known && echo "$PWM_FULL" ;;
esac
}
# Fan speed as 0..100 regardless of how the raw scale runs. For pwm that is the
# span between PWM_IDLE and PWM_FULL, which may descend on an inverted board;
# for a cooling device it is cur_state against max_state, which always ascends.
# Without this the status page reports a fan at full speed as nearly stopped.
tg_fan_percent() { # $1 optional raw value, otherwise the live reading
local cur max span pct
cur=${1:-$(tg_fan_state)}
cur=$(tg_int "$cur")
[ -n "$cur" ] || return 0
case "$FAN_VIEW" in
pwm)
tg_fan_polarity_known || return 0
span=$((PWM_FULL - PWM_IDLE))
[ "$span" -ne 0 ] || return 0
pct=$(( (cur - PWM_IDLE) * 100 / span ))
;;
cooling_device)
max=$(tg_int "$(tg_fan_max)")
[ -n "$max" ] && [ "$max" -gt 0 ] || return 0
pct=$((cur * 100 / max))
;;
*) return 0 ;;
esac
[ "$pct" -lt 0 ] && pct=0
[ "$pct" -gt 100 ] && pct=100
echo "$pct"
}
# We forced the fan when the stage was entered. If the value is gone the chip
# belongs to someone else, a second fan daemon or a governor we did not take
# over. Re-assert, because too much cooling is the safe direction, but say once
# what is happening instead of quietly fighting every cycle.
FAN_FOREIGN=0
tg_fan_check_owner() {
local want have
if [ "$STAGE" -lt 0 ]; then
# Idle, so nothing of ours is in there to compare against. For a
# cooling device the zone still tells: user_space means somebody took
# it off its governor, and it was not this daemon. Worth knowing
# before an incident rather than during one.
FAN_FOREIGN=0
[ "$FAN_MODE" = cooling_device ] && [ -n "$FAN_ZONE" ] || return 0
tg_path_thermal "$FAN_ZONE" || return 0
[ "$(cat "$FAN_ZONE/policy" 2>/dev/null)" = user_space ] || return 0
FAN_FOREIGN=1
tg_cfg_log "thermal zone is on user_space while no stage is active, another program drives this fan"
return 0
fi
case "$FAN_MODE" in
pwm) want=$PWM_FULL ;;
cooling_device) want=$(tg_int "$(tg_fan_max)") ;;
*) FAN_FOREIGN=0; return 0 ;;
esac
have=$(tg_int "$(tg_fan_state)")
[ -n "$want" ] && [ -n "$have" ] || return 0
if [ "$have" = "$want" ]; then
FAN_FOREIGN=0
return 0
fi
[ "$FAN_FOREIGN" = 1 ] || tg_log "fan was set to $want but reads $have, another controller owns it. Set fan_mode=none to leave it alone." warning
FAN_FOREIGN=1
tg_fan_full || tg_log "could not reassert the fan (mode $FAN_MODE)" crit
}
tg_fan_rpm() {
local h
for h in "$HWMON_ROOT"/hwmon*/fan1_input; do
[ -r "$h" ] && { cat "$h"; return; }
done
}
# Force full speed. cooling_device takes the zone away from the kernel governor,
# pwm writes the duty cycle after taking manual control of the chip.
tg_fan_full() {
case "$FAN_MODE" in
cooling_device)
tg_path_thermal "$FAN_CDEV" || return 1
# Take the zone off its governor first, where there is one, and
# remember what it was on. Another program may already have taken
# it, and handing it back to the configured default rather than to
# what was actually there would cut that program's cooling short.
if [ -n "$FAN_ZONE" ]; then
tg_path_thermal "$FAN_ZONE" || return 1
[ -n "$FAN_SAVED_POLICY" ] ||
FAN_SAVED_POLICY=$(cat "$FAN_ZONE/policy" 2>/dev/null)
echo user_space > "$FAN_ZONE/policy" 2>/dev/null || return 1
fi
# max_state means maximum cooling whichever way the cooling-levels
# in the device tree run, so this mode needs no idea of polarity.
tg_fan_max > "$FAN_CDEV/cur_state" 2>/dev/null
;;
pwm)
# Remember what the chip was doing before we take it over. The
# release writes this back, which beats asking the admin for a
# shell command and survives a daemon restart via the state file.
if [ -n "$PWM_ENABLE_PATH" ] && [ -z "$FAN_SAVED_ENABLE" ]; then
FAN_SAVED_ENABLE=$(tg_int "$(cat "$PWM_ENABLE_PATH" 2>/dev/null)")
fi
if [ -n "$PWM_PATH" ] && [ -z "$FAN_SAVED_PWM" ]; then
FAN_SAVED_PWM=$(tg_int "$(cat "$PWM_PATH" 2>/dev/null)")
fi
if [ -n "$PWM_ENABLE_PATH" ]; then
tg_path_hwmon "$PWM_ENABLE_PATH" || return 1
echo 1 > "$PWM_ENABLE_PATH" 2>/dev/null
fi
if [ -n "$PWM_PATH" ]; then
tg_path_hwmon "$PWM_PATH" || return 1
echo "$PWM_FULL" > "$PWM_PATH" 2>/dev/null
fi
;;
none) return 1 ;;
esac
}
# Hand the fan back. Only called on an explicit reset or a clean stop, never on
# the escalation path, so a crash leaves the fan running.
# Hands the zone to governor $1 and reads the policy back. The kernel refuses
# a governor it was built without, which would leave the zone on user_space
# and the fan at full speed with nobody told. step_wise is in every kernel
# with thermal support. Without available_policies the name is written as is.
tg_fan_zone_policy() {
local want="$1" avail got
avail=$(cat "$FAN_ZONE/available_policies" 2>/dev/null)
if [ -n "$avail" ]; then
case " $avail " in
*" $want "*) ;;
*)
tg_log "governor '$want' is not available on this zone, handing it to step_wise" warning
want=step_wise ;;
esac
fi
# Braces, so a failed open is as quiet as a failed write.
{ echo "$want" > "$FAN_ZONE/policy"; } 2>/dev/null
got=$(cat "$FAN_ZONE/policy" 2>/dev/null)
[ "$got" = "$want" ] && return 0
tg_log "zone still reports policy '${got:-nothing}' after handing it back" crit
return 1
}
# Writes $2 to $1 and reads it back; $3 names the value in the log.
tg_fan_write_back() {
local got
{ echo "$2" > "$1"; } 2>/dev/null
got=$(tg_int "$(cat "$1" 2>/dev/null)")
[ "$got" = "$2" ] && return 0
tg_log "$3 reads '${got:-nothing}' after handing it back, wrote '$2'" crit
return 1
}
tg_fan_release() {
local mode=""
case "$FAN_MODE" in
cooling_device)
# Nothing to hand back where no zone drives the cooling device.
[ -n "$FAN_ZONE" ] || return 0
tg_path_thermal "$FAN_ZONE" || return 0
# Back to whatever held the zone before, which may be another
# program's user_space rather than a kernel governor. Only where
# nothing was recorded, because the state was lost, does the
# configured default get used as the safety net.
if [ -n "$FAN_SAVED_POLICY" ]; then
tg_fan_zone_policy "$FAN_SAVED_POLICY"
else
tg_log "no saved fan policy, handing the zone to $FAN_GOVERNOR"
tg_fan_zone_policy "$FAN_GOVERNOR"
fi
FAN_SAVED_POLICY=""
;;
pwm)
# Put back what we found. The fallback 2 means "automatic" on the
# usual pwm chips; 0 is never written, because on some of them that
# stops the fan, which is the opposite of what a release should do.
if [ -n "$PWM_ENABLE_PATH" ] && tg_path_hwmon "$PWM_ENABLE_PATH"; then
mode=$FAN_SAVED_ENABLE
if [ -z "$mode" ] || [ "$mode" = 0 ]; then
tg_log "no saved fan control mode, handing the fan to automatic"
mode=2
fi
tg_fan_write_back "$PWM_ENABLE_PATH" "$mode" "fan control mode"
fi
# The duty cycle only in manual mode, or where there is no mode to
# set. In any other mode the chip drives it: the value saved before
# the takeover means nothing, some drivers refuse it with EINVAL,
# and reading it back would report the chip's own value as failure.
case "$mode" in
''|1)
if [ -n "$FAN_SAVED_PWM" ] && [ -n "$PWM_PATH" ] && tg_path_hwmon "$PWM_PATH"; then
tg_fan_write_back "$PWM_PATH" "$FAN_SAVED_PWM" "fan pwm"
fi
;;
esac
FAN_SAVED_ENABLE=""; FAN_SAVED_PWM=""
;;
esac
return 0
}
# ---- temperatures ----------------------------------------------------------
# Where the SoC sensor lives differs per target: a thermal zone on mediatek and
# most ARM boards, a hwmon node on ramips (MT7628) and on x86 with coretemp.
# Called when the configured path does not read, so a board nobody configured
# still gets watched instead of running blind.
#
# Prefers a zone or chip whose name looks like the SoC, then falls back to the
# first thermal zone, which is the SoC on the boards that have only one.
tg_find_cpu_sensor() {
local f name
for f in "$THERMAL_ROOT"/thermal_zone*/temp; do
[ -r "$f" ] || continue
name=$(cat "${f%/*}/type" 2>/dev/null)
case "$name" in
*cpu*|*soc*|*tsens*|*pkg*) echo "$f"; return ;;
esac
done
for f in "$HWMON_ROOT"/hwmon*/temp1_input; do
[ -r "$f" ] || continue
name=$(cat "${f%/*}/name" 2>/dev/null)
case "$name" in
*cpu*|*soc*|*thermal*|coretemp|*7620*|*7628*) echo "$f"; return ;;
esac
done
for f in "$THERMAL_ROOT"/thermal_zone*/temp; do
[ -r "$f" ] && { echo "$f"; return; }
done
}
# True when the board reports no temperature at all. Distinguishes "this path is
# wrong" from "there is nothing to read here", which are different problems: the
# first is a typo, the second is a fact about the hardware. Several ramips
# boards, the GL-MT300N-V2 among them, expose neither a thermal zone nor a
# hwmon temperature input.
tg_has_any_sensor() {
local f
for f in "$THERMAL_ROOT"/thermal_zone*/temp "$HWMON_ROOT"/hwmon*/temp*_input; do
[ -r "$f" ] && return 0
done
return 1
}
# Sysfs reports millidegrees, some drivers degrees. Anything above 1000 is mC.
tg_read_cpu() {
local raw
tg_path_sensor "$CPU_TEMP_PATH" || { echo ""; return; }
raw=$(tg_int "$(cat "$CPU_TEMP_PATH" 2>/dev/null)")
[ -n "$raw" ] || { echo ""; return; }
[ "$raw" -gt 1000 ] && raw=$((raw / 1000))
echo "$raw"
}
tg_modem_port() {
local p
if [ "$MODEM_AT_PORT" != auto ]; then
tg_path_dev "$MODEM_AT_PORT" || return
[ -c "$MODEM_AT_PORT" ] && echo "$MODEM_AT_PORT"
return
fi
for p in /dev/ttyUSB2 /dev/ttyUSB1 /dev/ttyUSB3 /dev/mhi_DUN /dev/cdc-wdm0; do
[ -c "$p" ] && { echo "$p"; return; }
done
}
# Quectel: AT+QTEMP lists named sensors in degrees. Take the hottest one and drop
# the -273 placeholders unpopulated sensors report.
tg_modem_quectel() {
tg_at "$1" 'AT+QTEMP' \
| sed -n 's/^+QTEMP:.*,"\(-\{0,1\}[0-9]\{1,3\}\)".*/\1/p' \
| awk '$1 > -100 && $1 < 150 && $1 > m { m = $1 } END { if (m != "") print m }'
}
# Fibocom: AT+GTSENRDTEMP=0 answers "<id>,<millidegrees>" per sensor. Only the RF
# and modem sensors matter, the rest read 0 while idle.
tg_modem_fibocom() {
tg_at "$1" 'AT+GTSENRDTEMP=0' | tr -d '\r' \
| sed -n 's/^+GTSENRDTEMP: *\(1[01457]\),\([0-9]*\)$/\1 \2/p' \
| awk '$2 > 0 && $2 > m { m = $2 } END { if (m > 0) printf "%d", m / 1000 }'
}
# Sets MODEM_VAL and MODEM_SRC. Empty value means "no reading", which the logic
# treats as "condition met" so a dead modem never blocks an escalation.
tg_read_modem() {
local port v rc
MODEM_VAL=""; MODEM_SRC=""
case "$MODEM_SOURCE" in
none) return 0 ;;
file)
tg_path_modem_file "$MODEM_TEMP_FILE" || return 0
v=$(tg_int "$(cat "$MODEM_TEMP_FILE" 2>/dev/null)")
[ -n "$v" ] && [ "$v" -gt 1000 ] && v=$((v / 1000))
[ -n "$v" ] && { MODEM_VAL=$v; MODEM_SRC="file"; }
return 0
;;
command)
# An executable from the hook directory, not a string from UCI.
[ -x "$HOOK_DIR/modem-temp" ] || return 0
v=$({ timeout -k 5 "$AT_TIMEOUT" "$HOOK_DIR/modem-temp"; } 2>/dev/null)
rc=$?
# Stopped at its time limit: coreutils timeout says 124, or 137 for
# the KILL a hook that ignored TERM gets; a BusyBox timeout dies of
# the signal itself (143, 137). What such a hook printed before is
# no reading.
[ "$rc" -eq 124 ] || [ "$rc" -gt 128 ] && return 0
v=$(tg_int "$v")
[ -n "$v" ] && { MODEM_VAL=$v; MODEM_SRC="command"; }
return 0
;;
esac
port=$(tg_modem_port)
[ -n "$port" ] || return 0
case "$MODEM_SOURCE" in
quectel) v=$(tg_modem_quectel "$port") ;;
fibocom) v=$(tg_modem_fibocom "$port") ;;
auto)
v=$(tg_modem_quectel "$port")
[ -n "$v" ] || v=$(tg_modem_fibocom "$port")
;;
esac
v=$(tg_int "$v")
[ -n "$v" ] && [ "$v" -ge 1 ] && [ "$v" -le 150 ] && { MODEM_VAL=$v; MODEM_SRC="at"; }
return 0
}
# Everything else the board reports: Wi-Fi chips, ethernet phys, disks. Printed as
# "name=degrees" per line. The source of the processor reading is skipped so it does
# not show up twice, and hwmon entries win over thermal zones with the same name.
tg_read_extra_sensors() {
local f name label raw seen=" " cpu_zone="" cpu_real="" cpu
[ "$EXTRA_SENSORS" = 1 ] || return 0
# The processor is shown on its own, so its sensor is skipped by path. A
# thermal zone is also mirrored by the kernel as a hwmon node named after
# the zone's type, which the path does not catch: that mirror is skipped
# by name. Neither check makes the other redundant.
# The loops walk class paths. A cpu_temp_path set by hand in its
# /sys/devices form matches none of them, so only then is it resolved and
# compared resolved: that costs a readlink per sensor, which the default
# and every detected path, all in class form, do not need.
case "$CPU_TEMP_PATH" in
"$THERMAL_ROOT"/*|"$HWMON_ROOT"/*) ;;
*) cpu_real=$(readlink -f "$CPU_TEMP_PATH" 2>/dev/null) ;;
esac
cpu=${cpu_real:-$CPU_TEMP_PATH}
# The kernel names a zone's hwmon mirror after the zone type with "-" turned
# into "_", hwmon names carry no hyphen: zone cpu-thermal, mirror
# cpu_thermal. So the zone side is normalised, once, and compared as is.
case "$cpu" in
*/thermal_zone*/temp)
cpu_zone=$(cat "${cpu%/temp}/type" 2>/dev/null | tr '-' '_') ;;
esac
for f in "$HWMON_ROOT"/hwmon*/temp*_input; do
[ -r "$f" ] || continue
[ "$f" = "$CPU_TEMP_PATH" ] && continue
[ -n "$cpu_real" ] && [ "$(readlink -f "$f")" = "$cpu_real" ] && continue
name=$(cat "${f%/*}/name" 2>/dev/null)
[ -n "$name" ] || continue
[ -n "$cpu_zone" ] && [ "$name" = "$cpu_zone" ] && continue
# a chip with several sensors labels them, use that to tell them apart
label=$(cat "${f%_input}_label" 2>/dev/null)
[ -n "$label" ] && name="$name $label"
case "$seen" in *" $name "*) continue ;; esac
raw=$(tg_int "$(cat "$f" 2>/dev/null)")
[ -n "$raw" ] || continue
[ "$raw" -gt 1000 ] && raw=$((raw / 1000))
[ "$raw" -gt -100 ] && [ "$raw" -lt 200 ] || continue
seen="$seen$name "
printf '%s=%s\n' "$name" "$raw"
done
for f in "$THERMAL_ROOT"/thermal_zone*/temp; do
[ -r "$f" ] || continue
[ "$f" = "$CPU_TEMP_PATH" ] && continue
[ -n "$cpu_real" ] && [ "$(readlink -f "$f")" = "$cpu_real" ] && continue
name=$(cat "${f%/*}/type" 2>/dev/null)
[ -n "$name" ] || continue
case "$seen" in *" $name "*) continue ;; esac
raw=$(tg_int "$(cat "$f" 2>/dev/null)")
[ -n "$raw" ] || continue
[ "$raw" -gt 1000 ] && raw=$((raw / 1000))
[ "$raw" -gt -100 ] && [ "$raw" -lt 200 ] || continue
seen="$seen$name "
printf '%s=%s\n' "$name" "$raw"
done
tg_read_mtwifi
}
# The proprietary MediaTek driver (mt_wifi) registers no hwmon sensor, its
# temperature is only reachable through a private ioctl. Same query as
# ImmortalWrt's autocore, and like it only for interfaces that are up.
# Numbered by position in the list, so a radio that is down leaves a gap
# instead of renaming the other one.
tg_read_mtwifi() {
local ifname flags raw n=-1
tg_have iwpriv || return 0
for ifname in ra0 rax0 rai0 ray0 rae0 raz0; do
n=$((n + 1))
[ -r "$NET_ROOT/$ifname/flags" ] || continue
flags=$(cat "$NET_ROOT/$ifname/flags" 2>/dev/null)
case "$flags" in 0x[0-9a-fA-F]*) ;; *) continue ;; esac
[ $((flags & 1)) -eq 1 ] || continue
raw=$(tg_mtwifi_temp "$ifname")
[ -n "$raw" ] || continue
printf 'mtwifi_phy%s=%s\n' "$n" "$raw"
done
}
tg_mtwifi_temp() { # $1 interface
local out v
if tg_have timeout; then
out=$(timeout 3 iwpriv "$1" stat 2>/dev/null)
else
out=$(iwpriv "$1" stat 2>/dev/null)
fi
v=$(printf '%s\n' "$out" | sed -n 's/^CurrentTemperature *= *\(-\{0,1\}[0-9]\{1,3\}\) *$/\1/p' | head -n 1)
v=$(tg_int "$v")
[ -n "$v" ] && [ "$v" -gt -100 ] && [ "$v" -lt 200 ] && echo "$v"
return 0
}
# ---- stage actions ---------------------------------------------------------
# CFUN=4 is flight mode and CFUN=0 minimum function, neither a reset: a reset
# while the PCIe link is up can wedge the host port on some boards.
tg_action_modem_radio_off() { tg_modem_cfun_set "${MODEM_RADIO_OFF_AT#AT+CFUN=}"; }
tg_action_modem_radio_on() { tg_modem_cfun_set 1; }
# Success is what the modem reports afterwards, not what the command returned:
# a collision on the port swallows the command and answers nothing. So the
# command is sent again on every attempt.
tg_modem_cfun_set() { # $1 level
local port got="" i=0
port=$(tg_modem_port)
[ -n "$port" ] || return 1
while [ "$i" -lt 3 ]; do
[ "$i" -gt 0 ] && sleep "$CFUN_SETTLE"
tg_at "$port" "AT+CFUN=$1" >/dev/null 2>&1
got=$(tg_at "$port" 'AT+CFUN?' | tr -d '\r' | sed -n 's/^+CFUN: *\([0-9]\).*/\1/p' | head -n1)
[ "$got" = "$1" ] && return 0
i=$((i + 1))
done
tg_log "modem reports CFUN ${got:-nothing} after AT+CFUN=$1" crit
return 1
}
# UCI section names only: the name ends up as an ifdown argument and in a ubus
# object path.
tg_iface_name_ok() {
case "$1" in
''|-*|*[!A-Za-z0-9_]*) return 1 ;;
esac
}
# "true" or "false" as netifd reports it, empty for an interface it does not know.
tg_iface_up_state() {
timeout "$UBUS_TIMEOUT" ubus call "network.interface.$1" status 2>/dev/null \
| jsonfilter -e '@.up' 2>/dev/null
}
tg_action_interface_down() { tg_iface_set "${ACTION_INTERFACE:-$UPLINK_INTERFACE}" false; }
tg_action_interface_up() { tg_iface_set "${ACTION_INTERFACE:-$UPLINK_INTERFACE}" true; }
# ifdown and ifup return before netifd has acted, so wait for its report.
tg_iface_set() { # $1 interface, $2 true or false
local i=0 got=""
if ! tg_iface_name_ok "$1"; then
tg_log "interface action has no usable interface, set action_interface or uplink_interface" warning
return 1
fi
tg_have ifdown || return 1
if [ "$2" = false ]; then
timeout "$CMD_TIMEOUT" ifdown "$1" >/dev/null 2>&1
else
timeout "$CMD_TIMEOUT" ifup "$1" >/dev/null 2>&1
fi
while [ "$i" -lt 3 ]; do
[ "$i" -gt 0 ] && sleep "$CFUN_SETTLE"
got=$(tg_iface_up_state "$1")
[ "$got" = "$2" ] && return 0
i=$((i + 1))
done
tg_log "interface $1 reports up=${got:-unknown} after if$([ "$2" = false ] && echo down || echo up)" crit
return 1
}
tg_action_wifi_off() { tg_wifi_set down; }
tg_action_wifi_on() { tg_wifi_set up; }
# netifd's view of the radios that are not disabled, as "<radios> <up>",
# nothing when it does not answer. One filter for both counts, so they come
# from the same radios. Written as !(=true), because != does not match a radio
# without a "disabled" key at all.
tg_wifi_radios() {
local json v radios=0 up=0
json=$(timeout "$UBUS_TIMEOUT" ubus call network.wireless status 2>/dev/null) || return 0
[ -n "$json" ] || return 0
for v in $(printf '%s\n' "$json" | jsonfilter -e '@[!(@.disabled=true)].up' 2>/dev/null); do
radios=$((radios + 1))
[ "$v" = true ] && up=$((up + 1))
done
echo "$radios $up"
}
# Like the modem, the command goes out again on every attempt, and success is
# what netifd reports afterwards. Starting hostapd per radio takes longer than
# a modem answers, hence its own settle time and more attempts.
tg_wifi_set() { # $1 down or up
local i=0 state radios="" up=""
if ! tg_have wifi; then
tg_log "no wifi command, cannot switch Wi-Fi $1" warning
return 1
fi
while [ "$i" -lt "$WIFI_TRIES" ]; do
timeout "$CMD_TIMEOUT" wifi "$1" >/dev/null 2>&1
[ "$WIFI_SETTLE" -gt 0 ] && sleep "$WIFI_SETTLE"
state=$(tg_wifi_radios)
if [ -n "$state" ]; then
radios=${state%% *}; up=${state#* }
# Nothing to switch off is not a success: the stage would claim an
# effect it never had. Nothing to bring back is fine, a reset must
# not fail on it.
if [ "$radios" -eq 0 ]; then
[ "$1" = up ] && return 0
tg_log "netifd manages no Wi-Fi radio, wifi_off switched nothing off" crit
return 1
fi
[ "$1" = down ] && [ "$up" -eq 0 ] && return 0
[ "$1" = up ] && [ "$up" -ge "$radios" ] && return 0
fi
i=$((i + 1))
done
if [ -z "$state" ]; then
tg_log "netifd reports no Wi-Fi status after wifi $1" crit
else
tg_log "Wi-Fi reports $up of $radios radio(s) up after wifi $1" crit
fi
return 1
}
# ---- uplink ----------------------------------------------------------------
# l3 device of the modem interface, so notifications can tell "there is another
# way out" from "the only way out is the thing we are about to switch off".
tg_modem_l3dev() {
[ -n "$UPLINK_INTERFACE" ] || return 0
timeout "$UBUS_TIMEOUT" ubus call "network.interface.$UPLINK_INTERFACE" status 2>/dev/null \
| jsonfilter -e '@.l3_device' 2>/dev/null
}
tg_default_devs() {
tg_have ip || return 0
{ ip -4 route show default; ip -6 route show default; } 2>/dev/null \
| sed -n 's/.* dev \([^ ]*\).*/\1/p'
}
# 0 = there is a default route that does not run through the modem.
# Without ip nothing can tell whether a route exists, and a message that waits
# for one nobody can see is never sent. Unknown therefore means send now.
tg_uplink_unknown() {
tg_have ip && return 1
[ "$IP_TOLD" = 1 ] ||
tg_cfg_log "no ip command, cannot tell whether another uplink exists, sending at once"
[ "$DAEMON" = 1 ] && IP_TOLD=1
return 0
}
tg_alt_uplink() {
local md dev
tg_uplink_unknown && return 0
md=$(tg_modem_l3dev)
for dev in $(tg_default_devs); do
[ -n "$md" ] && [ "$dev" = "$md" ] && continue
return 0
done
return 1
}
tg_any_uplink() { tg_uplink_unknown || [ -n "$(tg_default_devs)" ]; }
@@ -0,0 +1,344 @@
# SPDX-License-Identifier: GPL-2.0-only
# Trip management for thermal-guard: lowers the active trips of the fan's thermal
# zone while the modem runs hotter than the CPU, so the kernel spins the fan up
# for a heat source it cannot see. Sourced by /usr/sbin/thermal-guard.
# shellcheck shell=busybox
# shellcheck disable=SC2034 # TRIP_* are read by the caller and the status file
#
# Trips only ever go below the baseline, never above it. A wrong modem reading
# can at worst make the box loud, and a dead daemon leaves the trips where they
# were, which is the same safe direction. hot and critical are never written.
#
# All temperatures in here are millidegrees, the unit sysfs uses. Only UCI and
# the log speak whole degrees.
# Readings the effective delta is the maximum of. Lowering acts on one reading,
# raising the trips again needs this many in a row.
TRIP_WINDOW_N=3
TRIP_DELTA_STEP=5000
TRIP_DELTA_MAX=40000
# Runtime state, rebuilt from sysfs by every daemon start.
TRIP_STATE=off; TRIP_REASON=""; TRIP_ZONE=""; TRIP_IDX=""; TRIP_LIMIT=""
TRIP_BASE=""; TRIP_DT=""; TRIP_NOW=""; TRIP_WIN=""; TRIP_DELTA=0
TRIP_WAS_ACTIVE=0; TRIP_DT_CHANGED=0
# What was last said per message, each cleared when its cause is gone.
TRIP_TOLD_FIND=""; TRIP_TOLD_VALID=""; TRIP_TOLD_DT=""; TRIP_TOLD_WRITE=""
# Set once a failed write of trip_base has been reported, until one succeeds.
TRIP_BASE_TOLD=0
# $1 cpu, $2 modem, $3 offset, all whole degrees. Prints the delta in
# millidegrees, or nothing when a reading is missing: no reading is not the same
# as a cool modem, and must not pull the trips back up.
tg_trip_delta() {
local cpu modem off raw
cpu=$(tg_int "$1"); modem=$(tg_int "$2"); off=$(tg_int "$3")
[ -n "$cpu" ] && [ -n "$modem" ] && [ -n "$off" ] || return 0
raw=$(( (modem - off - cpu) * 1000 ))
if [ "$raw" -le 0 ]; then
echo 0
return 0
fi
raw=$(( (raw + TRIP_DELTA_STEP - 1) / TRIP_DELTA_STEP * TRIP_DELTA_STEP ))
[ "$raw" -gt "$TRIP_DELTA_MAX" ] && raw=$TRIP_DELTA_MAX
echo "$raw"
}
# Keep the last TRIP_WINDOW_N deltas in TRIP_WIN.
tg_trip_push() {
local d out=""
# shellcheck disable=SC2086 # word splitting of the window is intended
set -- $TRIP_WIN "$1"
while [ "$#" -gt "$TRIP_WINDOW_N" ]; do shift; done
for d in "$@"; do
out="$out${out:+ }$d"
done
TRIP_WIN=$out
}
tg_trip_window_max() {
local d m=0
for d in $TRIP_WIN; do [ "$d" -gt "$m" ] && m=$d; done
echo "$m"
}
# $1 space separated list in whole degrees, $2 expected count, $3 limit in
# millidegrees (lowest hot or critical trip, empty if the zone has none).
tg_trip_valid() {
local v prev="" n=0
for v in $1; do
[ -n "$(tg_int "$v")" ] || return 1
[ "$v" -ge 20 ] && [ "$v" -le 95 ] || return 1
[ -z "$prev" ] || [ "$v" -gt "$prev" ] || return 1
[ -z "$3" ] || [ $((v * 1000)) -lt "$3" ] || return 1
prev=$v; n=$((n + 1))
done
[ "$n" -gt 0 ] && [ "$n" -eq "$2" ]
}
# The fan's zone and the active trips its cooling device is bound to, ordered
# by temperature. Sets TRIP_ZONE, TRIP_IDX, TRIP_LIMIT or TRIP_REASON, a fixed
# code the web interface translates.
tg_trip_find() {
local cdev z c idx t lim=""
TRIP_ZONE=""; TRIP_IDX=""; TRIP_LIMIT=""
cdev=$(tg_fan_find_cdev)
[ -n "$cdev" ] || { TRIP_REASON=no_cdev; return 1; }
z=$FAN_ZONE
[ -n "$z" ] || z=$(tg_fan_find_zone "$cdev")
[ -n "$z" ] || { TRIP_REASON=no_zone; return 1; }
tg_path_thermal "$z" || { TRIP_REASON=zone_path; return 1; }
# One "temp index" line per active trip bound to the fan, ordered by
# temperature. This runs in a subshell, so nothing in it sets a global.
TRIP_IDX=$(
for c in "$z"/cdev*; do
case "${c##*/}" in cdev*[!0-9]*) continue ;; esac
[ "$(readlink -f "$c")" = "$(readlink -f "$cdev")" ] || continue
idx=$(tg_int "$(cat "${c}_trip_point" 2>/dev/null)")
[ -n "$idx" ] || continue
[ "$(cat "$z/trip_point_${idx}_type" 2>/dev/null)" = active ] || continue
t=$(tg_int "$(cat "$z/trip_point_${idx}_temp" 2>/dev/null)")
[ -n "$t" ] || continue
printf '%s %s\n' "$t" "$idx"
done | sort -n | uniq | while read -r t idx; do printf '%s ' "$idx"; done
)
TRIP_IDX=${TRIP_IDX% }
[ -n "$TRIP_IDX" ] || { TRIP_REASON=no_active_trip; return 1; }
for idx in $TRIP_IDX; do
[ -w "$z/trip_point_${idx}_temp" ] || { TRIP_REASON=not_writable; return 1; }
done
# hot and critical bound the baseline from above
for c in "$z"/trip_point_*_type; do
case "$(cat "$c" 2>/dev/null)" in
hot|critical)
t=$(tg_int "$(cat "${c%_type}_temp" 2>/dev/null)")
[ -n "$t" ] && { [ -z "$lim" ] || [ "$t" -lt "$lim" ]; } && lim=$t
;;
esac
done
TRIP_ZONE=$z; TRIP_LIMIT=$lim
return 0
}
tg_trip_read() { # prints the managed trips in millidegrees
local idx out=""
for idx in $TRIP_IDX; do
out="$out${out:+ }$(tg_int "$(cat "$TRIP_ZONE/trip_point_${idx}_temp" 2>/dev/null)")"
done
echo "$out"
}
# $1 target list in millidegrees, same order as TRIP_IDX. Lowering writes the
# lowest trip first and raising the highest first, so the trips stay ascending
# at every point in between. Every write re-checks the type: a trip that is not
# active is never touched, whatever the index says.
tg_trip_write() {
local want="$1" now p idx w got fail=0
now=$(tg_trip_read)
[ "$now" = "$want" ] && return 0
for p in $(tg_trip_pairs "$want" "$now"); do
idx=${p%%:*}; w=${p#*:}
[ "$(cat "$TRIP_ZONE/trip_point_${idx}_type" 2>/dev/null)" = active ] || {
tg_log "trip $idx is no longer active, not writing it" warning; fail=1; continue; }
echo "$w" > "$TRIP_ZONE/trip_point_${idx}_temp" 2>/dev/null
got=$(tg_int "$(cat "$TRIP_ZONE/trip_point_${idx}_temp" 2>/dev/null)")
[ "$got" = "$w" ] || fail=1
done
TRIP_NOW=$(tg_trip_read)
if [ "$fail" = 1 ]; then
tg_trip_tell "write" "could not set trips to $(tg_trip_deg "$want") C, they read $(tg_trip_deg "$TRIP_NOW") C" warning
return 1
fi
TRIP_TOLD_WRITE=""
tg_log "trips set to $(tg_trip_deg "$want") C (delta $((TRIP_DELTA / 1000)) K)"
}
# $1 target, $2 current, both in TRIP_IDX order. Prints idx:value in the order
# they have to be written.
tg_trip_pairs() {
local i=0 idx p pairs=""
for idx in $TRIP_IDX; do
i=$((i + 1))
p="$idx:$(tg_trip_nth "$i" "$1")"
if [ -n "$2" ] && [ "${1%% *}" -lt "${2%% *}" ]; then
pairs="$pairs${pairs:+ }$p"
else
pairs="$p${pairs:+ }$pairs"
fi
done
echo "$pairs"
}
tg_trip_nth() { # $1 position, $2 space separated list
local n=0 v
for v in $2; do
n=$((n + 1))
[ "$n" = "$1" ] && { echo "$v"; return 0; }
done
}
tg_trip_count() { # number of words in $1
local n=0 v
for v in $1; do n=$((n + 1)); done
echo "$n"
}
tg_trip_deg() { # millidegree list to whole degrees, for the log
local v out=""
for v in $1; do out="$out${out:+/}$((v / 1000))"; done
echo "$out"
}
# Say a thing once until it changes, the daemon runs every interval.
tg_trip_tell() { # $1 find, valid, dt or write, $2 text, $3 priority as for tg_cfg_log
local told
case "$1" in
find) told=$TRIP_TOLD_FIND ;;
valid) told=$TRIP_TOLD_VALID ;;
dt) told=$TRIP_TOLD_DT ;;
write) told=$TRIP_TOLD_WRITE ;;
*) return 0 ;;
esac
[ "$told" = "$2" ] && return 0
case "$1" in
find) TRIP_TOLD_FIND=$2 ;;
valid) TRIP_TOLD_VALID=$2 ;;
dt) TRIP_TOLD_DT=$2 ;;
write) TRIP_TOLD_WRITE=$2 ;;
esac
tg_cfg_log "$2" "${3:-warning}"
}
# Device tree values of this boot: read once per boot, before anything wrote.
tg_trip_dt_load() {
local f="$STATE_DIR/trip_dt"
if [ -r "$f" ]; then
TRIP_DT=$(cat "$f")
else
TRIP_DT=$(tg_trip_read)
mkdir -p "$STATE_DIR"
echo "$TRIP_DT" > "$f"
fi
}
# trip_base holds the device tree set, in whole degrees, that the operator's
# trip_active was made for. It lives on the overlay because a sysupgrade is
# exactly when the device tree changes. Without trip_active it means nothing,
# so it is neither read nor written then.
tg_trip_base_save() { # $1 whole degrees, space separated
local f="$PERSIST_DIR/trip_base"
mkdir -p "$PERSIST_DIR" 2>/dev/null &&
echo "$1" > "$f.tmp" 2>/dev/null && chmod 644 "$f.tmp" &&
mv "$f.tmp" "$f" 2>/dev/null || return 1
sync
}
tg_trip_base_read() {
local v
v=$(cat "$PERSIST_DIR/trip_base" 2>/dev/null)
case "$v" in
*[!0-9\ ]*) echo "" ;;
*) echo "$v" ;;
esac
}
# Sets TRIP_DT_CHANGED when the device tree set of this boot is not the one
# trip_base names. The first cycle with trip_active records it.
tg_trip_base_check() {
local now base msg
now=$(tg_trip_deg "$TRIP_DT" | tr '/' ' ')
if [ ! -e "$PERSIST_DIR/trip_base" ]; then
[ "$DRY" = 1 ] && return 0
if tg_trip_base_save "$now"; then
TRIP_BASE_TOLD=0
return 0
fi
[ "$TRIP_BASE_TOLD" = 1 ] && return 0
tg_cfg_log "could not write $PERSIST_DIR/trip_base, a new firmware will not be noticed"
[ "$DAEMON" = 1 ] && TRIP_BASE_TOLD=1
return 0
fi
base=$(tg_trip_base_read)
[ -n "$base" ] && [ "$base" != "$now" ] || return 0
TRIP_DT_CHANGED=1
msg="device tree trips are now $(tg_trip_deg "$TRIP_DT") C,"
tg_trip_tell dt "$msg trip_active was set for $(echo "$base" | tr ' ' '/') C" notice
}
# One cycle. $1 cpu, $2 modem in whole degrees.
tg_trip_cycle() {
local cpu="$1" modem="$2" d want="" b
if [ "$TRIP_BOOST" = off ] && [ -z "$TRIP_ACTIVE" ]; then
TRIP_STATE=off; TRIP_REASON=""
TRIP_TOLD_FIND=""; TRIP_TOLD_VALID=""; TRIP_TOLD_DT=""
# Switched off while running: hand the zone back its device tree set
# once, then leave it alone for good.
if [ "$TRIP_WAS_ACTIVE" = 1 ] && [ -n "$TRIP_ZONE" ] && [ -n "$TRIP_DT" ]; then
TRIP_DELTA=0
tg_trip_write "$TRIP_DT"
TRIP_WAS_ACTIVE=0; TRIP_WIN=""
fi
return 0
fi
if ! tg_trip_find; then
TRIP_STATE=inactive
tg_trip_tell find "trip management inactive ($TRIP_REASON), zone ${TRIP_ZONE:-none}"
return 0
fi
TRIP_TOLD_FIND=""
[ -n "$TRIP_DT" ] || tg_trip_dt_load
TRIP_NOW=$(tg_trip_read)
# Baseline: the operator's list when it holds up, the device tree otherwise
if [ -n "$TRIP_ACTIVE" ] &&
tg_trip_valid "$TRIP_ACTIVE" "$(tg_trip_count "$TRIP_IDX")" "$TRIP_LIMIT"; then
TRIP_BASE=""
for b in $TRIP_ACTIVE; do TRIP_BASE="$TRIP_BASE${TRIP_BASE:+ }$((b * 1000))"; done
TRIP_TOLD_VALID=""
else
if [ -n "$TRIP_ACTIVE" ]; then
tg_trip_tell valid "rejecting trip_active '$TRIP_ACTIVE', using the device tree values"
else
TRIP_TOLD_VALID=""
fi
TRIP_BASE=$TRIP_DT
fi
TRIP_DT_CHANGED=0
[ -n "$TRIP_ACTIVE" ] && tg_trip_base_check
[ "$TRIP_DT_CHANGED" = 1 ] || TRIP_TOLD_DT=""
if [ "$TRIP_BOOST" = modem ]; then
d=$(tg_trip_delta "$cpu" "$modem" "$TRIP_BOOST_OFFSET")
[ -n "$d" ] && tg_trip_push "$d"
d=$(tg_trip_window_max)
# While a stage is in force the trips do not come back up
[ "$STAGE" -ge 0 ] && [ "$d" -lt "$TRIP_DELTA" ] && d=$TRIP_DELTA
else
d=0; TRIP_WIN=""
fi
TRIP_DELTA=$d
for b in $TRIP_BASE; do want="$want${want:+ }$((b - d))"; done
TRIP_STATE=active; TRIP_REASON=""; TRIP_WAS_ACTIVE=1
[ "$DRY" = 1 ] && return 0
tg_trip_write "$want"
}
# cooling-levels of the pwm-fan from the device tree, for display only. The
# driver reads them once at probe and exposes no sysfs attribute for them.
# Big-endian 32 bit cells; BusyBox on the targets has hexdump but no od.
tg_fan_levels() {
local f
for f in "$DT_ROOT"/*/cooling-levels "$DT_ROOT"/*/*/cooling-levels; do
[ -r "$f" ] || continue
tr '\0' '\n' < "${f%/*}/compatible" 2>/dev/null | grep -qx pwm-fan || continue
hexdump -v -e '4/1 "%u " "\n"' "$f" 2>/dev/null |
awk 'NF == 4 { printf "%s%d", s, $1 * 16777216 + $2 * 65536 + $3 * 256 + $4; s = " " }'
return 0
done
}