🦄 Sync 2026-09-27 22:25:23
No files matched your search
@@ -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))
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
# }
|
||||
#}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
config honk 'config'
|
||||
option enabled '0'
|
||||
option advanced '0'
|
||||
option dashboard 'doona'
|
||||
option config_file '/etc/honk/config.dae'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
* text=auto eol=lf
|
||||
@@ -1,5 +0,0 @@
|
||||
# editor and OS noise
|
||||
*.sync-conflict-*
|
||||
.stversions/
|
||||
.stfolder/
|
||||
*~
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -1,458 +0,0 @@
|
||||
# thermal-guard
|
||||
|
||||
Overheating protection for OpenWrt routers with staged mitigation.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
Thresholds for the three stages
|
||||
|
||||

|
||||
|
||||
Fan, including the switching temperatures of a kernel-driven fan
|
||||
|
||||

|
||||
|
||||
Modem, Fibocom on the Banana Pi and Quectel on the GL-X3000
|
||||
|
||||

|
||||

|
||||
|
||||
Actions and notifications
|
||||
|
||||

|
||||
|
||||
</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" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
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": [
|
||||
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||