mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 16:55:13 +08:00
🤞 Sync 2026-09-27 22:31:51
This commit is contained in:
1 parent
3355bff186
commit
9a761ba34e
18 files changed
+5738
No files matched your search
@@ -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
|
||||
+113
@@ -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" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
Executable
+38
@@ -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
@@ -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
|
||||
Executable
+2179
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
|
||||
}
|
||||
Reference in new issue
Block a user