pax_global_header00006660000000000000000000000064147304144130014513gustar00rootroot0000000000000052 comment=2c9dd133c597b43fee2819e353ba5cd38e3d369a zcfan-1.4.0/000077500000000000000000000000001473041441300126165ustar00rootroot00000000000000zcfan-1.4.0/.clang-format000066400000000000000000000003571473041441300151760ustar00rootroot00000000000000BreakStringLiterals: false ColumnLimit: 80 IndentWidth: 4 PenaltyReturnTypeOnItsOwnLine: 999 PenaltyBreakComment: 0 SortIncludes: true UseTab: Never IndentPPDirectives: BeforeHash IndentCaseLabels: true AllowShortBlocksOnASingleLine: true zcfan-1.4.0/.github/000077500000000000000000000000001473041441300141565ustar00rootroot00000000000000zcfan-1.4.0/.github/workflows/000077500000000000000000000000001473041441300162135ustar00rootroot00000000000000zcfan-1.4.0/.github/workflows/ci.yml000066400000000000000000000007631473041441300173370ustar00rootroot00000000000000jobs: build_and_test: name: CI runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - run: sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test - run: sudo apt-get update - uses: awalsh128/cache-apt-pkgs-action@v1 with: packages: clang clang-tidy clang-format version: 1.0 - run: make lint - run: make clean clang-tidy - run: make clean clang-everything on: push: pull_request: workflow_dispatch: zcfan-1.4.0/.gitignore000066400000000000000000000000061473041441300146020ustar00rootroot00000000000000zcfan zcfan-1.4.0/LICENSE000066400000000000000000000020751473041441300136270ustar00rootroot00000000000000The MIT License Copyright (c) 2022-present Christopher Down Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. zcfan-1.4.0/Makefile000066400000000000000000000034571473041441300142670ustar00rootroot00000000000000CFLAGS:=-std=gnu99 -O2 -pedantic -Wall -Wextra -Wwrite-strings -Warray-bounds -Wconversion -Wstrict-prototypes -Werror $(CFLAGS) CPPFLAGS:=$(CPPFLAGS) SOURCES=$(wildcard *.c) EXECUTABLES=$(patsubst %.c,%,$(SOURCES)) INSTALL:=install prefix:=/usr/local bindir:=$(prefix)/bin datarootdir:=$(prefix)/share mandir:=$(datarootdir)/man SERVICE_TEMPLATE=zcfan.service.in SERVICE=zcfan.service all: $(EXECUTABLES) $(SERVICE) $(SERVICE): $(SERVICE_TEMPLATE) sed 's|@bindir@|$(bindir)|g' $< > $@ %: %.c $(CC) $(CPPFLAGS) $(CFLAGS) $< -o $@ $(LIBS) $(LDFLAGS) %.o: %.c $(CC) $(CPPFLAGS) $(CFLAGS) $< -c -o $@ %: %.o $(CC) $< -o $@ $(LIBS) $(LDFLAGS) # Noisy clang build that's expected to fail, but can be useful to find corner # cases. clang-everything: CC=clang clang-everything: CFLAGS+=-Weverything -Wno-disabled-macro-expansion -Wno-padded -Wno-covered-switch-default -Wno-gnu-zero-variadic-macro-arguments -Wno-declaration-after-statement clang-everything: all sanitisers: CFLAGS+=-fsanitize=address -fsanitize=undefined -fanalyzer sanitisers: debug debug: CFLAGS+=-Og -ggdb -fno-omit-frame-pointer debug: all clang-tidy: # DeprecatedOrUnsafeBufferHandling: See https://stackoverflow.com/a/50724865/945780 # clang-diagnostic-gnu-zero-variadic-macro-arguments: We require this for ##__VA_ARGS__ clang-tidy zcfan.c -checks=-clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling,-clang-diagnostic-gnu-zero-variadic-macro-arguments -- $(CPPFLAGS) $(CFLAGS) $(LDFLAGS) install: all mkdir -p $(DESTDIR)$(bindir)/ $(INSTALL) -pt $(DESTDIR)$(bindir)/ $(EXECUTABLES) $(INSTALL) -Dp -m 644 $(SERVICE) $(DESTDIR)$(prefix)/lib/systemd/system/$(SERVICE) $(INSTALL) -Dp -m 644 zcfan.1 $(DESTDIR)$(mandir)/man1/zcfan.1 lint: clang-format -style=file --dry-run --Werror zcfan.c clean: rm -f $(EXECUTABLES) $(SERVICE) zcfan-1.4.0/README.md000066400000000000000000000051001473041441300140710ustar00rootroot00000000000000# zcfan | [![Tests](https://img.shields.io/github/actions/workflow/status/cdown/zcfan/ci.yml?branch=master)](https://github.com/cdown/zcfan/actions?query=branch%3Amaster) Zero-configuration fan control daemon for ThinkPads. ## Features - Extremely small (~250 lines), simple, and easy to understand code - Sensible out of the box, configuration is optional (see "usage" below) - Strong focus on stopping the fan as soon as safe to do so, without inducing throttling - Automatic temperature- and time-based hysteresis: no bouncing between fan levels - Watchdog support - Minimal resource usage - No dependencies ## Usage zcfan has the following default fan states: | Config name | thinkpad_acpi fan level | Default trip temperature (C) | |-------------|-----------------------------------|------------------------------| | max_temp | full-speed (or 7 if unsupported) | 90 | | med_temp | 4 | 80 | | low_temp | 1 | 70 | If no trip temperature is reached, the fan will be turned off. The fan will also only be reduced once the temperature is now at least 10C below the trip temperature for the current fan state. This can be tuned with the config parameter `temp_hysteresis`. To override these defaults, you can place a file at `/etc/zcfan.conf` with updated trip temperatures in degrees celsius and/or fan levels. As an example: max_temp 85 med_temp 70 low_temp 55 temp_hysteresis 20 max_level full-speed med_level 4 low_level 1 ### Hysteresis We will only reduce the fan level again once: 1. The temperature is now at least `temp_hysteresis` Celsius (default 10C) below the trip point, and 2. At least 3 seconds have elapsed since the initial trip. This avoids unnecessary fluctuations in fan speed. ## Comparison with thinkfan I wrote zcfan because I found thinkfan's configuration and code complexity too much for my tastes. Use whichever suits your needs. ## Compilation Run `make`. ## Installation 1. Compile zcfan or install from the [AUR package](https://aur.archlinux.org/packages/zcfan) 2. Load your thinkpad_acpi module with `fan_control=1` - At runtime: `rmmod thinkpad_acpi && modprobe thinkpad_acpi fan_control=1` - By default: `echo options thinkpad_acpi fan_control=1 > /etc/modprobe.d/99-fancontrol.conf` 3. Run `zcfan` as root (or use the `zcfan` systemd service provided) ## Disclaimer While the author uses this on their own machine, obviously there is no warranty whatsoever. zcfan-1.4.0/zcfan.1000066400000000000000000000027501473041441300140050ustar00rootroot00000000000000.TH zcfan 1 .SH NAME zcfan - zero-configuration fan control daemon for ThinkPads .SH DESCRIPTION .B zcfan is a minimal, zero-configuration fan control daemon for ThinkPads. .SH OPTIONS .B zcfan does not take any options. If any are provided, a help message will be printed and .B zcfan will exit. .SH CONFIGURATION .SS USAGE .B zcfan has the following default fan states: .RS .TS tab(;); l l l. Config name;thinkpad_acpi fan level;Default trip temperature (C) _ max_temp;7;90 med_temp;4;80 low_temp;1;70 .TE .RE You can optionally override this configuration at .I /etc/zcfan.conf with your desired threshold values in the following format: .RS .EX max_temp 85 med_temp 70 low_temp 55 .EE .RE If no trip temperature is reached, the fan will be turned off. .SS HYSTERESIS We will only reduce the fan level again once: .IP "1." 3 The temperature is now at least .I temp_hysteresis Celsius below the trip point (default 10C), and .IP "2." 3 At least 3 seconds have elapsed since the initial trip. .PP This avoids unnecessary fluctuations in fan speed. .SS WATCHDOG The kernel watchdog is reset every 120 seconds by default, but a smaller value can be selected in the .I /etc/zcfan.conf config file with the syntax .RS .EX watchdog_secs 10 .EE .RE .SH DEPENDENCIES .B thinkpad-acpi must be loaded with .IR fan_control=1. .SH SEE ALSO .BR thinkfan (1) .SH AUTHOR Chris Down .MT chris@chrisdown.name .ME .SH REPORTING BUGS Please send bug reports to .UR https://github.com/cdown/zcfan/issues .UE . zcfan-1.4.0/zcfan.c000066400000000000000000000304151473041441300140660ustar00rootroot00000000000000#include #include #include #include #include #include #include #include #include #include #define MILLIC_TO_C(n) (n / 1000) #define TEMP_FILES_GLOB "/sys/class/hwmon/hwmon*/temp*_input" #define FAN_CONTROL_FILE "/proc/acpi/ibm/fan" #define TEMP_INVALID INT_MIN #define TEMP_MIN INT_MIN + 1 #define NS_IN_SEC 1000000000L // 1 second in nanoseconds #define THRESHOLD_NS 200000000 // 0.2 seconds #define STR_HELPER(x) #x #define STR(x) STR_HELPER(x) #define DEFAULT_WATCHDOG_SECS 120 #define S_DEFAULT_WATCHDOG_SECS STR(DEFAULT_WATCHDOG_SECS) #define info(fmt, ...) fprintf(stderr, "[INF] " fmt, ##__VA_ARGS__) #define err(fmt, ...) fprintf(stderr, "[ERR] " fmt, ##__VA_ARGS__) #define max(x, y) ((x) > (y) ? (x) : (y)) #define expect(x) \ do { \ if (!(x)) { \ fprintf(stderr, "FATAL: !(%s) at %s:%s:%d\n", #x, __FILE__, \ __func__, __LINE__); \ abort(); \ } \ } while (0) #define CONFIG_MAX_STRLEN 15 #define S_CONFIG_MAX_STRLEN STR(CONFIG_MAX_STRLEN) /* Must be highest to lowest temp */ enum FanLevel { FAN_MAX, FAN_MED, FAN_LOW, FAN_OFF, FAN_INVALID }; struct Rule { char tpacpi_level[CONFIG_MAX_STRLEN + 1]; int threshold; const char *name; }; static struct Rule rules[] = { [FAN_MAX] = {"full-speed", 90, "maximum"}, [FAN_MED] = {"4", 80, "medium"}, [FAN_LOW] = {"1", 70, "low"}, [FAN_OFF] = {"0", TEMP_MIN, "off"}, }; static struct timespec last_watchdog_ping = {0, 0}; static time_t watchdog_secs = DEFAULT_WATCHDOG_SECS; static int temp_hysteresis = 10; static const unsigned int tick_hysteresis = 3; static char output_buf[512]; static const struct Rule *current_rule = NULL; static volatile sig_atomic_t run = 1; static int first_tick = 1; /* Stop running if errors are immediate */ static glob_t temp_files; enum resume_state { RESUME_NOT_DETECTED, RESUME_DETECTED, }; static void exit_if_first_tick(void) { if (first_tick) { err("Quitting due to failure during first run\n"); exit(1); } } static int64_t timespec_diff_ns(const struct timespec *start, const struct timespec *end) { return ((int64_t)end->tv_sec - (int64_t)start->tv_sec) * NS_IN_SEC + (end->tv_nsec - start->tv_nsec); } static enum resume_state detect_suspend(void) { static struct timespec monotonic_prev, boottime_prev; struct timespec monotonic_now, boottime_now; expect(clock_gettime(CLOCK_MONOTONIC, &monotonic_now) == 0); expect(clock_gettime(CLOCK_BOOTTIME, &boottime_now) == 0); if (monotonic_prev.tv_sec == 0 && monotonic_prev.tv_nsec == 0) { monotonic_prev = monotonic_now; boottime_prev = boottime_now; return RESUME_NOT_DETECTED; } int64_t delta_monotonic = timespec_diff_ns(&monotonic_prev, &monotonic_now); int64_t delta_boottime = timespec_diff_ns(&boottime_prev, &boottime_now); monotonic_prev = monotonic_now; boottime_prev = boottime_now; return delta_boottime > delta_monotonic + THRESHOLD_NS ? RESUME_DETECTED : RESUME_NOT_DETECTED; } static int glob_err_handler(const char *epath, int eerrno) { err("glob: %s: %s\n", epath, strerror(eerrno)); return 0; } static void populate_temp_files(void) { expect(glob(TEMP_FILES_GLOB, 0, glob_err_handler, &temp_files) == 0); } static int full_speed_supported(void) { FILE *f = fopen(FAN_CONTROL_FILE, "re"); char line[256]; // If exceeded, we'll just read again int found = 0; expect(f); while (fgets(line, sizeof(line), f) != NULL) { if (strstr(line, "full-speed") != NULL) { found = 1; break; } } fclose(f); return found; } static int read_temp_file(const char *filename) { FILE *f = fopen(filename, "re"); int val; if (!f) { return TEMP_INVALID; } if (fscanf(f, "%d", &val) != 1) { val = TEMP_INVALID; } fclose(f); return val; } static int get_max_temp(void) { int max_temp = TEMP_INVALID; for (size_t i = 0; i < temp_files.gl_pathc; i++) { int temp = read_temp_file(temp_files.gl_pathv[i]); max_temp = max(max_temp, temp); } if (max_temp == TEMP_INVALID) { err("Couldn't find any valid temperature\n"); exit_if_first_tick(); return TEMP_INVALID; } return MILLIC_TO_C(max_temp); } #define write_fan_level(level) write_fan("level", level) static int write_fan(const char *command, const char *value) { FILE *f = fopen(FAN_CONTROL_FILE, "we"); int ret; if (!f) { err("%s: fopen: %s%s\n", FAN_CONTROL_FILE, strerror(errno), errno == ENOENT ? " (is thinkpad_acpi loaded?)" : ""); exit_if_first_tick(); return -errno; } expect(setvbuf(f, NULL, _IONBF, 0) == 0); /* Make fprintf see errors */ ret = fprintf(f, "%s %s", command, value); if (ret < 0) { err("%s: write: %s%s\n", FAN_CONTROL_FILE, strerror(errno), errno == EINVAL ? " (did you enable fan_control=1?)" : ""); exit_if_first_tick(); fclose(f); return -errno; } expect(clock_gettime(CLOCK_MONOTONIC, &last_watchdog_ping) == 0); fclose(f); return 0; } static void write_watchdog_timeout(const time_t timeout) { char timeout_s[sizeof(S_DEFAULT_WATCHDOG_SECS)]; /* max timeout value */ int ret = snprintf(timeout_s, sizeof(timeout_s), "%" PRIuMAX, (uintmax_t)timeout); expect(ret >= 0 && (size_t)ret < sizeof(timeout_s)); write_fan("watchdog", timeout_s); } /* 1: set fan level, 0: didn't set fan level */ static int set_fan_level(void) { int max_temp = get_max_temp(), temp_penalty = 0; static unsigned int tick_penalty = tick_hysteresis; if (tick_penalty > 0) { tick_penalty--; } if (max_temp == TEMP_INVALID) { write_fan_level("full-speed"); return 1; } for (size_t i = 0; i < FAN_INVALID; i++) { const struct Rule *rule = rules + i; if (rule == current_rule) { if (tick_penalty) { return 0; /* Must wait longer until able to move down levels */ } temp_penalty = temp_hysteresis; } if (rule->threshold < temp_penalty || (rule->threshold - temp_penalty) < max_temp) { if (rule != current_rule) { current_rule = rule; tick_penalty = tick_hysteresis; printf("[FAN] Temperature now %dC, fan set to %s\n", max_temp, rule->name); write_fan_level(rule->tpacpi_level); return 1; } return 0; } } err("No threshold matched?\n"); return 0; } #define WATCHDOG_GRACE_PERIOD_SECS 2 static void maybe_ping_watchdog(void) { struct timespec now; expect(current_rule); expect(clock_gettime(CLOCK_MONOTONIC, &now) == 0); if (detect_suspend() == RESUME_DETECTED) { // On resume, some models need a manual fan write again, or they will // revert to "auto". info("Clock jump detected, possible resume. Rewriting fan level\n"); write_fan_level(current_rule->tpacpi_level); } if (now.tv_sec - last_watchdog_ping.tv_sec < (watchdog_secs - WATCHDOG_GRACE_PERIOD_SECS)) { return; } // Transitioning from level 0 -> level 0 can cause a brief fan spinup on // some models, so don't reset the timer by write_fan_level(). write_watchdog_timeout(watchdog_secs); } #define CONFIG_PATH "/etc/zcfan.conf" #define fscanf_int_for_key(f, pos, name, dest) \ do { \ int val; \ if (fscanf(f, name " %d ", &val) == 1) { \ dest = val; \ } else { \ expect(fseek(f, pos, SEEK_SET) == 0); \ } \ } while (0) #define fscanf_str_for_key(f, pos, name, dest) \ do { \ char val[CONFIG_MAX_STRLEN + 1]; \ if (fscanf(f, name " %" S_CONFIG_MAX_STRLEN "s ", val) == 1) { \ strncpy(dest, val, CONFIG_MAX_STRLEN); \ dest[CONFIG_MAX_STRLEN] = '\0'; \ } else { \ expect(fseek(f, pos, SEEK_SET) == 0); \ } \ } while (0) static void get_config(void) { FILE *f; f = fopen(CONFIG_PATH, "re"); if (!f) { if (errno != ENOENT) { err("%s: fopen: %s\n", CONFIG_PATH, strerror(errno)); exit_if_first_tick(); } return; } while (!feof(f)) { long pos = ftell(f); int ch; expect(pos >= 0); fscanf_int_for_key(f, pos, "max_temp", rules[FAN_MAX].threshold); fscanf_int_for_key(f, pos, "med_temp", rules[FAN_MED].threshold); fscanf_int_for_key(f, pos, "low_temp", rules[FAN_LOW].threshold); fscanf_int_for_key(f, pos, "watchdog_secs", watchdog_secs); fscanf_int_for_key(f, pos, "temp_hysteresis", temp_hysteresis); fscanf_str_for_key(f, pos, "max_level", rules[FAN_MAX].tpacpi_level); fscanf_str_for_key(f, pos, "med_level", rules[FAN_MED].tpacpi_level); fscanf_str_for_key(f, pos, "low_level", rules[FAN_LOW].tpacpi_level); if (ftell(f) == pos) { while ((ch = fgetc(f)) != EOF && ch != '\n') {} } } /* Maximum value handled by the kernel is 120, and * (watchdog_secs - WATCHDOG_GRACE_PERIOD_SECS) must stay positive. */ if (watchdog_secs < WATCHDOG_GRACE_PERIOD_SECS || watchdog_secs > DEFAULT_WATCHDOG_SECS) { err("%s: value for the watchdog_secs directive has to be between %d and %d\n", CONFIG_PATH, WATCHDOG_GRACE_PERIOD_SECS, DEFAULT_WATCHDOG_SECS); exit(1); } fclose(f); } static void print_thresholds(void) { for (size_t i = 0; i < FAN_OFF; i++) { const struct Rule *rule = rules + i; printf("[CFG] At %dC fan is set to %s\n", rule->threshold, rule->name); } } static void stop(int sig) { (void)sig; run = 0; } int main(int argc, char *argv[]) { const struct sigaction sa_exit = { .sa_handler = stop, }; (void)argv; if (argc != 1) { printf("zcfan: Zero-configuration ThinkPad fan daemon.\n\n"); printf(" [any argument] Show this help\n\n"); printf("See the zcfan(1) man page for details.\n"); return 0; } get_config(); print_thresholds(); expect(sigaction(SIGTERM, &sa_exit, NULL) == 0); expect(sigaction(SIGINT, &sa_exit, NULL) == 0); expect(setvbuf(stdout, output_buf, _IOLBF, sizeof(output_buf)) == 0); if (!full_speed_supported()) { err("level \"full-speed\" not supported, using level 7\n"); strncpy(rules[FAN_MAX].tpacpi_level, "7", CONFIG_MAX_STRLEN); rules[FAN_MAX].tpacpi_level[CONFIG_MAX_STRLEN] = '\0'; } write_watchdog_timeout(watchdog_secs); populate_temp_files(); while (run) { int set = set_fan_level(); if (!set) { maybe_ping_watchdog(); } if (run) { sleep(1); first_tick = 0; } } globfree(&temp_files); printf("[FAN] Quit requested, reenabling thinkpad_acpi fan control\n"); if (write_fan_level("auto") == 0) { write_watchdog_timeout(0); } } zcfan-1.4.0/zcfan.service.in000066400000000000000000000007321473041441300157100ustar00rootroot00000000000000[Unit] Description=Zero-configuration fan control for ThinkPad Conflicts=thinkfan.service [Service] ExecStart=@bindir@/zcfan Restart=always RestartSec=500ms MemoryDenyWriteExecute=yes NoNewPrivileges=yes ProtectControlGroups=yes RestrictAddressFamilies= RestrictRealtime=yes # We don't need to do any substantial clean up, so if something hangs it's # going to stay that way. Just forcefully kill and get it over with. TimeoutStopSec=2 [Install] WantedBy=default.target