🦄 Sync 2026-09-27 22:25:23

This commit is contained in:
github-actions[bot] committed 2026-09-27 22:25:23 +08:00
1 parent aca9bac130
commit 08fc47ff20
67 files changed
+415 -8397

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
# SPDX-License-Identifier: GPL-2.0-only
include $(TOPDIR)/rules.mk
PKG_NAME:=honk
PKG_VERSION:=0.0.1_beta81
PKG_RELEASE:=1
# Raw upstream release tag. It keeps the dots in qualifiers (e.g. v0.0.1.beta.49)
# because that is what the GitHub release asset name uses.
HONK_RELEASE_TAG:=v0.0.1.beta.81
# SHA256 of each release tarball, kept in sync by scripts/update_honk_version.sh.
HONK_HASH_X86_64:=88e5c3b29200c6ad729cc89636e14a452d974108cd08852c9c1e44074d23e10e
HONK_HASH_AARCH64:=b160a3c8674556222b2b29ce6c975bc346f7ce3cddeb049474c6ca9e0a8191a6
PKG_LICENSE:=AGPL-3.0-only
PKG_LICENSE_FILE:=LICENSE
PKG_MAINTAINER:=luci-app-honk maintainers
ifeq ($(ARCH),x86_64)
HONK_TARGET:=x86_64-unknown-linux-musl
HONK_HASH:=$(HONK_HASH_X86_64)
else ifeq ($(ARCH),aarch64)
HONK_TARGET:=aarch64-unknown-linux-musl
HONK_HASH:=$(HONK_HASH_AARCH64)
endif
# Default to the stock-malloc build: lower RSS high-water on small gateways.
# Use an empty suffix to switch back to the mimalloc build (faster QUIC).
HONK_SUFFIX:=-stock
HONK_ASSET:=honk-core-$(HONK_RELEASE_TAG)-$(HONK_TARGET)$(HONK_SUFFIX).tar.gz
PKG_SOURCE:=$(HONK_ASSET)
PKG_SOURCE_URL:=https://github.com/daeuniverse/honk/releases/download/$(HONK_RELEASE_TAG)/
PKG_HASH:=skip
include $(INCLUDE_DIR)/package.mk
define Package/honk
SECTION:=net
CATEGORY:=Network
SUBMENU:=Web Servers/Proxies
TITLE:=honk - eBPF transparent proxy engine (dae-compatible)
URL:=https://github.com/daeuniverse/honk
DEPENDS:=@(x86_64||aarch64) \
+ca-bundle +ip-full \
+kmod-sched-core +kmod-sched-bpf +kmod-veth +kmod-nft-queue \
+v2ray-geoip +v2ray-geosite \
+@KERNEL_XDP_SOCKETS \
+HONK_USE_VMLINUX_BTF:vmlinux-btf
endef
define Package/honk/config
choice
prompt "BTF source for CO-RE"
default HONK_USE_KERNEL_BTF
depends on PACKAGE_honk
config HONK_USE_KERNEL_BTF
bool "Use kernel BTF (integrated)"
depends on KERNEL_DEBUG_INFO_BTF
config HONK_USE_VMLINUX_BTF
bool "Use vmlinux-btf package"
endchoice
endef
define Package/honk/conffiles
/etc/honk/
/etc/config/honk
endef
define Build/Prepare
mkdir -p $(PKG_BUILD_DIR)
$(TAR) -xzf $(DL_DIR)/$(PKG_SOURCE) -C $(PKG_BUILD_DIR) --strip-components=1
endef
define Build/Compile
true
endef
define Package/honk/install
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(PKG_BUILD_DIR)/honk-core $(1)/usr/bin/honk-core
$(INSTALL_DIR) $(1)/etc/honk/config.d
$(INSTALL_CONF) $(CURDIR)/files/config.dae $(1)/etc/honk/config.dae
$(INSTALL_CONF) $(CURDIR)/files/config.d/node.dae $(1)/etc/honk/config.d/node.dae
$(INSTALL_CONF) $(CURDIR)/files/config.d/route.dae $(1)/etc/honk/config.d/route.dae
$(INSTALL_CONF) $(CURDIR)/files/config.d/dns.dae $(1)/etc/honk/config.d/dns.dae
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) $(CURDIR)/files/honk.config $(1)/etc/config/honk
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) $(CURDIR)/files/honk.init $(1)/etc/init.d/honk
endef
$(eval $(call BuildPackage,honk))
+23
View File
@@ -0,0 +1,23 @@
# dns.dae
dns {
bind: 'tcp+udp://[::]:53530'
client_subnet: auto
#ipversion_prefer: 4
upstream {
local: 'udp://127.0.0.1:53' -> direct
remote: 'tcp+udp://dns.google:53'
}
routing {
request {
qtype(https) -> reject
qname(geosite: gfw) -> remote
fallback: local
}
response {
upstream(remote) -> accept
qname(geosite: private) -> accept
ip(geoip: cn) -> accept
fallback: remote
}
}
}
+35
View File
@@ -0,0 +1,35 @@
# node.dae
node {
node1: 'xxx'
node2: 'xxx'
}
subscription {
# 短形式:默认 86400s 刷新一次
sub: 'http://sub'
# 短形式 + UA:仍然只能默认 86400s
sub_ua: 'http://sub'(honk/1.0 like)
# 块形式:可以自定义定时刷新周期
sub2: {
url: 'http://sub'
ua: 'honk/1.0'
interval: '10000s' # 每 10000 秒刷新一次
}
# 禁用定期刷新
sub3: {
url: 'http://sub'
interval: 0
}
}
group {
proxy {
filter: subtag(sub) && !name(keyword: 'ExpireAt:')
filter: name(node1, node2)
policy: score
}
}
+27
View File
@@ -0,0 +1,27 @@
# route.dae
routing {
pname(dnsmasq, zerotier-one) -> direct(must)
dip(224.0.0.0/3, 'ff00::/8', geoip: private) -> direct
domain(geosite: category-bank-cn, geosite: boc@!cn, geosite: synology) -> direct
# block quic, if u node has poor quic support, enable this line
# l4proto(udp) && dport(443) -> block
domain(geosite: spotify) -> proxy
domain(geosite: category-doh) -> proxy
domain(geosite: tiktok) -> proxy
domain(geosite: category-ai-!cn, geosite: github) -> proxy
domain(geosite: gfw) -> proxy
dip(geoip: telegram) -> proxy
dip(geoip: cn) -> direct
# geoip:cn always using chiness asn, can use domain rule to override
# domain(geosite: cn) -> direct
# using proxy for ssh, http, https, and common ports, avoid (bt, or other p2p)
# dport(22, 80, 443, 8080, 6443, 9090) -> proxy
# fallback: direct
fallback: proxy
}
+47
View File
@@ -0,0 +1,47 @@
# config.dae
# Load all honk files placed in ./config.d/.
include {
config.d/*.dae
}
global {
log_level: warn
check_interval: 600s
check_tolerance: 30ms
lan_interface: br-lan
wan_interface: auto
auto_config_kernel_parameter: true
data_dir: '/etc/honk'
log_file: '/var/log/honk/honk.log'
store_subscribe: true
# if u not using dns bind, set dial_mode to domain++ prefer
dial_mode: domain
bootstrap_resolver: '223.5.5.5:53'
tcp_check_url: 'https://www.gstatic.com/generate_204'
tcp_check_http_method: HEAD
udp_check_dns: 'dns.google.com:53', '8.8.8.8', '2001:4860:4860::8888'
}
# using for enable experimental features
# native-api, clash-api, cache-file
#experimental {
# native_api {
# enabled: true
# listen: '0.0.0.0:9527'
# secret: 'honk'
# ui: '/etc/honk/dashboard'
# }
# clash_api {
# external_controller: '0.0.0.0:9090'
# external_ui: '/etc/honk/zashboard'
# secret: ''
# default_mode: 'Rule'
# }
# cache_file {
# enabled: true
# path: '/etc/honk/cache.db'
# cache_id: ''
# store_fakeip: false #in this time, this field will be ignored
# }
#}
+7
View File
@@ -0,0 +1,7 @@
config honk 'config'
option enabled '0'
option advanced '0'
option dashboard 'doona'
option config_file '/etc/honk/config.dae'
+112
View File
@@ -0,0 +1,112 @@
#!/bin/sh /etc/rc.common
USE_PROCD=1
START=96
STOP=10
extra_command "hot_reload" "Hot-reload service"
CONF="honk"
PROG="/usr/bin/honk-core"
# honk-core hardcodes dae-compatible datapath names (dae0/dae0peer + daens);
# these are the real interface/netns names, not leftover dae config.
HONK_HOST_IFACE="dae0"
HONK_PEER_IFACE="dae0peer"
HONK_NETNS_NAME="daens"
HONK_NETNS_PATH="/run/netns/$HONK_NETNS_NAME"
RESOLV_HONK="/tmp/resolv.conf.honk"
set_chmod_640() {
mkdir -p /etc/honk/config.d
chmod -R 640 /etc/honk/ 2>/dev/null || true
}
cleanup_netns() {
ip netns del "$HONK_NETNS_NAME" 2>/dev/null || true
umount "$HONK_NETNS_PATH" 2>/dev/null || true
rm -f "$HONK_NETNS_PATH" 2>/dev/null || true
ip link del "$HONK_HOST_IFACE" 2>/dev/null || true
ip link del "$HONK_PEER_IFACE" 2>/dev/null || true
}
hijack_resolv_conf() {
grep -q ' /tmp/resolv\.conf ' /proc/mounts && return 0
grep -E '^(search|domain) ' /tmp/resolv.conf > "$RESOLV_HONK" 2>/dev/null
local dns_list LOGICAL_WAN
. /lib/functions/network.sh
network_find_wan LOGICAL_WAN 2>/dev/null
if [ -n "$LOGICAL_WAN" ]; then
dns_list=$(ubus call "network.interface.$LOGICAL_WAN" status 2>/dev/null | \
jsonfilter -e '@["dns-server"][*]' 2>/dev/null)
fi
if [ -z "$dns_list" ] && [ -f "/tmp/resolv.conf.d/resolv.conf.auto" ]; then
dns_list=$(awk '/^nameserver/{print $2}' "/tmp/resolv.conf.d/resolv.conf.auto")
fi
[ -z "$dns_list" ] && dns_list="119.29.29.29 223.5.5.5"
for dns in $dns_list; do
echo "nameserver $dns" >> "$RESOLV_HONK"
done
mount --bind "$RESOLV_HONK" /tmp/resolv.conf
mount -o remount,ro,bind /tmp/resolv.conf
}
restore_resolv_conf() {
grep -q ' /tmp/resolv\.conf ' /proc/mounts || return 0
mount -o remount,rw,bind /tmp/resolv.conf 2>/dev/null
umount /tmp/resolv.conf 2>/dev/null
rm -f "$RESOLV_HONK"
}
start_service() {
local enabled config_file
config_load "$CONF"
config_get_bool enabled "config" "enabled" "0"
[ "$enabled" -eq "1" ] || return 1
config_get config_file "config" "config_file" "/etc/honk/config.dae"
set_chmod_640
cleanup_netns
hijack_resolv_conf
procd_open_instance "$CONF"
procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray"
procd_set_param command "$PROG" --config "$config_file"
procd_set_param netdev "$HONK_HOST_IFACE" "$HONK_PEER_IFACE"
procd_set_param limits core="unlimited"
procd_set_param limits nofile="1000000 1000000"
procd_set_param term_timeout 8
procd_set_param respawn 3600 5 5
procd_set_param stderr 1
procd_close_instance
}
service_stopped() {
restore_resolv_conf
cleanup_netns
}
reload_service() {
stop
start
}
service_triggers() {
procd_add_reload_trigger "$CONF"
}
hot_reload() {
set_chmod_640
if /etc/init.d/honk running; then
"$PROG" reload
else
/etc/init.d/honk restart
fi
}
+1 -1
View File
@@ -12,7 +12,7 @@ LUCI_DEPENDS:=+luci-base +logd +jsonfilter +coreutils-timeout
LUCI_PKGARCH:=all
PKG_VERSION:=0.1.46
PKG_RELEASE:=42
PKG_RELEASE:=43
# Reproducible build: honor SOURCE_DATE_EPOCH from the build environment (set by docker-sdk.sh / CI).
ifdef SOURCE_DATE_EPOCH
@@ -197,7 +197,7 @@ function summary_json(out) {
out = out ",\"top_drops\":" summary_top(summary_drop_counts, 3)
out = out ",\"top_rules\":" summary_top(summary_rule_counts, 3) "}"
# LC_ALL=C makes this a byte budget; utf8_prefix keeps field cuts valid.
if (length(out) > 256) return "{\"scope\":\"top of shown sample\",\"truncated\":true}"
if (length(out) > 1024) return "{\"scope\":\"top of shown sample\",\"truncated\":true}"
return out
}
BEGIN {
@@ -6,9 +6,10 @@
# Log messages are treated as data (jsonfilter + awk stdin); never interpolated
# into shell command strings. Usage: ubus call log read '...' | fwlive-log-filter.sh
#
# Performance: one jsonfilter for @.log[*] plus one awk classify. The same
# awk pass counts enumerated entries for messages_received; process count is
# constant per poll, not O(entries).
# Performance: one jsonfilter for @.log[*] plus one awk classify. An empty
# @.log array pays a second jsonfilter -t to tell healthy empty from a
# parse failure. The same awk pass counts enumerated entries for
# messages_received; process count is not O(entries).
#
# Entry point (pipeline). The classifier sibling is sourced and must not set
# strict mode itself.
@@ -37,10 +38,12 @@ fi
# (or paused FETCH_LINES_MAX poll) can exceed that and make jsonfilter fail
# while this script still returns a valid empty result.
# A valid input with no log entries is not fatal; the classifier closes JSON
# and reports messages_received:0. A jsonfilter failure is different:
# it must remain an error so rpcd does not record a fast healthy sample. Keep
# jsonfilter output in a secure temporary file before classification so a
# partial pipeline cannot produce malformed JSON on failure.
# and reports messages_received:0. OpenWrt jsonfilter exits 1 when @.log[*]
# matches nothing, including a real empty array, so that rc alone is not a
# parse failure. A missing log key or malformed JSON must remain an error so
# rpcd does not record a fast healthy sample. Keep jsonfilter output in a
# secure temporary file before classification so a partial pipeline cannot
# produce malformed JSON on failure.
# This script runs from rpcd as root and reopens the path for classification.
# Match _fwlive_mktemp: only use a verified, sticky dump directory. The
# production rpcd caller passes /tmp; the explicit argument lets host tests
@@ -50,17 +53,40 @@ if [ ! -d "$FILTER_TMP_DIR" ] || [ -L "$FILTER_TMP_DIR" ] || ! [ -k "$FILTER_TMP
printf '%s' '{"log":[],"error":"filter_tempfile_failed"}'
exit 1
fi
_filter_tmp=$(mktemp "$FILTER_TMP_DIR/fwlive-filter.XXXXXX") || {
_filter_in=$(mktemp "$FILTER_TMP_DIR/fwlive-filter-in.XXXXXX") || {
printf '%s' '{"log":[],"error":"filter_tempfile_failed"}'
exit 1
}
trap 'rm -f "$_filter_tmp"' 0
trap 'rm -f "$_filter_tmp"; exit 129' 1
trap 'rm -f "$_filter_tmp"; exit 130' 2
trap 'rm -f "$_filter_tmp"; exit 131' 3
trap 'rm -f "$_filter_tmp"; exit 143' 15
if ! jsonfilter -e '@.log[*]' >"$_filter_tmp" 2>/dev/null; then
_filter_tmp=$(mktemp "$FILTER_TMP_DIR/fwlive-filter.XXXXXX") || {
rm -f "$_filter_in"
printf '%s' '{"log":[],"error":"filter_tempfile_failed"}'
exit 1
}
trap 'rm -f "$_filter_in" "$_filter_tmp"' 0
trap 'rm -f "$_filter_in" "$_filter_tmp"; exit 129' 1
trap 'rm -f "$_filter_in" "$_filter_tmp"; exit 130' 2
trap 'rm -f "$_filter_in" "$_filter_tmp"; exit 131' 3
trap 'rm -f "$_filter_in" "$_filter_tmp"; exit 143' 15
cat >"$_filter_in" || {
printf '%s' '{"log":[],"error":"filter_failed"}'
exit 1
}
_jf_rc=0
jsonfilter -e '@.log[*]' <"$_filter_in" >"$_filter_tmp" 2>/dev/null || _jf_rc=$?
if [ "$_jf_rc" -ne 0 ]; then
# Empty @.log array: extract rc=1 and empty stdout on OpenWrt jsonfilter.
# Confirm the member is an array before treating it as a healthy empty poll.
if [ ! -s "$_filter_tmp" ]; then
_log_type=$(jsonfilter -t '@.log' <"$_filter_in" 2>/dev/null) || _log_type=
if [ "$_log_type" = "array" ]; then
: >"$_filter_tmp"
else
printf '%s' '{"log":[],"error":"filter_failed"}'
exit 1
fi
else
printf '%s' '{"log":[],"error":"filter_failed"}'
exit 1
fi
fi
_fwlive_filter_json_reply <"$_filter_tmp"
+20 -6
View File
@@ -263,8 +263,8 @@ EOF
# One awk pass: prefix<TAB>comment. Last quoted log prefix / comment on
# the line, including \" units (same capture as the former sed).
# Raw tabs in prefix/comment become the two-character sequence \t so the
# separator tab is unique. Backslashes are not re-encoded.
# Raw tabs become \t and backslashes become \\ so the separator tab is
# unique. Walk bytes: BusyBox awk ≥1.37 gsub cannot emit those sequences.
nft_dump_fields() {
LC_ALL=C awk '
function last_at(s, needle, p, i, found) {
@@ -276,6 +276,22 @@ nft_dump_fields() {
}
return found
}
function tsv_escape(s, out, i, n, c) {
# BusyBox awk ≥1.37 treats gsub replacement "\\t" as a tab
# (and "\\\\" as one backslash). Walk bytes instead.
out = ""
n = length(s)
for (i = 1; i <= n; i++) {
c = substr(s, i, 1)
if (c == "\\")
out = out "\\\\"
else if (c == "\t")
out = out "\\t"
else
out = out c
}
return out
}
function quoted_at(s, needle, i, rest, out, c, n, esc, j) {
i = last_at(s, needle)
if (i == 0)
@@ -307,10 +323,8 @@ nft_dump_fields() {
comment = quoted_at($0, "comment \"")
if (prefix == "" && comment == "")
next
gsub(/\\/, "\\\\", prefix)
gsub(/\t/, "\\t", prefix)
gsub(/\\/, "\\\\", comment)
gsub(/\t/, "\\t", comment)
prefix = tsv_escape(prefix)
comment = tsv_escape(comment)
print prefix "\t" comment
}
' || return 1
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-meow
PKG_VERSION:=0.21.2
PKG_RELEASE:=1
PKG_RELEASE:=2
LUCI_TITLE:=LuCI support for meow (panel + settings)
LUCI_DEPENDS:=+luci-base +meow
-10
View File
@@ -1,10 +0,0 @@
本仓库完全自包含:init 脚本、UCI 默认配置、默认 YAML、LuCI JS/JSON 均已收录
(取自 meow-rs 仓库 `openwrt/` 目录),CI 里不需要再从上游仓库复制任何文件;
构建时唯一的外部依赖是 `meow/Makefile` 声明的官方 release tarball
(`PKG_SOURCE_URL` + `PKG_HASH` 校验)。
## 参考
- meow-rs: https://github.com/meow-rs/meow-rs
- OpenWrt feeds 文档: https://openwrt.org/docs/guide-developer/feeds
- OpenWrt apk 包管理器(25.12+): https://openwrt.org/docs/guide-user/additional-software/apk
@@ -1,50 +0,0 @@
#!/bin/bash
# scripts/update_meow_version.sh — 跟随 meow-rs 上游 release 自动更新 feed
# 行为:
# 1) 通过 GitHub API 获取 meow-rs 最新 release tag(形如 v0.21.2)
# 2) 下载对应 aarch64 musl tarball,运行时计算真实 SHA256(不硬编码)
# 3) 用 sed 更新 meow/Makefile 的 PKG_VERSION 与 PKG_HASH(保留 Tab 缩进)
# 以及 luci-app-meow/Makefile 的 PKG_VERSION(两个包版本保持一致)
# 4) 已是最新版本时无修改、退出码 0(CI 判定为无变化)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
UPSTREAM="meow-rs/meow-rs"
TARBALL_PREFIX="aarch64-unknown-linux-musl.tar.gz" # 静态 musl 二进制包
MEOW_MK="meow/Makefile"
LUCI_MK="luci-app-meow/Makefile"
# ---- 1. 获取最新 release tag ----
TAG="$(curl -fsSL --retry 3 \
"https://api.github.com/repos/${UPSTREAM}/releases/latest" \
| grep -oP '"tag_name":\s*"\K[^"]+')"
[ -n "${TAG}" ] || { echo "ERROR: 无法获取上游 release tag" >&2; exit 1; }
VERSION="${TAG#v}" # PKG_VERSION 不带 v 前缀(源码 URL 用 v$(PKG_VERSION) 拼接)
CURRENT="$(grep '^PKG_VERSION:=' "${MEOW_MK}" | cut -d= -f2)"
if [ "${VERSION}" = "${CURRENT}" ]; then
echo "meow 已是最新版本 ${TAG},无需更新。"
exit 0
fi
# ---- 2. 下载并计算真实 SHA256 ----
URL="https://github.com/${UPSTREAM}/releases/download/${TAG}/meow-${TAG}-${TARBALL_PREFIX}"
TMP="$(mktemp)"
trap 'rm -f "${TMP}"' EXIT
curl -fsSL --retry 3 -o "${TMP}" "${URL}"
HASH="$(sha256sum "${TMP}" | cut -d' ' -f1)"
echo "上游 ${TAG} tarball SHA256 = ${HASH}"
# ---- 3. 更新 Makefile(sed 保留行内 Tab;两个包版本同步) ----
sed -i "s|^PKG_VERSION:=.*|PKG_VERSION:=${VERSION}|" "${MEOW_MK}" "${LUCI_MK}"
sed -i "s|^PKG_HASH:=.*|PKG_HASH:=${HASH}|" "${MEOW_MK}"
# ---- 4. 完整性自检:PKG_SOURCE/URL 拼接 + 哈希回验 ----
SOURCE_NAME="meow-v${VERSION}-${TARBALL_PREFIX}"
[ -f "${TMP}" ] && [ "$(sha256sum "${TMP}" | cut -d' ' -f1)" = "${HASH}" ] \
|| { echo "ERROR: 哈希回验失败" >&2; exit 1; }
echo "已更新 ${MEOW_MK}: PKG_VERSION=${VERSION}, PKG_HASH=${HASH}"
echo "已更新 ${LUCI_MK}: PKG_VERSION=${VERSION}"
echo "源码包名(由 \$(PKG_VERSION) 拼接): ${SOURCE_NAME}"
@@ -10,7 +10,7 @@ PKG_LICENSE_FILES:=LICENSE
PKG_SOURCE:=meow-v$(PKG_VERSION)-aarch64-unknown-linux-musl.tar.gz
PKG_SOURCE_URL:=https://github.com/meow-rs/meow-rs/releases/download/v$(PKG_VERSION)/
PKG_HASH:=7966b35ee2101e32406009f074f97cbbcc8017489daa02cd3fc5b91b4dbcbfeb
PKG_HASH:=skip
PKG_BUILD_DIR:=$(BUILD_DIR)/meow-v$(PKG_VERSION)-aarch64-unknown-linux-musl
include $(INCLUDE_DIR)/package.mk
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
-1
View File
@@ -1 +0,0 @@
* text=auto eol=lf
-5
View File
@@ -1,5 +0,0 @@
# editor and OS noise
*.sync-conflict-*
.stversions/
.stfolder/
*~
-591
View File
@@ -1,591 +0,0 @@
# Changelog
All notable changes to `thermal-guard` and `luci-app-thermal-guard`.
Versions follow `Major.Minor.Patch`; both packages are released
together and carry the same number.
## 2.0.0
### Breaking changes
- **Breaking: `trip_dt` is no longer a UCI option.** The daemon used to write
it into `/etc/config/thermal-guard` from the running process, next to the
operator's own edits. The device tree trips that `trip_active` was made for
now live in `/etc/thermal-guard/trip_base`, written on the first cycle with
`trip_active` set and kept across a sysupgrade. Without `trip_active` the file
is neither read nor written. The package moves an existing `trip_dt` there
once (`/etc/uci-defaults/90-thermal-guard-trip-base`) and removes the option;
a leftover `trip_dt` is ignored. The daemon no longer writes UCI.
- **Breaking: `fan_cooling_device` and `fan_thermal_zone` are gone.** Both
named a numbered path (`cooling_device0`, `thermal_zone0`), and those numbers
can change between boots, which is what the detection by type avoids. The
fan is found by `fan_cooling_device_type`, its zone by the link to it. A
value left in an old configuration is ignored like any unknown option.
### Notes for operators
- **Syslog filters may need adjusting.** Every line now carries a
`daemon.*` priority instead of `user.notice`: `crit`, `warning`, `notice`,
`info` as listed under Changed. A `logread` filter or remote syslog rule
written for `user.notice` no longer matches.
- **New dependency `jsonfilter`.** `base-files` already ships it on every
OpenWrt and ImmortalWrt image, so nothing new gets installed there.
- **Update both packages together.** The config page's answer to a changed
firmware runs `thermal-guard trips-keep`, which needs the daemon and the
rpcd ACL of this release; the ACL grants exactly that command line.
### Added
- **`thermal-guard trips-keep`** records the device tree trips of the running
boot as the ones `trip_active` was made for. The config page runs it for both
answers to the new firmware notice; the ACL grants exactly this command line.
### Changed
- **README: a section on known limitations**: ModemManager and `at_lock`,
notifications without `curl`, boards that report no temperature, and
`wifi_off` on radios netifd does not manage.
- **Without `curl`, a notification address with a query string or user info
is refused.** `uclient-fetch` and `wget` take the address on the command
line, where every local process can read it in `/proc`, so a token in
`?token=...` or `user:pass@host` would leak. The check runs when the
configuration loads and clears `notify_url`, so nothing piles up in the
outbox; the notify hook still works. An address without `http://` or
`https://`, or with characters `curl` would misread, and a malformed
`notify_header` are now rejected at load for the same reason. Each reason is
logged once, the address itself never. A secret in the path of the address
(Slack, Discord, a private ntfy topic) cannot be detected and stays visible
without `curl`; the README says so.
- **A notification post without `curl` has a time limit.** `uclient-fetch` and
`wget` run under `timeout` (`HTTP_TIMEOUT`, 20 s by default) with `-T 15` for
the network wait, so a hung DNS lookup or server costs one message instead of
blocking the loop. The `curl` path keeps its `-m 15`.
- **`wifi_off` is read back.** It was the only built in stage action that
trusted the exit code of `wifi down`. Now the command is sent up to five times
with 5 s in between (`WIFI_SETTLE`), and the action succeeds only when
`ubus call network.wireless status` reports no radio up; the way back after a
reset waits until every radio that is not disabled is up. Checked against the
real netifd answers of a Banana Pi R3 Mini (`mt_wifi`) and a GL-X3000, kept as
test fixtures.
- **Time limits for `wifi`, `ifdown`, `ifup` and every hook.** The commands get
15 s (`CMD_TIMEOUT`), the `notify`, `stage1` and `stage2` hooks 15 s
(`HOOK_TIMEOUT`), the `modem-temp` hook 5 s. A hook that ignores TERM is killed
5 s later. Before, a hung command or hook held the whole loop.
- **The config page checks what the daemon will use.** The order of the
thresholds is checked against the values in the form, not the saved ones,
and an empty field counts as the daemon's default; before, `cpu_warn` 85 with
`cpu_crit` left empty passed and made the daemon fall back to all defaults.
A threshold like `85abc` is refused. `interval` takes whole numbers of 5 and
more only. `fan_governor` is a list of the four governors the daemon accepts.
- **`fan_governor` only takes a kernel governor that regulates:**
`step_wise`, `fair_share`, `bang_bang` or `power_allocator`. Before, any
string went to the zone's `policy` on reset, `user_space` included, which
leaves nothing regulating the fan. Anything else now means `step_wise` and is
logged once.
- **Syslog lines carry a priority** instead of the default `user.notice`:
`daemon.crit` for stages, restored stages, a stage that cannot be persisted
and failed stage actions (also a modem or interface that does not come back
after a reset), `daemon.warning` for rejected options, missing tools, a
sensor that does not read and trips that could not be written,
`daemon.notice` for cooled down, reset, `trips-keep` and new firmware trips
that need a decision, `daemon.info` for the rest. The `blind:` message for a
CPU sensor that stops reading ran through the stage 0 path and would have
been `crit`; it is a `warning` and is still notified. The event log format
is unchanged.
- **netifd's answers are read with `jsonfilter`** instead of `sed` and `awk` on
the text of `ubus` output, which depended on its indentation. Every `ubus
call` has a time limit of 5 s (`UBUS_TIMEOUT`): netifd answers late while
`wifi down` runs, and the readback must not hang on exactly what it checks.
A radio counts as enabled unless netifd reports it `disabled`; the Wi-Fi log
line counts only those. `jsonfilter` is now in `DEPENDS`; `base-files`
already pulls it into every image.
- **The configuration is read with one `config_load` per cycle** instead of
one `uci -q get` per option, about 40 processes every 20 s. The checks on
the values are unchanged.
- **The detected fan pwm output is logged once as `fan pwm output detected at
'...'`.** Before it read `fan pwm path '' unusable`, although nothing was
configured. A configured `pwm_path` that stopped working is still reported
as unusable, once per replacement.
### Fixed
- **Four help texts on the config page say what the service does** (English
and German): `fan_governor` applies only when no earlier control mode was
recorded; `hold_minutes` starts stage 2 of 3 when the temperature has not
dropped and stage 3 of 3 only while it still rises; the `zone_path` reason
names the fan's zone, which is no longer configured; `uplink_interface`
decides whether a message goes out or is queued, not whether the modem is
switched off, and is the default for the interface action.
- **README, hooks README and the comments in `/etc/config/thermal-guard`
describe what the code does**: which stage actions can keep a connection
off after a reboot, what a pwm reset writes back, when `test` shows the
escalation over time, what a missing hook means per hook, what
`fan_cooling_device_type` is used for, and that `fan_governor` applies only
when no earlier governor was recorded.
- **`thermal-guard test` reports the fan mode the daemon would use.** It took
`fan_mode` as configured, so `auto` without a cooling device or `pwm`
without `pwm_full` and `pwm_idle` claimed "forcing fan to full speed", while
the daemon leaves the fan alone in both cases. It now runs the same fan
detection, which only reads sysfs.
- **A `cpu_temp_path` set in its `/sys/devices` form no longer shows the
processor among the other sensors.** Paths were compared as written, and
the sensor loops walk the `/sys/class` links. Such a path is now resolved
and compared resolved; the default and detected paths are in class form and
cost nothing extra.
- **`... cannot send a header` is logged once per fetcher.** Without `curl` it
came with every message and with every retry of the outbox, two minutes
apart. It is said again when the header was empty in between or another
fetcher takes over, and forgotten once `curl` is there.
- **Trip messages are said once each.** They shared one memory, which every
successful trip write cleared, so `device tree trips are now ...` came back
after each change of the trips. Each message now has its own, cleared when
its cause is gone: the zone found, `trip_active` valid again, the firmware
trips recorded, a write that worked.
- **The 1.2.0 entry on notifications overstated the config file.** Only `curl`
reads `notify_url` and `notify_header` from a file; `uclient-fetch` and
`wget` take the address on their command line. Since this release such an
address is refused when it carries a query string or user info (see
Changed).
- **`at_lock` is checked where it is used, not only when the configuration
loads.** 1.2.0 said every UCI path is checked again at its use site; for
`at_lock` that was not so, `tg_at` handed it to `flock` as it was. Now a path
the guard refuses falls back to `/var/lock/modem-at.lock` there as well.
- **The processor no longer shows up among the other sensors under another
name.** It was left out only when its sensor was called `cpu_thermal`. A
processor zone with another type, or a hwmon input such as `coretemp`, was
listed a second time. Now its own sensor is skipped by path, and where it is
a thermal zone, the hwmon node the kernel mirrors it as is skipped by the
zone's type. The other inputs of the same chip stay listed.
- **A `modem-temp` hook stopped at its time limit gives no reading.** Its
output was used whatever the exit status, so a hook that printed a value
and then hung still counted. Now the output is dropped when `timeout`
reports the limit (124) or the hook died of a signal (above 128: the KILL
for a hook that ignored TERM, or a BusyBox `timeout`, which reports 143),
as the hooks README already said.
- **A reading missing at stage entry no longer counts as 0.** The reference
for "no drop" and "still climbing" was stored as 0 when that reading was
missing, and a state file from before the references existed loaded them as
0. Any later reading then counted as a rise, and stage 3 of 3 could start
without one. A missing reference now stays empty, the comparison counts only
channels with a reading and a reference, and the first reading after the gap
becomes the reference, so the channel compares again as "risen since it came
back".
- **`thermal-guard test` shows what the readings would trigger, and only on
stdout.** It ran one cycle, while every stage needs two readings in a row, so
it never showed a stage. What it could do was write a `cooled down` line
(notice) into syslog and the event log when a stage was active. Now it runs
two cycles at the same moment and prints each message it would send as
`would notify (<priority>): ...`; syslog, the event log and the outbox stay
untouched.
- **Without `ip`, messages were never delivered from stage 2 of 3 on.** The
uplink check found no route, so every message went to the outbox, and the
outbox waited for a route it could never see. Nothing was logged. Now a
missing `ip` means the uplink is unknown and the message goes out at once,
with `no ip command, cannot tell whether another uplink exists, sending at
once` logged once as a warning. A missing `flock` is logged once as well
(`no flock command, AT queries run without the shared lock`); the queries
still run.
- **A stage restored after a reboot reports an action that fails.** The
restore reapplied the stage actions without looking at the result, so a
modem that stayed on or Wi-Fi that stayed up went unreported. Now each
failure is logged as crit (`stage N action '...' failed after the reboot`),
and the one restore message, sent after the actions, names what failed. The
event log gets `restoring stage N after a reboot` before the actions start,
so a daemon that dies halfway leaves a trace.
- **Handing a pwm fan back is read back.** Reset wrote the saved mode and duty
cycle without checking them. Both are read back now and a mismatch is logged
as crit. In automatic mode the duty cycle is no longer written at all: the
chip sets it itself, the value from before the takeover means nothing there,
and some drivers refuse it.
- **The status page no longer says a board without a sensor takes no action.**
The note read "The service stays idle and takes no action", while the modem
thresholds keep working as long as the modem reports a temperature. It now
says that the processor is not watched and the modem thresholds still apply
(English and German).
- **`wifi_off` no longer counts as done when netifd manages no radio.** An
empty `network.wireless` status used to confirm the action: nothing was up,
because nothing was there, and the stage reported Wi-Fi off on a device whose
radios netifd does not see. Now the action fails (`netifd manages no Wi-Fi
radio, wifi_off switched nothing off`, crit) and is notified like any failed
action. Bringing Wi-Fi back after a reset still passes, there is nothing to
bring back. The daemon also warns once when it loads a configuration that
uses `wifi_off` on such a device, so the mistake shows before the box runs
hot; a ubus error there is left to the action.
- **`thermal-guard selftest` says why it stops** when it cannot create its
scratch directory, instead of ending with 1 and no word. The
`cooling-levels` case skips on a host without `hexdump` rather than failing.
- **Handing the zone back on reset is checked.** `tg_fan_release` wrote the
governor to the zone's `policy` without reading it back. A governor the
kernel was built without, such as `power_allocator` on the Filogic kernel,
was refused by the kernel, and the zone stayed on `user_space` with the fan
at full speed and nobody told. Now a governor missing from
`available_policies` becomes `step_wise` (logged as warning), and a zone that
still reports another policy afterwards is logged as crit.
- **A deleted option kept its last value until the daemon restarted.**
Removing `notify_url` went on sending notifications, removing a threshold
kept the old threshold. Every option now returns to its default within one
cycle.
- **A persisted stage could be lost to a power cut, and a failed persist went
unnoticed.** Writing `/etc/thermal-guard/state` on a stage change and removing
it on reset now end with `sync`. A persist that cannot be written, for example
on a full or read only overlay, is logged once (`could not persist stage N to
..., it will not survive a reboot`) instead of being treated as done. The
per cycle path writes only to `/var/run` and does not sync.
## 1.5.0
### Added
- **Wi-Fi temperatures with the proprietary MediaTek driver.** `mt_wifi`, used
by the MTK vendor images of the Banana Pi R3 Mini, registers no hwmon sensor,
so the status page showed no Wi-Fi readings although the LuCI overview did.
The extra sensors now also read `CurrentTemperature` from
`iwpriv <interface> stat` for `ra0`, `rax0` and the other `mt_wifi`
interfaces, only while they are up and bounded by `timeout`, the same way
ImmortalWrt's `autocore` does it. Shown as Wi-Fi 1, Wi-Fi 2, for display only.
## 1.4.0
Where the kernel drives the fan, thermal-guard can now lower its trip points
for a hot modem instead of competing with it. Package release `1.4.0-r2`,
deployed and checked on the GL-X3000 and both Banana Pi R3 Mini.
### Added
- **Trip management (`trip_boost`, `trip_active`, `trip_boost_offset`,
`trip_dt`).** With `trip_boost 'modem'` the daemon lowers the active trips of
the thermal zone that drives the fan by `(modem - offset) - cpu`, rounded up
to 5 K and capped at 40 K, so the kernel regulator spins the fan up for a hot
modem it cannot see. Trips only ever go below the baseline, `hot` and
`critical` are never written, a missing reading holds the trips and raising
them needs three cool readings in a row. Stopping the daemon leaves them where
they are. Nothing is written unless `trip_boost` or `trip_active` is set, so
boards with a kernel-driven fan such as the GL-X3000 keep their trips. New
module `/usr/share/thermal-guard/trips.sh`, selftest cases against a planted
sysfs tree.
- **`THERMAL_ROOT`** environment variable, default `/sys/class/thermal`, for
tests only. UCI cannot change it.
- **`/var/run/thermal-guard/modem-temp`**: one line `<degrees> <unix time>
<source>`, written atomically after every good modem reading. Other programs
read the value here instead of querying the modem over AT themselves. A
missing reading leaves the file alone, so its age shows the outage.
- Deployment findings for 1.2.0 on real hardware, one section per device,
taken into the development repository unchanged.
### Changed
- **AT queries have a time limit.** `timeout 10 flock <lock> timeout 5
sms_tool …`: the outer limit covers the wait for the lock, which BusyBox
`flock` cannot bound itself, the inner one the query. Before, a hung modem or
a program sitting on the lock stopped the whole loop, stages and trip
management included. A timeout counts as a missing reading. New dependency
`coreutils-timeout`; the selftest fails where `timeout` is missing.
- **Web interface.** System → Thermal Guard, tab Fan: `trip_boost`, the
modem allowance and the switching temperatures (`trip_active`, checked with
the same rules as the service), the state of the trip management, the
`cooling-levels` from the device tree with their duty cycle, and after an
image with other device tree trips a notice with "use the firmware values" and
"keep my values". The status page shows the current trips and by how much
they are lowered. 31 new strings, German catalogue complete.
- Status file: `trip_state`, `trip_reason` (a fixed code the page translates),
`trip_zone`, `trip_base`, `trip_now`, `trip_dt`, `trip_delta`,
`trip_dt_changed`, `trip_limit`, `fan_levels`.
- Docs: README covers trip management, `modem-temp` and the dependency.
The internal deployment documents follow the new layout of the build
repositories.
- `luci-app-thermal-guard` 1.4.0, same number as the daemon.
- **`/lib/upgrade/keep.d/thermal-guard`** keeps `/etc/thermal-guard/` across a
`sysupgrade`. Before, only `/etc/config/thermal-guard` survived: the hooks
an operator placed over ssh, `matrix.env` for `matrix-notify`, and the
persisted stages were lost with every flash. Stages persisted at the time of
a flash now come back after it, the same as after a reboot; clear them before
flashing if the cause is fixed.
- The shipped configuration leaves `trip_boost` unset, which means off. An
image can then switch it on for its board at first boot with a uci-defaults
script that only sets missing options, without overriding an own setting.
- Integration tests for trip management, the hung modem and the held lock run
the daemon against a planted sysfs tree. The suite now takes about two
minutes.
- README: why this package exists, and screenshots of the status page, the
overview block and the configuration tabs on a Banana Pi R3 Mini and a
GL-X3000, in `screenshots/`.
- CI installs shellcheck 0.11.0 from its release, checked against a pinned
SHA256, instead of the Ubuntu package. Ubuntu 24.04 ships 0.9.0, which does
not know the busybox dialect, so the lint job failed on its first run on
GitHub without looking at a single line.
### Fixed
- **A package upgrade left the old daemon running.** procd keeps an instance
whose command line did not change, and `thermal-guard daemon` never does, so
after `apk add` the previous version went on running while `thermal-guard -V`
already showed the new one. Found on the GL-X3000 with 1.4.0-r1. The package
now restarts the service after an upgrade on a running system, only if it was
running. Package release `1.4.0-r2`.
## 1.3.2
### Fixed
- **`thermal-guard selftest` failed on boards with a fan chip.** The case
"nothing to read, nothing shown" assumed no hwmon fan exists; on the GL-X3000
the real `pwm-fan` is found by name and read, which is correct. The case now
runs only where no fan chip is present.
## 1.3.1
### Fixed
- **`thermal-guard selftest` worked in the live state directory.** It saved
test stages into `/var/run/thermal-guard/state` under a running daemon, which
then saw a changed `GEN` and dropped its decision ("reset during this
cycle"), placed test hooks there, and wrote its test messages to syslog. The
1.3.0 cases also emptied the event log, the incident history the status page
shows. The deployment runbook runs the selftest on every board. It now works
in a temporary directory and logs nothing. Found on the GL-MT300N-V2 right
after installing 1.3.0.
## 1.3.0
The daemon no longer touches a fan it has not been told about. Found in the
first buildroot build: a package install starts the service at once, and on a
board without a configuration the shipped `auto` could resolve to `pwm` with an
assumed polarity of `255`/`0`, which on an inverted board stops the fan in the
stage meant to force it.
### Changed
- **`fan_mode` defaults to `none`**, in the shipped configuration and in the
daemon when the option is missing. Most boards with a fan already regulate it,
and this package is a protection layer, not a regulator.
- **`pwm_full` and `pwm_idle` have no default.** `fan_mode=pwm` needs both; with
either missing the daemon logs it once and leaves the fan alone. The
configuration page requires both when `pwm` is selected.
- **`auto` never resolves to `pwm`**, only to `cooling_device` or `none`. The
cooling device carries its polarity in the device tree; a raw pwm output
needs it from the operator.
- An upgrade keeps the existing configuration. Affected is only a board that set
`auto` without a cooling device, or `pwm` without both values; the deployment
runbook lists the cases.
### Added
- **Stage actions are read back instead of assumed.** `modem_radio_off` asks the
modem `AT+CFUN?` afterwards and resends up to three times, because a
collision on the AT port swallows a command without an error. A modem that
does not confirm fails the stage, which is notified. Switching it back on at
a reset is checked the same way.
- **`modem_radio_off_at`**: `AT+CFUN=4` (flight mode, default) or `AT+CFUN=0`
(minimum function) for a modem that does not take 4. Nothing else is
accepted, because the configuration page can write every option and a free
AT command would let it reset or disable the modem.
- **`interface_down` as a stage action.** Takes `action_interface` down, or
`uplink_interface` when that is unset, and waits for netifd to report it
down. A reset brings it back up. Interface names are checked as UCI section
names, in the daemon and on the page, since they reach `ifdown` and a ubus
path; `uplink_interface` is now checked the same way.
- **The fan is shown even where another program drives it.** `fan_mode` says
what the daemon may write, the new `fan_view` in the status file what it
reads. Under `none` it reads the raw pwm output where `pwm_full` and
`pwm_idle` are set, otherwise the cooling device, otherwise the raw output
without a percentage. The raw output comes first because the kernel derives
a cooling device's `cur_state` assuming ascending `cooling-levels`; the Banana
Pi R3 Mini's descend (`<255 96 0>`), so full speed reads as state 0. The status
page and the overview show the fan whenever `fan_view` names something.
### Fixed
- **A reset only undid `modem_radio_off` at stage 2 of 3 and `wifi_off` at
stage 3**, so either action configured for the other stage stayed applied
after a clear. Each stage's configured action is now reversed.
- **The overview block showed a pwm fan as "fan step 140 of 1"** on an inverted
board, because it used the raw value against `pwm_full` as the maximum. It
now shows the percentage the daemon computes, and `fan_max` is only reported
for pwm when both polarity values are set.
- **Stage 1 of 3 promised a fan at full speed under `fan_mode=none`**, in the
notification, on the status page and in two descriptions on the configuration
page, and logged "could not force the fan" as if the stage had failed. It now
says the fan is left to its own controller and logs nothing about it.
### Translations
- German: new `Leave the fan alone` (replaces `No fan`) and `Stage 1 of 3 active:
warning, fan left to its own controller`; reworded the descriptions of the fan
mode, `pwm_full`, `pwm_idle`, `cpu_warn` and the Actions tab.
- German: `Switch the modem radio off` (replaces `Switch the modem off
(AT+CFUN=4)`, since the level is now configurable), and new `Take a network
interface down`, `network interface down`, `How the modem is switched off`
with its description, `Flight mode (AT+CFUN=4)`, `Minimum function
(AT+CFUN=0)`, `Interface to take down` with its description.
## 1.2.0
Hardening pass over the whole package, plus the corrections that came out of
testing on a Banana Pi R3 Mini, a GL-X3000 and a GL-MT300N-V2.
### Fixed
- **One-shot commands narrated the configuration into the event log.** Every
entry point runs the same validation, so `status`, `reset` and `test` each
repeated whatever it found: three `thermal-guard status` calls on a board
without a sensor produced three identical lines. The event log keeps the last
50 entries, so a monitoring check calling `status` in a loop flushed the
record of an actual incident out of it within the hour. Only the daemon
reports these now. Found during deployment on a GL-MT300N-V2.
- **A reset could be undone by the cycle it interrupted.** Reading the modem
over AT takes seconds, and a reset landing in that window was overwritten
when the cycle saved. In the worst shape the fan was forced again right after
being handed back, leaving it at full speed while the state said idle, with
no way to clear it: the release was guarded by `stage >= 0`. Resets are now
counted in `GEN` and the count is checked before acting and before saving,
and `thermal-guard reset` releases the fan unconditionally.
- **Turning `enabled` off removed the guard instead of pausing it.**
`start_service` registered no instance, so stages stayed applied, the fan
stayed forced and the status file froze with its last values while the page
kept showing an active stage. The instance now always runs and the daemon
pauses itself.
- **The fan was addressed by hwmon number.** `hwmon2` today is `hwmon1` after
an NVMe moves or a kernel changes, and the daemon would then write a duty
cycle to a different chip and report success. The fan chip is looked up by
its hwmon name.
- **`tg_fan_release` did nothing in pwm mode** unless `fan_release_command` was
set, so the fan stayed on manual full speed after a reset.
- **A failing stage action reported success.** `tg_run_action` ended in an
unconditional `return 0`, so a modem that could not be reached still counted
as switched off. It now returns what the action returned, an unknown action
name is logged, and a failed stage is notified.
- **`tg_cfg` skipped path validation and sensor detection** entirely when `uci`
was absent.
- CI checked `70_thermal-guard.js`, which was renamed to `11_` some releases
ago, so the `luci-syntax` job had been failing on a missing file.
- The rpcd ACL named the status file and the reset command but not the ubus
`file` object they are reached through, nor the `/tmp/run` path that `/var`
resolves to. Both reads and the clear button were denied for every session
that was not root.
- **Installing `luci-app-thermal-guard` as a package did not load its ACL**
(`1.2.0-r2`). The package's own `postinst` replaced the one `luci.mk`
provides, which is the one that reloads rpcd, so the pages were refused until
rpcd restarted. The own `postinst` is gone; the default drops the menu caches
as well. Found in the first buildroot build of either package.
### Added
- **Protection stages survive a reboot.** A stage change is mirrored to
`/etc/thermal-guard/state` and reapplied at startup. The state used to live
only in `/var/run`, which is tmpfs, so any reboot silently cleared the
stages and the box came back with the modem enabled and the same broken fan.
- **`pwm_idle`**, the raw value meaning slowest, as the counterpart to
`pwm_full`. The pair carries the polarity, which several boards run inverted
(`1` full speed, `255` stopped). The fan percentage on the status page is
computed from it instead of assuming a rising scale.
- **A SoC sensor is looked for** when `cpu_temp_path` does not read, covering
ramips and x86 boards that report through hwmon rather than a thermal zone.
- **"This device reports no temperature at all"** as a state of its own, told
apart from a wrong path and shown on the status page. Some boards, the
GL-MT300N-V2 among them, expose no sensor whatsoever.
- **A second fan controller is detected.** When a value the daemon wrote has
been replaced, it logs which `fan_mode` avoids the fight, reasserts and
reports it to the page.
- The status file carries `now`, `interval`, `enabled`, `no_sensor`,
`fan_percent`, `fan_foreign` and `cpu_src`, which lets the page tell running,
paused and stopped apart instead of showing a stale snapshot as current.
- `tests/run-integration.sh`, exercising reset, the reboot restore, a garbled
state file and the collectd filter through the real scripts.
### Changed
- **Notifications go out over HTTP.** `notify_url` is posted to with `curl`
where present and `uclient-fetch` otherwise; `notify_header` carries an
authorisation token where `curl` is installed. Both go through a config file
rather than the command line, because `/proc/*/cmdline` is readable by every
local process, and both are rejected if they contain anything that could
turn that config file into further directives.
- **Custom code lives in `/etc/thermal-guard/hooks/`** as executables, not in
UCI as strings. The rpcd ACL grants write access to every option in this
configuration, so a shell command stored there was a way for anyone who
could reach the configuration page to run code as root. `sh -c` no longer
appears in the package at all.
- Paths taken from UCI are checked against the subtree they belong to, in
`tg_cfg` and again at each use site, and the config view validates the same
prefixes.
- `thermal-guard-collectd` passes status values through an integer filter
before building a `PUTVAL` line.
- A cooling device that no thermal zone binds is usable; the zone is only
needed to take the device away from a governor. `cooling_device` is preferred
where available because `max_state` means maximum cooling whichever way the
device tree runs its `cooling-levels`, so that mode cannot get the polarity
wrong.
- Reload is a no-op. `tg_cfg` runs every cycle, so a changed option is live
within one interval, and restarting would drop a stage action in flight.
- The stage banner names the configured action instead of the default, so a
box with `stage1_action=wifi_off` no longer claims the modem was switched
off.
- German catalogue regenerated: 124 entries, 34 of them new, 11 stale ones
removed.
### Documented
- **What this package is for.** It is a protection layer, not a fan controller,
and where a board already has one `fan_mode=none` is the normal setting
rather than a workaround. The built-in fan handling is for boards with
nothing else.
- **Sharing the modem's AT port.** Overlapping AT queries return nothing
useful and neither side reports a fault. Deploying next to a fan controller
that queried the modem without a lock garbled that controller's reading,
collapsed its control temperature and left the fan at its minimum on a board
at 68 °C. The symptom was at the fan, the cause at the serial port.
- **`fan_mode=pwm` is not a regulator**, now a warning in the deployment
runbook. Below the thresholds it writes nothing to the fan. Pausing the Banana
Pi's `fan-control` in favour of it left `pwm1` at a stale minimum while the
SoC climbed from 58 to 68 °C.
- **Deployment runbook, gaps found on hardware.** Check for an installed
package (`apk info -e` / `opkg status`) and remove it through the package
manager before deleting files by hand; the Banana Pi carried 1.1.0 as apk
packages. The migration now lists the options this repository removed in
1.2.0 (`notify_command` and the other `*_command` options) with their
replacements, not only the BananaWRT variant's. `pwm_idle='255'` is set on
the Banana Pi, and the runbook separates what a missing value does:
`fan_percent` pinned at 100 % under `fan_mode=pwm`, empty under
`fan_mode=none`.
- **Whether there is a kernel backstop at all.** Preferring `cooling_device`
used to be justified by the kernel continuing to regulate while the guard is
idle. That only holds where a thermal zone actually binds the cooling device,
which several boards do not do, and the README now says how to check.
### Removed
- `notify_command`, `stage1_command`, `stage2_command`, `modem_temp_command`
and `fan_release_command`. The first is replaced by `notify_url`, the next
three by hooks of the same name, and the last by recording what the pwm chip
was set to before the takeover and writing it back on release.
## 1.1.0
- Configuration page under System, clear button on the status page
- Other board temperature sensors reported alongside SoC and modem
- Fan mode no longer reset to `auto` on every cycle
## 1.0.0
- Staged overheating protection: force the fan, modem radio off, Wi-Fi off
- Status page, overview block and collectd feed
-338
View File
@@ -1,338 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
-458
View File
@@ -1,458 +0,0 @@
# thermal-guard
Overheating protection for OpenWrt routers with staged mitigation.
![Status page on a Banana Pi R3 Mini](screenshots/status-bpi-r3-mini.png)
## Why this exists
The idea for this project came after a fan malfunction on my Banana Pi R3 Mini
damaged its Fibocom FM350-GL 5G module beyond repair. An inexpensive part
failed and took a much pricier one with it. Since one of my BPI R3 Minis runs
in a motorhome, I did not want to go through that again and set out to build
something that prevents it. This package is the result.
A better fan controller would not have helped: the one on the board was
convinced it had the fan at full speed. What was missing was something that
ignores what the fan is supposed to be doing and only looks at the temperature.
**This is not a fan controller.** OpenWrt has several of those and they do the
job better: they map temperature onto a curve and keep the box in its normal
band. This one does nothing at all until a threshold is crossed, and then does
something drastic that does not undo itself. Thermostat and thermal fuse, two
layers rather than two opinions.
So where a fan controller is already installed, leave it in charge: `fan_mode`
is `none` by default, and that is the regular configuration for such a board,
not a workaround. The built-in fan handling exists for boards that have nothing else,
and "force it to maximum" is a poor substitute for a curve. What this package
adds is the layer above: the one that assumes the controller can fail, or can
be wrong, and acts on the temperature rather than on what the fan reports.
A fan that stops is rarely noticed until something dies. `thermal-guard` watches
SoC and modem temperature and, when the box runs hot, escalates in three stages:
| Stage | Default action | Triggered by |
|---|---|---|
| 1 of 3 | Warning; fan forced to full speed where `fan_mode` lets the daemon drive it | `cpu_warn` or `modem_warn` reached |
| 2 of 3 | Modem radio off (`AT+CFUN=4`) | `cpu_crit`/`modem_crit`, or stage 1 held for `hold_minutes` without any drop |
| 3 of 3 | Wi-Fi off | `cpu_emergency`, or stage 2 held for `hold_minutes` while still climbing |
The web interface counts stages from one, the daemon and the status file count
them from zero, so stage 1 of 3 is `stage=0` in `thermal-guard status`. What
stages 2 and 3 do is configurable; the table shows the defaults. Each built in
action is read back rather than assumed: the modem is asked for its state
(`AT+CFUN?`), `interface_down` waits for netifd to report the interface down,
`wifi_off` waits for netifd to report no enabled radio up. An action that does not
confirm counts as failed, and the failure is notified. A `command` hook counts
by its exit code.
Three design decisions worth knowing before you deploy it:
* **Stages never roll back on their own.** Cooling down is reported once, the
stage stays. A fan that failed once will fail again, and a silent recovery
hides the fault. Clearing is an operator decision: `thermal-guard reset`.
* **A reboot does not clear them either.** A stage change is mirrored to
`/etc/thermal-guard/state`, and the daemon reapplies it at startup. The box
did not get a new fan while it was down, so coming back up with the modem
enabled would just repeat the incident. The restore is logged and notified.
* **A crash leaves the fan running.** The daemon only hands the fan back on an
explicit reset, so dying mid-incident is the safe direction.
If a restored stage ever locks you out, reach the box over a wired port or
serial and run `thermal-guard reset`. Only a device that reached a stage whose
action is `wifi_off` or `interface_down` (stage 3 of 3 with the defaults) comes
back from a reboot with that connection still off, and such a device has a fan
and a modem, so it has wired ports too.
Every transition needs two consecutive readings above the threshold, which keeps
sensor glitches from triggering anything. A missing reading never blocks an
escalation: at a threshold it simply does not count, on the hold path it counts
as no drop, so it never delays stage 2 of 3, and a reference missing at stage
entry is taken from the first reading after the gap.
Escalating from stage 1 of 3 on time alone, when the temperature has not dropped, is
the part that earns its keep. The guard never asks whether the fan turned; it
asks whether the box got cooler. That covers a fan that failed, a fan cable
that came off, and the case that prompted this package: a fan controller whose
pwm polarity was configured the wrong way round, which stopped the fan while
reporting that it had gone to full speed. No software can tell those apart on
a board without a tachometer, and none of them have to be told apart, because
the answer is the same. Set `hold_minutes` with that in mind: it is how long a
stage is given to prove it worked before the next one starts.
## Screenshots
The status page on two boards: a Banana Pi R3 Mini with a Fibocom modem, an NVMe
drive and the MediaTek vendor Wi-Fi driver, where another program drives the fan,
and a GL-iNet GL-X3000 with a Quectel modem, where the kernel does. Wi-Fi 1 and 2
come from `iwpriv` on the one and from hwmon on the other.
<table>
<tr>
<td><img src="screenshots/status-bpi-r3-mini.png" alt="Status page, Banana Pi R3 Mini"></td>
<td><img src="screenshots/status-gl-x3000.png" alt="Status page, GL-X3000"></td>
</tr>
</table>
The same readings in a block on the LuCI overview page:
<table>
<tr>
<td><img src="screenshots/overview-bpi-r3-mini.png" alt="Overview block, Banana Pi R3 Mini"></td>
<td><img src="screenshots/overview-gl-x3000.png" alt="Overview block, GL-X3000"></td>
</tr>
</table>
<details>
<summary>Configuration page, System → Thermal Guard</summary>
General
![General tab](screenshots/settings-general.png)
Thresholds for the three stages
![Thresholds tab](screenshots/settings-thresholds.png)
Fan, including the switching temperatures of a kernel-driven fan
![Fan tab](screenshots/settings-fan.png)
Modem, Fibocom on the Banana Pi and Quectel on the GL-X3000
![Modem tab, Fibocom](screenshots/settings-modem-fibocom.png)
![Modem tab, Quectel](screenshots/settings-modem-quectel.png)
Actions and notifications
![Actions tab](screenshots/settings-actions.png)
</details>
## Install
```sh
opkg install thermal-guard luci-app-thermal-guard # or: apk add ...
/etc/init.d/thermal-guard enable
/etc/init.d/thermal-guard start
```
Two LuCI pages: **Status → Thermal Guard** shows the temperatures, the fan and
the current stage, and clears the stages; **System → Thermal Guard** edits the
configuration. A changed option is picked up within one interval, and a
deleted one is back at its default by then, so Apply needs no restart.
The package depends on `coreutils-timeout`: every AT query runs under a time
limit, so a hung modem costs one reading instead of the whole loop. It also
depends on `jsonfilter` to read netifd's answers; `base-files` ships it on every
image anyway. Optional runtime helpers, none of them a hard dependency:
`sms_tool` for the modem AT commands, `flock` for the AT lock, `ip` for the
uplink check, and `curl` for notification headers. Without `flock` the AT
queries run without the lock; without `ip` the daemon cannot tell whether a
route exists and sends every message at once instead of queueing it. Each is
logged once as a warning.
## Configuration
`/etc/config/thermal-guard`, section `main`. Defaults leave the fan alone and
read a Quectel or Fibocom modem; driving the fan is an explicit choice.
| Option | Default | Meaning |
|---|---|---|
| `enabled` | `1` | Pauses the checks when `0`; stages and the fan stay until a reset |
| `interval` | `20` | Seconds between readings, minimum 5 |
| `cpu_warn` / `cpu_crit` / `cpu_emergency` | `70` / `80` / `90` | SoC thresholds in °C, must be ordered |
| `modem_warn` / `modem_crit` | `80` / `88` | Modem thresholds in °C |
| `hold_minutes` | `3` | How long a stage must persist before time alone escalates |
| `hysteresis` | `10` | Degrees below the warn thresholds that count as cooled down |
| `extra_sensors` | `1` | `0` hides the board's other sensors (Wi-Fi, ethernet phys, NVMe) from the status page |
| `cpu_temp_path` | `/sys/class/thermal/thermal_zone0/temp` | Sysfs source under `/sys/class/thermal/` or `/sys/class/hwmon/`, degrees or millidegrees. A sensor is looked for when this does not read |
| `fan_mode` | `none` | `cooling_device`, `pwm`, `auto`. `auto` takes the cooling device if there is one, otherwise it stays `none`; it never picks `pwm` |
| `fan_cooling_device_type` | `pwm-fan` | Type string used to find the cooling device |
| `fan_governor` | `step_wise` | Governor the zone is handed back to on reset when no earlier one was saved: `step_wise`, `fair_share`, `bang_bang` or `power_allocator`. Anything else, `user_space` included, means `step_wise`, and so does a governor the kernel was built without |
| `pwm_path`, `pwm_enable_path` | unset | `pwm` mode only; found by hwmon name when left empty |
| `pwm_full`, `pwm_idle` | unset | Raw values for fastest and slowest, both required for `pwm`. Without both the fan is left alone |
| `modem_source` | `auto` | `quectel`, `fibocom`, `file`, `command`, `none` |
| `modem_at_port` | `auto` | Serial port for AT commands |
| `modem_temp_file` | unset | Source for `file` mode; `command` mode reads the `modem-temp` hook |
| `at_lock` | `/var/lock/modem-at.lock` | Shared lock. Every other AT user on this port has to take the same one, see below |
| `trip_boost` | `off` | `modem` lowers the fan's trip points while the modem runs hotter than the CPU, see below |
| `trip_boost_offset` | `15` | Degrees the modem may run above the CPU before the trips go down, 0 to 40 |
| `trip_active` | unset | List, baseline of the fan's active trips in °C, ascending, one per trip. Unset means the device tree values |
| `stage1_action` / `stage2_action` | `modem_radio_off` / `wifi_off` | Also `interface_down`, `command`, which runs a hook, or `none` |
| `modem_radio_off_at` | `AT+CFUN=4` | Or `AT+CFUN=0` for a modem without flight mode. Nothing else is accepted |
| `action_interface` | unset | Interface `interface_down` takes down; `uplink_interface` when unset |
| `notify_url` | unset | The warning text is posted here |
| `notify_header` | unset | Extra header for the post, needs `curl` |
| `uplink_interface` | unset | Modem interface name, lets the daemon tell whether another uplink exists |
### Fan modes
`cooling_device` sets the cooling device to its maximum step, taking the thermal
zone off its governor first if one drives it. Prefer this mode wherever the
board offers it: `max_state` means maximum cooling whichever way the device tree
runs its `cooling-levels`, so this mode cannot get the polarity wrong.
On reset the zone gets back the governor it had before, or `fan_governor` when
none was recorded. A governor the running kernel does not list in the zone's
`available_policies` is replaced by `step_wise`. The policy is read back
afterwards: a zone that still reports another one is logged as `crit`, since
then nothing regulates the fan.
A cooling device that no thermal zone binds still works, there is simply nothing
to take over and nothing to hand back. Whether one is bound is worth knowing,
because it decides whether there is a kernel backstop underneath at all:
```sh
ls /sys/class/thermal/thermal_zone*/cdev* 2>/dev/null || echo "no zone drives any cooling device"
```
Where nothing is printed, the device tree defines no `cooling-maps`, the zone's
trip points fire into nothing, and the only regulation on the board is whatever
runs in userspace. That is worth fixing in the device tree, because it is the
difference between a fan controller that can fail safely and one whose failure
nobody catches until this package escalates.
`pwm` writes the duty cycle to sysfs directly. Use it on boards where the fan is
not wired into a thermal zone. Before taking the chip over, the daemon records
its mode and duty cycle. Reset writes the mode back, and the duty cycle where
the chip was in manual mode; in automatic mode the chip sets it itself. Nothing
has to be configured for the way out. Leaving `pwm_path` empty is fine: the fan chip is
looked up by its hwmon name, which survives the renumbering that happens when
another sensor appears.
Polarity is not something the daemon can work out, and getting it wrong stops
the fan in an emergency instead of speeding it up. Several boards, the Banana Pi
R3 Mini among them, run the signal inverted: `1` is full speed and `255` stops
the fan. So there is no default: `pwm` needs both `pwm_full` and `pwm_idle`, and
with either missing the daemon logs it once and leaves the fan alone. For the
same reason `auto` never resolves to `pwm`. Confirm the values by ear rather
than by reading the number.
If another service already controls the fan, keep `fan_mode` at `none`. Stage
1 of 3 is then a warning only. Setting a fan mode anyway works: the daemon notices when a value it wrote has been replaced, says so in the log and
reasserts, but two controllers writing one file is worth avoiding, and the
other one almost certainly has the better curve. Nothing is lost: the stage
that forces the fan is the one a working controller has already covered, and
the stages that matter come after it.
### Trip management
Where the kernel drives the fan through a thermal zone, it only compares the
CPU against the zone's trip points; a modem running hotter than the CPU does not
reach it. With `trip_boost 'modem'` the daemon lowers the zone's active trips by
```
delta = (modem - trip_boost_offset) - cpu, 0 if negative,
rounded up to 5 K, at most 40 K
```
which is the same as regulating on `max(cpu, modem - offset)`, but leaves the
regulator in the kernel. The zone is the one whose `cdevN` points at the fan's
cooling device, the trips are the `active` ones bound to it, whatever their
numbers are. `hot` and `critical` are never written.
The trips only ever go below the baseline, never above it. A wrong modem reading
can therefore only make the fan louder, not slower. A missing reading keeps the
trips where they are, raising them again takes three cool readings in a row, and
not at all while a stage is in force. Stopping the daemon leaves them as they
are; setting `trip_boost 'off'` with `trip_active` unset hands the zone its
device tree values back once and leaves it alone from then on.
Nothing is written unless `trip_boost` or `trip_active` is set, so a board whose
fan the kernel already drives keeps its trips as they are.
A new firmware image can bring other trips in its device tree. The first cycle
with `trip_active` set records the device tree values of that boot in
`/etc/thermal-guard/trip_base`, which survives a sysupgrade. When a later boot
finds other values, the log says so, the status file carries
`trip_dt_changed=1`, and **System → Thermal Guard** offers to use the new values
or to keep your own. `trip_active` stays in force until you decide. Both
choices run `thermal-guard trips-keep`, which records the values of the running
boot. Without `trip_active` the file is neither read nor written.
### Sharing the modem with another program
A modem answers two overlapping AT conversations with nothing useful, and the
program that gets the nothing rarely says so. This daemon takes `at_lock` around
every query. **Anything else on the box that talks to the same port has to take
the same lock**, or both readings become unreliable. ModemManager cannot, see
the known limitations below.
That is not a theoretical concern. On a Banana Pi R3 Mini this package was
installed next to a fan controller that queried the modem without a lock. The
collisions garbled the controller's modem reading, its control temperature
collapsed, and it set the fan to its minimum while the board sat at 68 °C.
Nothing in either program reported a fault: the fan controller believed it was
regulating correctly, and this daemon believed the fan was somebody else's
business. The symptom appeared at the fan, the cause was at the serial port.
If the other program cannot be changed, give it the modem and set
`modem_source` to `none` here, but understand the cost. A modem die runs
appreciably hotter than the SoC, commonly by 10 to 20 K, so protecting it
through the processor thresholds alone means acting late for the part that is
actually at risk. Prefer fixing the lock.
Programs that only need the temperature do not have to ask the modem at all.
After every good reading the daemon writes one line to
`/var/run/thermal-guard/modem-temp`:
```
47 1790150400 at
```
degrees, Unix time of the reading, source (`at`, `file` or `command`). The file
is replaced atomically and not touched when a reading fails, so a reader that
finds it older than three intervals should treat the value as unknown.
### Notifications
Set `notify_url` and the message is posted there as the request body. That
covers ntfy, Gotify, Matrix, Slack and most other things you would want to be
woken by, and it needs no shell command in the configuration to do it.
```
option notify_url 'https://ntfy.sh/my-topic'
```
`curl` is used when it is installed, otherwise `uclient-fetch` from the OpenWrt
base, or `wget`. Only `curl` can send `notify_header`, and only `curl` reads the
address and the header from a file (mode 600) instead of the command line, where
`/proc` shows them to every local process.
Without `curl` the daemon therefore refuses an address that carries a query
string or user info (`?token=...`, `user:pass@host`). It logs the reason once
and sends nothing over HTTP; the notify hook below still works. A secret in the
path of the address, as in Slack or Discord webhooks or a private ntfy topic,
cannot be told apart from an ordinary path and is sent as it is. Without `curl`
it is visible in the process list while the message goes out. Install `curl`
for such services.
For a delivery HTTP cannot express, put an executable at
`/etc/thermal-guard/hooks/notify`; it gets the text on stdin. With both set,
the URL is tried first and the hook is the fallback.
From stage 2 of 3 on the daemon checks whether a route exists that does not run
through the modem; if not, the message is queued and retried, because that
stage is about to switch the modem off.
### Hooks
`/etc/thermal-guard/hooks/` holds executables the daemon runs for `notify`,
for `stage1` and `stage2` when the action is `command`, and for `modem-temp`
when `modem_source` is `command`. See the `README` installed in that directory.
The whole of `/etc/thermal-guard/` is listed in `/lib/upgrade/keep.d/`, so
hooks and any files they need, persisted stages and `trip_base` survive a
`sysupgrade`.
Every hook runs under a time limit: 15 s for `notify`, `stage1` and `stage2`,
5 s for `modem-temp`, which runs every cycle. A hook still running then gets
TERM, and KILL 5 s later, and counts as failed.
They are files rather than options because the rpcd ACL that lets the web
interface edit this configuration does not grant write or execute access to
that directory. Custom code therefore has to be put there by someone with
shell access, and editing the configuration cannot introduce any.
## Commands
```sh
thermal-guard status # current stage, readings, fan state
thermal-guard reset # clear all stages, hand the fan back, radio on
thermal-guard trips-keep # record this boot's device tree trips in trip_base
thermal-guard test 85 70 # two cycles with these readings, print what they would trigger, change nothing
thermal-guard selftest # run the decision logic against its test cases
```
`test` runs its two cycles at the same moment, because every stage needs two
readings in a row. The hold time does not pass in between: from an idle state,
stage 3 of 3 shows only through `cpu_emergency`, and the escalation over time
cannot be tried this way. `test 75 66` staying at stage 1 of 3 says nothing
about it. On a box already in a stage, its hold timers apply.
`/var/run/thermal-guard.status` holds a machine readable snapshot. Everything
else reads that file, so only the daemon talks to the modem, and `thermal-guard
reset`, which switches the radio back on under `at_lock`.
## Where the readings show up
* **Status -> Thermal Guard** is the monitor page: temperatures, fan state, stage.
* **Status -> Overview** gets a short temperature block, installed with the LuCI app.
* **Syslog**, tag `thermal-guard`, facility `daemon`: `crit` for a stage, a
restored stage, a stage that cannot be persisted and every stage action that
failed, the way back after a reset included; `warning` for a rejected option,
a missing tool, a sensor that does not read and trips that could not be
written; `notice` for cooled down, reset, `trips-keep` and new firmware trips
that need a decision; `info` for the rest. `logread -e thermal-guard` shows
them all, a remote syslog can filter on the priority. The event log in
`/var/run/thermal-guard/log` keeps the same lines without it.
* **Other sensors** of the board (`extra_sensors`, on by default): every hwmon and
thermal zone temperature besides the processor, such as Wi-Fi chips, ethernet
phys and NVMe drives. The proprietary MediaTek Wi-Fi driver `mt_wifi` exposes
none of those; its radios are read with `iwpriv <interface> stat` instead, the
way ImmortalWrt's own overview does it, and only while the interface is up.
These readings are shown only, they never trigger a stage.
* **RRD graphs** through collectd. The package ships an exec plugin feed, enable it
in `/etc/collectd.conf`:
```
LoadPlugin exec
<Plugin exec>
Exec "nobody:nogroup" "/usr/libexec/thermal-guard-collectd"
</Plugin>
```
Needs `collectd-mod-exec` and `collectd-mod-rrdtool`. It emits
`temperature-soc`, `temperature-modem`, `gauge-fan_step` and `gauge-stage`
under the plugin name `thermal_guard`.
## Hardware
Developed against two boards, which is where the two fan modes come from:
| Board | Fan | Modem |
|---|---|---|
| GL.iNet GL-X3000 (MT7981) | kernel thermal zone, `cooling_device` | Quectel RM520N-GL |
| Banana Pi R3 Mini (MT7986) | sysfs PWM, `pwm` | Fibocom FM350-GL |
Nothing in the package is board specific, any OpenWrt device with a readable
thermal zone should work.
## Known limitations
**ModemManager does not know `at_lock`.** It keeps the modem's AT ports open
and sends its own commands without taking the lock this daemon uses. The daemon
cannot detect that. Where ModemManager manages the modem, read the temperature
another way (`modem_source` `file` or `command`) or set it to `none`; the
section on sharing the modem says what `none` costs.
**Without `curl`, notifications are limited.** `uclient-fetch` and `wget` cannot
send `notify_header`. The message goes out without it, a webhook that needs it
refuses the message, and it stays in the outbox, retried every two minutes.
Both tools also take the address on their command line, where `/proc` shows it
to every local process while the message goes out. An address with a query
string or user info is refused for that reason and logged once; a secret in the
path of the address cannot be told apart and is sent as it is.
**A board that reports no temperature gets no processor protection.** When
neither a thermal zone nor a hwmon sensor can be read, the daemon logs `this
device reports no temperature at all, nothing to watch here` once and the
status page says so. No stage can start from the processor side. The modem
thresholds still apply as long as the modem gives a reading.
**`wifi_off` sees only what netifd manages.** The action runs `wifi down` and
confirms it through `ubus call network.wireless status`. Radios that netifd
does not manage are neither switched by `wifi` nor seen by the check. When
netifd lists no radio, the action fails and is notified, and the daemon warns
once when it loads a configuration that uses `wifi_off`. When netifd does not
answer, the action fails as well.
**Two cooling devices of the same type cannot be told apart.** The daemon
takes the first one of `fan_cooling_device_type` whose `max_state` is above 0.
If your board has such a setup, please report it.
## License
GPL-2.0-only. The escalation logic started out as a device specific script for
the two boards above and was generalised for this package.
@@ -1,26 +0,0 @@
#
# 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
@@ -1,113 +0,0 @@
// 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])
]);
}));
}
});
@@ -1,431 +0,0 @@
// 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();
}
});
@@ -1,298 +0,0 @@
// 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
});
@@ -1,509 +0,0 @@
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 %%)"
@@ -1,504 +0,0 @@
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 ""
@@ -1,26 +0,0 @@
{
"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 }
}
}
}
@@ -1,25 +0,0 @@
{
"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" ]
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

@@ -1,39 +0,0 @@
#!/bin/sh
# shellcheck shell=busybox
# SPDX-License-Identifier: GPL-2.0-only
# Builds jsonfilter for the test suites on a development host or CI runner.
# It comes from jsonpath, linked against a static libubox, both at the
# commits the ImmortalWrt image uses. Needs git, cmake, a C compiler and the
# json-c headers (Ubuntu: cmake libjson-c-dev). The binary lands in
# <dir>/bin/jsonfilter.
set -eu
LIBUBOX_REF=7dd127841e82eb1cfb61185da37dde7b9bd9ba6d
JSONPATH_REF=b9034210bd331749673416c6bf389cccd4e23610
[ $# -eq 1 ] || { echo "usage: build-jsonfilter.sh <dir>" >&2; exit 2; }
mkdir -p "$1"
out=$(cd "$1" && pwd)
src="$out/src"
fetch() { # $1 project, $2 commit
[ -d "$src/$1" ] || git clone -q "https://git.openwrt.org/project/$1.git" "$src/$1"
git -C "$src/$1" checkout -q "$2"
}
fetch libubox "$LIBUBOX_REF"
fetch jsonpath "$JSONPATH_REF"
cmake -S "$src/libubox" -B "$src/libubox/build" -DBUILD_LUA=OFF -DBUILD_EXAMPLES=OFF \
> /dev/null
cmake --build "$src/libubox/build" --target ubox-static > /dev/null
mkdir -p "$out/include/libubox" "$out/lib" "$out/bin"
cp "$src"/libubox/*.h "$out/include/libubox/"
cp "$src/libubox/build/libubox.a" "$out/lib/"
# jsonpath builds in its source tree, its parser generator uses relative paths.
cmake -S "$src/jsonpath" -B "$src/jsonpath" -DCMAKE_INCLUDE_PATH="$out/include" \
-DCMAKE_EXE_LINKER_FLAGS="-L$out/lib" > /dev/null
cmake --build "$src/jsonpath" > /dev/null
cp "$src/jsonpath/jsonpath" "$out/bin/jsonfilter"
echo "$out/bin/jsonfilter"
@@ -1,130 +0,0 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-only
"""Report quoted strings in shell files that contain a real line break.
A line end has to come from an escape (printf '%s\n'), not from the source
text: an editor or a CRLF checkout changes the latter without a trace, and
shellcheck does not look. Heredocs are skipped, and so are awk and sed
programs, whose text spans lines on purpose. In double quotes a break inside
$( ) does not count, that is a command spanning lines.
Usage: check-quoted-newlines.py FILE... exit 1 when anything is found
check-quoted-newlines.py --selftest
"""
import os
import re
import sys
import tempfile
HEREDOC = re.compile(r"<<-?\s*['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?")
PROGRAM = re.compile(r"\b(awk|sed)\b")
SUBST = re.compile(r"\$\((?:[^()]|\([^()]*\))*\)")
def closing_double(text, start):
"""Index of the double quote that ends the string opened before start."""
i, depth = start, 0
while i < len(text):
c = text[i]
if c == "\\":
i += 2
continue
if text.startswith("$(", i):
depth += 1
i += 2
continue
if c == ")" and depth:
depth -= 1
elif c == '"' and not depth:
return i
i += 1
return len(text)
def scan(path):
"""Yields (line, first characters) for every string with a line break."""
text = open(path, encoding="utf-8").read()
i, line, heredoc = 0, 1, None
while i < len(text):
c = text[i]
if heredoc:
end = text.find("\n", i)
if end < 0:
return
if text[i:end].strip() == heredoc:
heredoc = None
i, line = end + 1, line + 1
continue
if c == "\n":
i, line = i + 1, line + 1
continue
if c == "#" and (i == 0 or text[i - 1] in " \t\n;"):
end = text.find("\n", i)
i = len(text) if end < 0 else end
continue
match = HEREDOC.match(text, i)
if match:
heredoc = match.group(1)
end = text.find("\n", i)
i = len(text) if end < 0 else end
continue
if c == "\\":
i += 2
continue
if c in "'\"":
end = text.find("'", i + 1) if c == "'" else closing_double(text, i + 1)
if end < 0:
end = len(text)
body = text[i + 1:end]
before = text[text.rfind("\n", 0, i) + 1:i]
flat = body if c == "'" else SUBST.sub("", body)
if "\n" in flat and not (c == "'" and PROGRAM.search(before)):
yield line, body[:40]
i, line = end + 1, line + body.count("\n")
continue
i += 1
def check(paths):
found = 0
for path in paths:
for line, head in scan(path):
print(f"{path}:{line}: line break inside a quoted string: {head!r}")
found += 1
return found
def selftest():
cases = {
# These are Python strings: \\n puts the shell escape \n into the
# planted file, a bare \n a real line break. Mixing them up tests the
# opposite of what the name says.
"printf with an escape": ("printf '%s=%s\\n' a b\n", 0),
"printf with a real break": ("printf '%s=%s\n' a b\n", 1),
"assignment": ('list="$list$t\n"\n', 1),
"awk program": ("awk '\n{ print }\n' f\n", 0),
"command spanning lines": ('x="$(grep -c \\\n\tfoo f)"\n', 0),
"heredoc": ("cat <<'END'\nit's\nEND\n", 0),
"comment": ("# don't\necho ok\n", 0),
}
fail = 0
with tempfile.TemporaryDirectory() as tmp:
for name, (text, want) in cases.items():
path = os.path.join(tmp, "case.sh")
with open(path, "w", encoding="utf-8") as f:
f.write(text)
got = len(list(scan(path)))
ok = got == want
fail |= not ok
print(f"{'ok ' if ok else 'FAIL'} {name}: {got} found, {want} expected")
return fail
if __name__ == "__main__":
if sys.argv[1:] == ["--selftest"]:
sys.exit(1 if selftest() else 0)
if not sys.argv[1:]:
print(__doc__.strip().splitlines()[-2], file=sys.stderr)
sys.exit(2)
sys.exit(1 if check(sys.argv[1:]) else 0)
@@ -1,192 +0,0 @@
{
"MT7986_1_1": {
"up": true,
"pending": false,
"autostart": true,
"disabled": false,
"retry_setup_failed": false,
"config": {
"type": "mtwifi",
"band": "2g",
"channel": "auto",
"htmode": "HE20",
"noscan": true,
"txpower": 100,
"mu_beamformer": true,
"dbdc_main": true,
"country": "DE"
},
"interfaces": [
{
"section": "mgmt_2g",
"config": {
"network": [
"mgmt"
],
"isolate": true,
"device": "MT7986_1_1",
"mode": "ap",
"encryption": "sae-mixed",
"key": "REDACTED",
"ssid": "example-ssid",
"hidden": false,
"ieee80211k": false,
"radios": [
]
},
"ifname": "ra0",
"vlans": [
],
"stations": [
]
},
{
"section": "gast_2g",
"config": {
"network": [
"gast"
],
"isolate": true,
"device": "MT7986_1_1",
"mode": "ap",
"encryption": "sae-mixed",
"key": "REDACTED",
"ssid": "example-ssid",
"hidden": false,
"ieee80211k": true,
"radios": [
]
},
"ifname": "ra1",
"vlans": [
],
"stations": [
]
},
{
"section": "iot_2g",
"config": {
"network": [
"iot"
],
"isolate": true,
"device": "MT7986_1_1",
"mode": "ap",
"encryption": "psk2",
"key": "REDACTED",
"ssid": "example-ssid",
"hidden": false,
"ieee80211k": false,
"radios": [
]
},
"ifname": "ra2",
"vlans": [
],
"stations": [
]
},
{
"section": "yoda_2g",
"config": {
"network": [
"lan"
],
"device": "MT7986_1_1",
"mode": "ap",
"encryption": "sae-mixed",
"key": "REDACTED",
"ssid": "example-ssid",
"radios": [
]
},
"ifname": "ra3",
"vlans": [
],
"stations": [
]
}
]
},
"MT7986_1_2": {
"up": true,
"pending": false,
"autostart": true,
"disabled": false,
"retry_setup_failed": false,
"config": {
"type": "mtwifi",
"band": "5g",
"channel": "auto",
"htmode": "HE80",
"noscan": false,
"txpower": 100,
"mu_beamformer": true,
"dbdc_main": false,
"country": "DE"
},
"interfaces": [
{
"section": "yoda_5g",
"config": {
"network": [
"lan"
],
"device": "MT7986_1_2",
"mode": "ap",
"encryption": "sae-mixed",
"key": "REDACTED",
"ssid": "example-ssid",
"radios": [
]
},
"ifname": "rax0",
"vlans": [
],
"stations": [
]
},
{
"section": "gast_5g",
"config": {
"network": [
"gast"
],
"isolate": true,
"device": "MT7986_1_2",
"mode": "ap",
"encryption": "sae-mixed",
"key": "REDACTED",
"ssid": "example-ssid",
"hidden": false,
"ieee80211k": true,
"radios": [
]
},
"ifname": "rax1",
"vlans": [
],
"stations": [
]
}
]
}
}
@@ -1,88 +0,0 @@
{
"radio0": {
"up": true,
"pending": false,
"autostart": true,
"disabled": false,
"retry_setup_failed": false,
"config": {
"type": "mac80211",
"band": "2g",
"cell_density": 0,
"channel": "1",
"htmode": "HE20",
"path": "platform/soc/18000000.wifi"
},
"interfaces": [
{
"section": "default_radio0",
"config": {
"network": [
"lan"
],
"device": [
"radio0"
],
"mode": "ap",
"encryption": "sae-mixed",
"key": "REDACTED",
"ocv": 0,
"ssid": "example-ssid",
"radios": [
]
},
"ifname": "phy0-ap0",
"vlans": [
],
"stations": [
]
}
]
},
"radio1": {
"up": true,
"pending": false,
"autostart": true,
"disabled": false,
"retry_setup_failed": false,
"config": {
"type": "mac80211",
"band": "5g",
"cell_density": 0,
"channel": "36",
"htmode": "HE80",
"path": "platform/soc/18000000.wifi+1"
},
"interfaces": [
{
"section": "default_radio1",
"config": {
"network": [
"lan"
],
"device": [
"radio1"
],
"mode": "ap",
"encryption": "sae-mixed",
"key": "REDACTED",
"ocv": 0,
"ssid": "example-ssid",
"radios": [
]
},
"ifname": "phy1-ap0",
"vlans": [
],
"stations": [
]
}
]
}
}
-48
View File
@@ -1,48 +0,0 @@
# shellcheck shell=busybox
# SPDX-License-Identifier: GPL-2.0-only
# Stand-in for /lib/functions.sh with the two calls thermal-guard makes. Reads
# the UCI file "$UCI_CONFIG_DIR/<package>" and fills CONFIG_<section>_<option>
# the way the original does: list values joined with a space, lists cleared
# before each load, plain options left alone.
# Like the original it is not safe under set -u: the load reads unset
# variables, and config_get expands its fourth argument for a missing option.
config_load() {
local kw name val sect="" item
if [ -z "$CONFIG_APPEND" ]; then
for item in $CONFIG_LIST_STATE; do
unset "CONFIG_$item"
done
CONFIG_LIST_STATE=""
fi
[ -r "$UCI_CONFIG_DIR/$1" ] || return 1
while read -r kw name val; do
val=${val#[\'\"]}; val=${val%[\'\"]}
case "$kw" in
config) sect=$val; continue ;;
option|list) ;;
*) continue ;;
esac
case "$sect$name" in
''|*[!A-Za-z0-9_]*) continue ;;
esac
if [ "$kw" = option ]; then
eval "CONFIG_${sect}_$name=\$val"
continue
fi
case " $CONFIG_LIST_STATE " in
*" ${sect}_$name "*)
eval "CONFIG_${sect}_$name=\"\$CONFIG_${sect}_$name \$val\"" ;;
*)
CONFIG_LIST_STATE="$CONFIG_LIST_STATE ${sect}_$name"
eval "CONFIG_${sect}_$name=\$val" ;;
esac
done < "$UCI_CONFIG_DIR/$1"
}
config_get() {
case "$2$3" in
*[!A-Za-z0-9_]*) return 0 ;;
esac
eval "$1=\${CONFIG_$2_$3:-\${4}}"
}
@@ -1,669 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
# Runs the shipped script as a process against a planted state directory.
# The selftest covers the logic in memory, this covers what ends up on disk.
# shellcheck shell=busybox
set -eu
root=$(cd "$(dirname "$0")/.." && pwd)
tmp=$(mktemp -d)
# set -e ends the suite at the first command that fails outside a check. Say
# so, with the last check that ran, instead of stopping without a word.
finished=0; last="none yet"
suite_exit() { # $1 exit status
[ "$finished" = 1 ] || echo "FAIL the suite stopped early, the last check was: $last"
rm -rf "$tmp"
exit "$1"
}
trap 'suite_exit $?' EXIT
BIN="$root/thermal-guard/files/usr/sbin/thermal-guard"
LIB_DIR="$root/thermal-guard/files/usr/share/thermal-guard"
STATE_DIR="$tmp/state"
STATUS_FILE="$tmp/status"
PERSIST_DIR="$tmp/persist"
HOOK_DIR="$tmp/hooks"
export LIB_DIR STATE_DIR STATUS_FILE PERSIST_DIR HOOK_DIR
# Every daemon run logs through this stand-in, never the host's syslog. It
# records the arguments, and fails on demand while logger.fail exists.
cat > "$tmp/logger" <<LOGGER_END
#!/bin/sh
[ -e "$tmp/logger.fail" ] && exit 1
echo "\$*" >> "$tmp/syslog"
LOGGER_END
chmod 0755 "$tmp/logger"
export TG_LOGGER="$tmp/logger"
prio() { # $1 start of a message, prints the priority it went out with
sed -n "s/^-t thermal-guard -p \(daemon\.[a-z]*\) $1.*/\1/p" "$tmp/syslog" | head -n1
}
fail=0
chk() { # $1 label, $2 got, $3 expected
last=$1
if [ "$2" = "$3" ]; then
echo "ok $1"
else
echo "FAIL $1: expected [$3], got [$2]"
fail=1
fi
}
field() { # $1 file, $2 key
sed -n "s/^$2=//p" "$1" 2>/dev/null | head -n1
}
# A case that reads the status file first proves the file is from its own
# run: fresh_status before the run, sfield after it, which says "stale" for a
# file left over from an earlier case.
fresh_status() {
rm -f "$STATUS_FILE"; status_t0=$(date +%s)
}
sfield() { # $1 key
local now
now=$(field "$STATUS_FILE" now)
if [ -n "$now" ] && [ "$now" -ge "$status_t0" ]; then
field "$STATUS_FILE" "$1"
else
echo stale
fi
}
# How many daemons have started on this event log, to show one was running.
started() {
cat "$STATE_DIR/log" 2>/dev/null | grep -c 'started, version' || true
}
# A cool planted zone for the daemon runs before the trip section plants its
# own tree, so they do not depend on the host's temperature.
COOL="$tmp/cool"
mkdir -p "$COOL/thermal_zone0"; echo 40000 > "$COOL/thermal_zone0/temp"
plant_stage1() {
mkdir -p "$STATE_DIR"
cat > "$STATE_DIR/state" <<'EOF'
STAGE=1
T0=1000
T1=1100
T2=0
REF_CPU0=72
REF_MODEM0=0
REF_CPU1=73
REF_MODEM1=0
PENDING=-1
PENDING_N=0
COOLED=0
LAST_CPU=73
LAST_MODEM=
LAST_MODEM_SRC=
LAST_TS=1100
GEN=3
EOF
}
# ---- reset clears the stage and bumps the generation ----------------------
plant_stage1
: > "$tmp/syslog"; fresh_status
sh "$BIN" reset >/dev/null 2>&1
chk "reset clears the stage" "$(field "$STATE_DIR/state" STAGE)" "-1"
chk "reset goes to syslog as daemon.notice" "$(prio 'reset requested')" "daemon.notice"
chk "reset bumps GEN" "$(field "$STATE_DIR/state" GEN)" "4"
chk "reset refreshes the status file" "$(sfield stage)" "-1"
# ---- a plain cycle leaves the generation alone ----------------------------
# A daemon cycle, not a dry run: only a real cycle saves the state.
fresh_status
THERMAL_ROOT="$COOL" timeout 3 sh "$BIN" daemon >/dev/null 2>&1 || true
chk "a daemon cycle ran and saved the state" \
"$(sfield stage)/$([ "$(field "$STATE_DIR/state" LAST_TS)" -ge "$status_t0" ] 2>/dev/null &&
echo saved || echo not)" "-1/saved"
chk "a cycle does not touch GEN" "$(field "$STATE_DIR/state" GEN)" "4"
# ---- reset is idempotent, and safe on a state that is already clear -------
sh "$BIN" reset >/dev/null 2>&1
chk "second reset bumps GEN again" "$(field "$STATE_DIR/state" GEN)" "5"
chk "second reset keeps the stage clear" "$(field "$STATE_DIR/state" STAGE)" "-1"
# ---- the status file carries what the UI needs to spot a dead service -----
# Written by a daemon cycle; a dry run does not write it.
fresh_status
THERMAL_ROOT="$COOL" timeout 3 sh "$BIN" daemon >/dev/null 2>&1 || true
chk "status reports the enabled flag" "$(sfield enabled)" "1"
# The value depends on the machine running the tests, the field must be there
# either way because the web interface branches on it.
case "$(sfield no_sensor)" in
0|1) chk "status reports whether a sensor exists" present present ;;
*) chk "status reports whether a sensor exists" missing present ;;
esac
case "$(sfield fan_view)" in
none|pwm|cooling_device) chk "status reports what the fan is read from" present present ;;
*) chk "status reports what the fan is read from" missing present ;;
esac
chk "status reports the interval" "$(sfield interval)" "20"
written=$(field "$STATUS_FILE" now)
now=$(date +%s)
if [ -n "$written" ] && [ $((now - written)) -lt 60 ]; then
chk "status is stamped with the write time" fresh fresh
else
chk "status is stamped with the write time" "stale($written)" fresh
fi
# ---- a garbled state file must not carry values into the logic ------------
# Checked through "status", which loads the state and prints it without
# writing anything back, so the planted file stays as it is.
mkdir -p "$STATE_DIR"
cat > "$STATE_DIR/state" <<'EOF'
STAGE=1; rm -rf /
GEN=notanumber
LAST_CPU=42
EOF
sh "$BIN" status > "$tmp/status-out" 2>/dev/null
chk "junk stage is dropped" "$(sed -n 's/^stage=//p' "$tmp/status-out")" "-1"
chk "junk GEN falls back to zero" "$(sed -n 's/^gen=//p' "$tmp/status-out")" "0"
chk "junk reading is dropped" "$(sed -n 's/^last_cpu=//p' "$tmp/status-out")" "42"
# ---- one-shot commands stay out of the event log --------------------------
# Every entry point runs the same configuration checks. If a one-shot command
# logs what they find, repeating it repeats the line, and because the event log
# keeps only the last 50 entries a monitoring check calling status in a loop
# flushes out the record of an actual incident.
# A board without any sensor gives such a diagnostic. The daemon logging it
# shows there is something the one-shot commands have to hold back.
rm -rf "$STATE_DIR" "$PERSIST_DIR"
mkdir -p "$STATE_DIR" "$tmp/nosensor"
THERMAL_ROOT="$tmp/nosensor" HWMON_ROOT="$tmp/nosensor" timeout 3 sh "$BIN" daemon >/dev/null 2>&1 || true
chk "the daemon logs the missing sensor" "$(grep -c 'reports no temperature at all' "$STATE_DIR/log")" "1"
printf '2026-01-01 00:00:00 stage 1: modem radio off\n' > "$STATE_DIR/log"
i=0
while [ "$i" -lt 12 ]; do
THERMAL_ROOT="$tmp/nosensor" HWMON_ROOT="$tmp/nosensor" sh "$BIN" status >/dev/null 2>&1
THERMAL_ROOT="$tmp/nosensor" HWMON_ROOT="$tmp/nosensor" sh "$BIN" test 40 40 >/dev/null 2>&1
i=$((i + 1))
done
chk "status and test add nothing to the log" "$(wc -l < "$STATE_DIR/log")" "1"
chk "the incident is still there" "$(grep -c 'stage 1' "$STATE_DIR/log")" "1"
# ---- a dry run changes nothing -------------------------------------------
# First that the stages were reached at all, then that nothing was written:
# "nothing happened" only counts where something could have.
rm -rf "$STATE_DIR" "$PERSIST_DIR"
: > "$tmp/syslog"
out=$(sh "$BIN" test 95 95 2>/dev/null)
chk "a dry run of 95/95 reaches all three stages" \
"$(printf '%s\n' "$out" | grep -c '^would notify (crit): stage [0-2]: cpu 95 C')" "3"
chk "and ends in stage 2" "$(printf '%s\n' "$out" | sed -n 's/^decision=.* stage=\([-0-9]*\) .*/\1/p')" "2"
chk "it makes no logger call" "$(wc -l < "$tmp/syslog" | tr -d ' ')" "0"
chk "and writes no event log" "$([ -s "$STATE_DIR/log" ] && echo wrote || echo clean)" "clean"
chk "a dry run writes no state" "$([ -f "$STATE_DIR/state" ] && echo wrote || echo clean)" "clean"
chk "a dry run persists nothing" "$([ -f "$PERSIST_DIR/state" ] && echo wrote || echo clean)" "clean"
# ---- stages survive a reboot ---------------------------------------------
# A reboot empties /var/run but not the overlay. Simulate that by planting a
# persisted stage and removing the working state, the way tmpfs would.
rm -rf "$STATE_DIR" "$PERSIST_DIR"
mkdir -p "$PERSIST_DIR"
cat > "$PERSIST_DIR/state" <<'EOF'
STAGE=1
T0=1000
T1=1100
T2=0
REF_CPU0=72
REF_MODEM0=0
REF_CPU1=73
REF_MODEM1=0
PENDING=-1
PENDING_N=0
COOLED=0
LAST_CPU=73
LAST_MODEM=
LAST_MODEM_SRC=
LAST_TS=1100
FAN_SAVED_ENABLE=2
FAN_SAVED_PWM=90
GEN=3
EOF
# Only the daemon restores, and it does so at startup, so run one briefly.
timeout 5 sh "$BIN" daemon >/dev/null 2>&1 || true
chk "the daemon adopts the persisted stage" "$(field "$STATE_DIR/state" STAGE)" "1"
chk "the restore keeps the saved fan state" "$(field "$STATE_DIR/state" FAN_SAVED_ENABLE)" "2"
if grep -q "restored stage 1 after a reboot" "$STATE_DIR/log" 2>/dev/null; then
chk "the restore is logged" logged logged
else
chk "the restore is logged" silent logged
fi
# reset must clear both copies, otherwise the next boot brings the stage back
plant_stage1
sh "$BIN" reset >/dev/null 2>&1
if [ -e "$PERSIST_DIR/state" ]; then
chk "reset clears the persisted stage" present gone
else
chk "reset clears the persisted stage" gone gone
fi
# The removal has to reach the flash as well. A stand-in sync counts the call.
# A BusyBox built with FEATURE_SH_STANDALONE runs its own sync applet before
# anything on PATH, so the case is skipped where the stand-in would not run.
mkdir -p "$tmp/bin"
printf '#!/bin/sh\necho x >> "%s"\n' "$tmp/sync.calls" > "$tmp/bin/sync"
chmod 0755 "$tmp/bin/sync"
if [ "$(PATH="$tmp/bin:$PATH" sh -c 'command -v sync')" = "$tmp/bin/sync" ]; then
plant_stage1
mkdir -p "$PERSIST_DIR"; cp "$STATE_DIR/state" "$PERSIST_DIR/state"
rm -f "$tmp/sync.calls"
PATH="$tmp/bin:$PATH" sh "$BIN" reset >/dev/null 2>&1
chk "reset flushes the removal to flash" \
"$([ -e "$PERSIST_DIR/state" ] && echo present || echo gone)/$(wc -l < "$tmp/sync.calls" 2>/dev/null | tr -d ' ')" \
"gone/1"
else
echo "skip reset flushes the removal to flash (sync would be a shell applet here)"
fi
rm -f "$tmp/bin/sync"
# ---- stage hooks are files, not configuration -----------------------------
# The point of the hook directory is that nothing in UCI can introduce code.
# A stage set to "command" must run the file and must say so when it is absent.
rm -rf "$STATE_DIR" "$PERSIST_DIR" "$HOOK_DIR"
mkdir -p "$HOOK_DIR"
cat > "$HOOK_DIR/stage1" <<EOF
#!/bin/sh
echo ran > "$tmp/stage1-ran"
EOF
chmod 0755 "$HOOK_DIR/stage1"
# The dry run with a hook as stage action follows once the configuration can
# say "command", below.
# Whether a hook is executed is covered by the selftest, which can call
# tg_run_hook directly. Here it matters only that a dry run stays dry.
# ---- configuration through the stand-in functions.sh -----------------------
# The daemon reads UCI with config_load, which runs /sbin/uci by its full path,
# so tests/functions.sh reads the planted file instead. The uci on PATH only
# has to exist; every call to it is recorded, and the daemon has no business
# calling it. "get" answers from uci.get, for the migration script.
export TG_FUNCTIONS="$root/tests/functions.sh" UCI_CONFIG_DIR="$tmp/config"
mkdir -p "$tmp/bin" "$UCI_CONFIG_DIR"
cat > "$tmp/bin/uci" <<UCI_END
#!/bin/sh
echo "\$*" >> "$tmp/uci.calls"
[ "\$2" = get ] && [ -r "$tmp/uci.get" ] && sed -n "s|^\$3=||p" "$tmp/uci.get"
exit 0
UCI_END
chmod 0755 "$tmp/bin/uci"
# One "option" line per "name=value" argument, a "list" line per "+name=value".
uci_conf() {
local a
{
echo "config thermal-guard 'main'"
for a in "$@"; do
case "$a" in
+*) a=${a#+}; printf "\tlist %s '%s'\n" "${a%%=*}" "${a#*=}" ;;
*) printf "\toption %s '%s'\n" "${a%%=*}" "${a#*=}" ;;
esac
done
} > "$UCI_CONFIG_DIR/thermal-guard"
: > "$tmp/uci.calls"
}
# ---- pwm without a polarity leaves the fan alone, and says so once ---------
# pwm with pwm_idle but no pwm_full. The daemon runs three cycles and must
# report it once; a status call must not report it at all.
# ---- a dry run with a hook as stage action does not run it -----------------
# 85 C clears cpu_crit, so the two cycles of test enter stage 1, whose action
# is the stage1 hook planted above.
uci_conf stage1_action=command
out=$(PATH="$tmp/bin:$PATH" sh "$BIN" test 85 40 2>/dev/null)
chk "a dry run reaches the command stage" \
"$(printf '%s\n' "$out" | grep -c '^would notify (crit): stage 1: .* running command\.$')" "1"
chk "and never touches the hook" "$([ -e "$tmp/stage1-ran" ] && echo ran || echo no)" "no"
# A dry run reports the fan mode the daemon would use: pwm without pwm_full
# leaves the fan alone there, so the dry run must not claim to force it.
uci_conf fan_mode=pwm pwm_idle=255
out=$(PATH="$tmp/bin:$PATH" sh "$BIN" test 75 60 2>/dev/null)
chk "a dry run reaches stage 0 with the fan mode the daemon would use" \
"$(printf '%s\n' "$out" | grep -c '^would notify (crit): stage 0: .* fan left to its own controller')" "1"
uci_conf fan_mode=pwm pwm_idle=255 interval=5
rm -rf "$STATE_DIR" "$PERSIST_DIR"
PATH="$tmp/bin:$PATH" timeout 12 sh "$BIN" daemon >/dev/null 2>&1 || true
chk "pwm without pwm_full is reported once" \
"$(grep -c 'needs both pwm_full and pwm_idle' "$STATE_DIR/log" 2>/dev/null)" "1"
chk "pwm without pwm_full runs as none" \
"$(PATH="$tmp/bin:$PATH" sh "$BIN" status 2>/dev/null | sed -n 's/^fan_mode=//p')" "none"
chk "status does not report it again" \
"$(grep -c 'needs both pwm_full and pwm_idle' "$STATE_DIR/log" 2>/dev/null)" "1"
# ---- selftest leaves the live state alone -----------------------------------
# The runbook runs it on boards whose daemon is running. It must not write the
# working state, the event log or the hook directory of the real installation.
rm -rf "$STATE_DIR" "$HOOK_DIR"
plant_stage1
echo "2026-01-01 00:00:00 an incident worth keeping" > "$STATE_DIR/log"
before=$(cat "$STATE_DIR/state")
# Not under set -e: a failing selftest has to show up as a FAIL line here.
if sh "$BIN" selftest >/dev/null 2>&1; then r=passed; else r=failed; fi
chk "the selftest passes" "$r" "passed"
chk "selftest leaves the working state alone" "$(cat "$STATE_DIR/state")" "$before"
chk "selftest leaves the event log alone" "$(cat "$STATE_DIR/log")" "2026-01-01 00:00:00 an incident worth keeping"
chk "selftest creates no hooks in the real directories" \
"$(find "$STATE_DIR/hooks" "$HOOK_DIR" -type f 2>/dev/null | wc -l | tr -d ' ')" "0"
# ---- trip management, the daemon as a process against a planted tree -------
# The tree mirrors the Banana Pi R3 Mini: critical 125, hot 120, active
# 50/60/70, the pwm-fan bound to trips 2, 3 and 4.
TR="$tmp/thermal"
plant_tree() {
local z="$TR/thermal_zone0" c="$TR/cooling_device0" i t
rm -rf "$TR"; mkdir -p "$z" "$c"
echo pwm-fan > "$c/type"; echo 3 > "$c/max_state"; echo 0 > "$c/cur_state"
echo step_wise > "$z/policy"; echo 50000 > "$z/temp"
i=0
for t in critical:125000 hot:120000 active:50000 active:60000 active:70000; do
echo "${t%%:*}" > "$z/trip_point_${i}_type"
echo "${t#*:}" > "$z/trip_point_${i}_temp"
i=$((i + 1))
done
for i in 0 1 2; do
ln -s ../cooling_device0 "$z/cdev$i"
echo $((i + 2)) > "$z/cdev${i}_trip_point"
done
}
trips() { cat "$TR"/thermal_zone0/trip_point_[234]_temp | tr '\n' ' '; }
tg_run() { # $1 seconds, the daemon runs until timeout sends TERM
THERMAL_ROOT="$TR" PATH="$tmp/bin:$PATH" timeout "$1" sh "$BIN" daemon >/dev/null 2>&1 || true
}
BOOST="trip_boost=modem"
IVL="interval=5"
MFILE="modem_temp_file=$tmp/modem"
MSRC="modem_source=file"
# I1: a hot modem at start lowers the trips in one step
rm -rf "$STATE_DIR" "$PERSIST_DIR"; plant_tree; echo 90 > "$tmp/modem"
uci_conf "$BOOST" "$IVL" "$MSRC" "$MFILE"
: > "$tmp/syslog"
rc=0
THERMAL_ROOT="$TR" PATH="$tmp/bin:$PATH" timeout 3 sh "$BIN" daemon >/dev/null 2>&1 || rc=$?
chk "a hot modem at start lowers the trips" "$(trips)" "25000 35000 45000 "
chk "in one write, without the baseline in between" "$(grep -c 'trips set to' "$STATE_DIR/log")" "1"
chk "a trip change is daemon.info" "$(prio 'trips set to')" "daemon.info"
# Every event log line reached the logger, and nothing else did: one path.
missing=0
while IFS= read -r l; do
grep -qF -- "${l#* * }" "$tmp/syslog" || missing=$((missing + 1))
done < "$STATE_DIR/log"
chk "every event log line went to the logger" "$missing" "0"
chk "and the logger got no line besides them" \
"$(wc -l < "$tmp/syslog" | tr -d ' ')" "$(wc -l < "$STATE_DIR/log" | tr -d ' ')"
# I3: timeout ended that run with TERM, the trips stay where they were. One
# start and timeout's status for TERM show the daemon was running when TERM
# came: coreutils says 124, a BusyBox timeout applet dies of the signal (143).
case "$rc" in 124|143) rc=term ;; esac
chk "the daemon was running when it was stopped" "$rc/$(started)" "term/1"
chk "stopping the daemon leaves the trips down" "$(trips)" "25000 35000 45000 "
# I4: the device tree set of this boot is kept, and nothing goes to UCI
chk "device tree set kept for this boot" "$(cat "$STATE_DIR/trip_dt")" "50000 60000 70000"
chk "without trip_active no trip_base is written" \
"$([ -e "$PERSIST_DIR/trip_base" ] && echo written || echo none)" "none"
chk "trip management calls uci for nothing" "$(cat "$tmp/uci.calls")" ""
# I2: killed while lowered, restarted with a cool modem: baseline again
n0=$(started)
THERMAL_ROOT="$TR" PATH="$tmp/bin:$PATH" sh "$BIN" daemon >/dev/null 2>&1 &
pid=$!
sleep 2
chk "the second daemon was running before kill -9" "$(started)" "$((n0 + 1))"
kill -9 "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true
chk "kill -9 leaves the trips down" "$(trips)" "25000 35000 45000 "
echo 40 > "$tmp/modem"
fresh_status
tg_run 3
chk "a restart with a cool modem restores the baseline" "$(trips)" "50000 60000 70000 "
chk "the restart did not read the lowered trips as device tree" "$(sfield trip_dt)" "50/60/70"
# I10: the first cycle with trip_active records the device tree set, on the
# overlay and not in /etc/config. trip_active as a UCI list, like the page.
TA="+trip_active=45 +trip_active=55 +trip_active=65"
rm -rf "$STATE_DIR" "$PERSIST_DIR"; plant_tree
# shellcheck disable=SC2086 # TA holds three arguments
uci_conf "$BOOST" "$IVL" "$MSRC" "$MFILE" $TA
conf_before=$(md5sum < "$UCI_CONFIG_DIR/thermal-guard")
tg_run 3
chk "the first cycle with trip_active records trip_base" \
"$(cat "$PERSIST_DIR/trip_base")" "50 60 70"
chk "and leaves the configuration file alone" \
"$(md5sum < "$UCI_CONFIG_DIR/thermal-guard")" "$conf_before"
chk "and calls uci for nothing" "$(cat "$tmp/uci.calls")" ""
# I5: a new image with other device tree values, the operator's baseline stays
rm -rf "$STATE_DIR"; plant_tree; echo 75000 > "$TR/thermal_zone0/trip_point_4_temp"
: > "$tmp/syslog"; fresh_status
tg_run 3
chk "a changed device tree is reported" "$(sfield trip_dt_changed)" "1"
chk "at daemon.notice" "$(prio 'device tree trips are now')" "daemon.notice"
chk "and trip_active stays the baseline" "$(trips)" "45000 55000 65000 "
chk "trip_base is left alone" "$(cat "$PERSIST_DIR/trip_base")" "50 60 70"
# The page runs trips-keep once the operator has decided.
chk "trips-keep succeeds" "$(sh "$BIN" trips-keep >/dev/null 2>&1; echo $?)" "0"
chk "and records the set of this boot" "$(cat "$PERSIST_DIR/trip_base")" "50 60 75"
fresh_status
tg_run 3
chk "after that no change is reported" "$(sfield trip_dt_changed)" "0"
# I11: the package moves trip_dt out of UCI once, on install or first boot
MIG="$root/thermal-guard/files/etc/uci-defaults/90-thermal-guard-trip-base"
mig() { # $1 trip_dt as uci -q get prints it, empty for none
if [ -n "$1" ]; then echo "thermal-guard.main.trip_dt=$1"; fi > "$tmp/uci.get"
: > "$tmp/uci.calls"
PATH="$tmp/bin:$PATH" sh "$MIG" >/dev/null 2>&1
echo "rc=$?"
}
uci_removed() {
grep -c -e '^-q delete thermal-guard.main.trip_dt$' -e '^-q commit thermal-guard$' \
"$tmp/uci.calls"
}
rm -rf "$PERSIST_DIR"
chk "the migration exits 0" "$(mig '50 60 70')" "rc=0"
chk "and moves trip_dt to trip_base" "$(cat "$PERSIST_DIR/trip_base")" "50 60 70"
chk "and removes it from UCI" "$(uci_removed)" "2"
chk "a second run exits 0" "$(mig '')" "rc=0"
chk "after asking UCI for trip_dt" "$(grep -c '^-q get thermal-guard.main.trip_dt$' "$tmp/uci.calls")" "1"
chk "and changes nothing" "$(uci_removed)" "0"
echo "45 55 65" > "$PERSIST_DIR/trip_base"
mig '50 60 70' >/dev/null
chk "an existing trip_base is kept" "$(cat "$PERSIST_DIR/trip_base")" "45 55 65"
chk "and trip_dt removed all the same" "$(uci_removed)" "2"
rm -f "$PERSIST_DIR/trip_base"
mig '50 60;reboot' >/dev/null
chk "a garbled trip_dt is not moved" \
"$([ -e "$PERSIST_DIR/trip_base" ] && echo moved || echo none)" "none"
chk "only removed" "$(uci_removed)" "2"
: > "$tmp/not-a-dir"
chk "a failed write exits 0 as well" "$(PERSIST_DIR="$tmp/not-a-dir" mig '50 60 70')" "rc=0"
chk "after reading trip_dt" "$(grep -c '^-q get thermal-guard.main.trip_dt$' "$tmp/uci.calls")" "1"
chk "and keeps trip_dt in UCI" "$(uci_removed)" "0"
# I9: nothing configured, nothing written: the GL-X3000 case
rm -rf "$STATE_DIR"; plant_tree; echo 90 > "$tmp/modem"
before=$(find "$TR" -name 'trip_point_*' -exec cat {} \; | md5sum)
uci_conf "$IVL" "$MSRC" "$MFILE"
conf_before=$(md5sum < "$UCI_CONFIG_DIR/thermal-guard")
tg_run 12
chk "the daemon leaves the configuration file alone" \
"$(md5sum < "$UCI_CONFIG_DIR/thermal-guard")" "$conf_before"
chk "and calls uci for nothing" "$(cat "$tmp/uci.calls")" ""
chk "without trip options the zone is untouched" "$(find "$TR" -name 'trip_point_*' -exec cat {} \; | md5sum)" "$before"
chk "and not even read for this boot" "$([ -e "$STATE_DIR/trip_dt" ] && echo read || echo untouched)" "untouched"
chk "status says off" "$(sfield trip_state)" "off"
case "$(cat "$STATE_DIR/modem-temp" 2>/dev/null)" in
"90 "[0-9]*" file") chk "the modem temperature is published" ok ok ;;
*) chk "the modem temperature is published" "$(cat "$STATE_DIR/modem-temp" 2>/dev/null)" "90 <time> file" ;;
esac
# An option deleted while the daemon runs is back at its default by the next
# cycle, for a number and for a word alike.
wait_field() { # $1 key, $2 value, $3 seconds
local i=0
while [ "$i" -lt "$3" ]; do
[ "$(sfield "$1")" = "$2" ] && break
sleep 1; i=$((i + 1))
done
sfield "$1"
}
rm -rf "$STATE_DIR"; plant_tree; echo 40 > "$tmp/modem"
uci_conf "$IVL" "$MSRC" "$MFILE" cpu_warn=60 fan_mode=cooling_device
fresh_status
THERMAL_ROOT="$TR" PATH="$tmp/bin:$PATH" sh "$BIN" daemon >/dev/null 2>&1 &
pid=$!
chk "the configured cpu_warn is in use" "$(wait_field cpu_warn 60 6)" "60"
chk "the configured fan_mode is in use" "$(wait_field fan_mode cooling_device 1)" "cooling_device"
uci_conf "$IVL" "$MSRC" "$MFILE"
# One interval of 5 s plus the time a cycle takes.
chk "a deleted option returns to its default within one cycle" "$(wait_field cpu_warn 70 7)" "70"
chk "a deleted word option returns to its default as well" "$(wait_field fan_mode none 1)" "none"
kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true
# fan_cooling_device and fan_thermal_zone are gone. A value left over in an
# old configuration is ignored like any unknown option: the fan is found by
# its type, not the numbered path the option named.
rm -rf "$STATE_DIR"; plant_tree
mkdir -p "$TR/cooling_device1"; echo other > "$TR/cooling_device1/type"
echo 7 > "$TR/cooling_device1/max_state"; echo 0 > "$TR/cooling_device1/cur_state"
uci_conf "$IVL" "$MSRC" "$MFILE" fan_mode=cooling_device fan_cooling_device="$TR/cooling_device1"
fresh_status
tg_run 3
chk "a leftover fan_cooling_device is ignored" "$(sfield fan_max)" "3"
chk "and not even rejected in the log" "$(grep -c 'fan_cooling_device' "$STATE_DIR/log")" "0"
# I6 and I7: a hung modem or a held lock costs a reading, not the loop.
# /dev/null stands in for the AT port, it is a character device.
AT="modem_source=fibocom"
PORT="modem_at_port=/dev/null"
LOCK="at_lock=$tmp/at.lock"
cat > "$tmp/bin/sms_tool" <<SMS_END
#!/bin/sh
[ -e "$tmp/sms.hang" ] && exec sleep 60
echo "+GTSENRDTEMP: 1,48000"
SMS_END
chmod 0755 "$tmp/bin/sms_tool"
loop_alive() { # $1 label: status written again after a stalled first cycle
local t0 t1
fresh_status
THERMAL_ROOT="$TR" PATH="$tmp/bin:$PATH" sh "$BIN" daemon >/dev/null 2>&1 &
pid=$!
sleep 3; t0=$(field "$STATUS_FILE" now)
sleep 12; t1=$(field "$STATUS_FILE" now)
kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true
if [ -n "$t1" ] && [ "${t1:-0}" -gt "${t0:-0}" ]; then
chk "$1" alive alive
else
chk "$1" "stuck($t0/$t1)" alive
fi
}
rm -rf "$STATE_DIR" "$PERSIST_DIR"; plant_tree; touch "$tmp/sms.hang"
uci_conf "$IVL" "$AT" "$PORT" "$LOCK"
loop_alive "a hung modem does not stop the loop"
chk "and it reads as no modem temperature" "$(grep -c 'cannot read modem temperature' "$STATE_DIR/log")" "1"
rm -rf "$STATE_DIR" "$PERSIST_DIR"; rm -f "$tmp/sms.hang"
flock "$tmp/at.lock" sleep 60 &
holder=$!
sleep 1
loop_alive "a held lock does not stop the loop"
kill "$holder" 2>/dev/null || true; wait "$holder" 2>/dev/null || true
# A logger that fails costs nothing: the loop goes on, the event log is written.
rm -rf "$STATE_DIR" "$PERSIST_DIR"; plant_tree; echo 40 > "$tmp/modem"
uci_conf "$IVL" "$MSRC" "$MFILE"
touch "$tmp/logger.fail"
loop_alive "a failing logger does not stop the loop"
chk "and the event log is written all the same" "$(grep -c 'started, version' "$STATE_DIR/log")" "1"
rm -f "$tmp/logger.fail"
# I8: stage 1 against a hung modem fails loudly and the loop goes on
rm -rf "$STATE_DIR" "$PERSIST_DIR"; plant_tree; touch "$tmp/sms.hang"
echo 85000 > "$TR/thermal_zone0/temp"
uci_conf "$IVL" "$AT" "$PORT" "$LOCK"; : > "$tmp/syslog"
AT_WAIT=2 AT_TIMEOUT=1 THERMAL_ROOT="$TR" PATH="$tmp/bin:$PATH" \
timeout 30 sh "$BIN" daemon >/dev/null 2>&1 || true
chk "stage 1 against a hung modem is reported as failed" \
"$(grep -c "stage 1 action 'modem_radio_off' failed" "$STATE_DIR/log")" "1"
chk "as daemon.crit" "$(prio "stage 1 action 'modem_radio_off' failed")" "daemon.crit"
chk "and the stage is still entered" "$(field "$STATE_DIR/state" STAGE)" "1"
rm -f "$tmp/sms.hang"
# I12: a stage restored after a reboot whose action fails says so, in the one
# restore message. The modem stand-in never answers +CFUN, a notify hook
# collects the messages, and a stand-in ip reports a default route that does
# not run through the modem, so the message goes out instead of queueing.
rm -rf "$STATE_DIR" "$PERSIST_DIR"; plant_tree
mkdir -p "$PERSIST_DIR" "$HOOK_DIR"
# Stage 1 entered just now at 50 C, so the hold time cannot escalate it
# further while the case runs.
now=$(date +%s)
printf 'STAGE=1\nT0=%s\nT1=%s\nREF_CPU0=50\nREF_CPU1=50\nREF_MODEM0=48\nREF_MODEM1=48\nGEN=3\n' \
"$now" "$now" > "$PERSIST_DIR/state"
cat > "$HOOK_DIR/notify" <<HOOK_END
#!/bin/sh
cat >> "$tmp/notified"
echo "----" >> "$tmp/notified"
HOOK_END
cat > "$tmp/bin/ip" <<'IP_END'
#!/bin/sh
[ "$1" = -4 ] && echo "default via 192.0.2.1 dev eth9 proto static"
exit 0
IP_END
chmod 0755 "$HOOK_DIR/notify" "$tmp/bin/ip"
: > "$tmp/notified"; : > "$tmp/syslog"
uci_conf "$IVL" "$AT" "$PORT" "$LOCK"
AT_WAIT=2 AT_TIMEOUT=1 THERMAL_ROOT="$TR" PATH="$tmp/bin:$PATH" \
timeout 25 sh "$BIN" daemon >/dev/null 2>&1 || true
chk "a failed action after a reboot is crit" \
"$(prio "stage 1 action 'modem_radio_off' failed after the reboot")" "daemon.crit"
# Counted by kind: a cooled down message may follow, the board is at 50 C.
chk "one restore message goes out" "$(grep -c 'thermal-guard: restored stage' "$tmp/notified")" "1"
chk "the failure is no message of its own" "$(grep -c 'failed after the reboot' "$tmp/notified")" "0"
chk "and it names the failed action" "$(grep -c \
"restored stage 1 after a reboot, but reapplying failed for: stage 1 action 'modem_radio_off'" \
"$tmp/notified")" "1"
rm -f "$HOOK_DIR/notify" "$tmp/bin/ip"
# I13: a persisted stage 1 from an older state file, without references and
# with its hold time long past. A missing reference is no 0: without a rise
# nothing escalates.
rm -rf "$STATE_DIR" "$PERSIST_DIR"; plant_tree; echo 48 > "$tmp/modem"
mkdir -p "$PERSIST_DIR"; printf 'STAGE=1\nT0=1000\nT1=1100\nGEN=3\n' > "$PERSIST_DIR/state"
uci_conf "$IVL" "$MSRC" "$MFILE"
tg_run 12
chk "an old state without references stays in stage 1" "$(field "$STATE_DIR/state" STAGE)" "1"
chk "and logs no stage 2" "$(grep -c 'stage 2:' "$STATE_DIR/log")" "0"
chk "but has references again" "$(field "$STATE_DIR/state" REF_CPU1)/$(field "$STATE_DIR/state" REF_MODEM1)" "50/48"
# ---- collectd emits only plain integers ----------------------------------
# Same filter as the helper, checked here so a change to it cannot go unnoticed.
tg_num() {
case "$1" in
''|*[!0-9-]*|-|?*-*) echo "" ;;
*) echo "$1" ;;
esac
}
chk "collectd keeps a reading" "$(tg_num 55)" "55"
chk "collectd keeps idle stage -1" "$(tg_num '-1')" "-1"
chk "collectd drops a shell payload" "$(tg_num '1;reboot')" ""
chk "collectd drops a lone dash" "$(tg_num '-')" ""
chk "collectd drops a stray minus" "$(tg_num 1-2)" ""
chk "collectd drops an empty value" "$(tg_num '')" ""
finished=1
if [ "$fail" = 0 ]; then
echo "integration: all cases passed"
else
echo "integration: FAILED"
exit 1
fi
@@ -1,16 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
# Runs the decision logic against its test cases without touching any hardware.
# Everything the daemon would write goes to a scratch directory.
set -eu
root=$(cd "$(dirname "$0")/.." && pwd)
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
STATE_DIR="$tmp/state" \
STATUS_FILE="$tmp/status" \
LIB_DIR="$root/thermal-guard/files/usr/share/thermal-guard" \
FIXTURE_DIR="$root/tests/fixtures" \
sh "$root/thermal-guard/files/usr/sbin/thermal-guard" selftest
@@ -1,68 +0,0 @@
#
# 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))
@@ -1,80 +0,0 @@
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'
@@ -1,38 +0,0 @@
#!/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
}
@@ -1,35 +0,0 @@
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
@@ -1,25 +0,0 @@
#!/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
@@ -1 +0,0 @@
/etc/thermal-guard/
@@ -1,67 +0,0 @@
#!/bin/sh
# shellcheck shell=busybox
# SPDX-License-Identifier: GPL-2.0-only
#
# collectd exec plugin feed. Reads the status file the daemon writes and prints
# PUTVAL lines, so temperatures end up in RRD without anyone else touching the
# modem. Add to /etc/collectd.conf:
#
# LoadPlugin exec
# <Plugin exec>
# Exec "nobody:nogroup" "/usr/libexec/thermal-guard-collectd"
# </Plugin>
#
# The status file is world readable, so an unprivileged user is enough.
set -u
STATUS_FILE="${STATUS_FILE:-/var/run/thermal-guard.status}"
INTERVAL="${COLLECTD_INTERVAL:-30}"
HOSTNAME="${COLLECTD_HOSTNAME:-$(cat /proc/sys/kernel/hostname 2>/dev/null || echo localhost)}"
# collectd wants whole seconds and refuses an interval of zero
INTERVAL=${INTERVAL%%.*}
[ "$INTERVAL" -ge 1 ] 2>/dev/null || INTERVAL=30
# The status file is written by the daemon, but a truncated write or a stale
# file from an older version can still hand us something that is not a number.
# Anything that is not a plain integer becomes empty, and empty is not emitted.
tg_num() {
case "$1" in
''|*[!0-9-]*|-|?*-*) echo "" ;;
*) echo "$1" ;;
esac
}
putval() { # $1 type-instance, $2 value
[ -n "$2" ] || return 0
printf 'PUTVAL "%s/thermal_guard/temperature-%s" interval=%s N:%s\n' \
"$HOSTNAME" "$1" "$INTERVAL" "$2"
}
putgauge() { # $1 type-instance, $2 value
[ -n "$2" ] || return 0
printf 'PUTVAL "%s/thermal_guard/gauge-%s" interval=%s N:%s\n' \
"$HOSTNAME" "$1" "$INTERVAL" "$2"
}
while :; do
if [ -r "$STATUS_FILE" ]; then
cpu=""; modem=""; stage=""; fan=""
while IFS='=' read -r k v; do
case "$k" in
cpu_c) cpu=$(tg_num "$v") ;;
modem_c) modem=$(tg_num "$v") ;;
stage) stage=$(tg_num "$v") ;;
fan_state) fan=$(tg_num "$v") ;;
esac
done < "$STATUS_FILE"
putval soc "$cpu"
putval modem "$modem"
putgauge fan_step "$fan"
# -1 means idle, the graph then shows anything above zero as an incident
putgauge stage "$stage"
fi
sleep "$INTERVAL"
done
File diff suppressed because it is too large. Load diff
@@ -1,969 +0,0 @@
# 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)" ]; }
@@ -1,344 +0,0 @@
# 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
}