diff --git a/fancontrol/Makefile b/fancontrol/Makefile index c606782b..96cd8845 100644 --- a/fancontrol/Makefile +++ b/fancontrol/Makefile @@ -1,9 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=fancontrol -PKG_VERSION:=1 -PKG_RELEASE:=1 - +PKG_VERSION:=2.0.0 +PKG_RELEASE:=2 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE @@ -16,16 +15,16 @@ include $(INCLUDE_DIR)/package.mk define Package/fancontrol SECTION:=utils CATEGORY:=Utilities - TITLE:=FanControl for OpenWRT + TITLE:=Generic hwmon PWM fan controller endef define Package/fancontrol/description - FanControl for OpenWRT. + Temperature-based continuous PWM fan controller using the Linux hwmon ABI. endef define Build/Prepare mkdir -p $(PKG_BUILD_DIR) - $(CP) ./src/* $(PKG_BUILD_DIR) + $(CP) ./src/Makefile ./src/fancontrol.c $(PKG_BUILD_DIR) endef MAKE_FLAGS += \ @@ -35,7 +34,7 @@ define Package/fancontrol/install $(INSTALL_DIR) $(1)/usr/bin $(INSTALL_BIN) $(PKG_BUILD_DIR)/fancontrol $(1)/usr/bin/fancontrol $(INSTALL_DIR) $(1)/etc/config - $(INSTALL_BIN) ./files/fancontrol.config $(1)/etc/config/fancontrol + $(INSTALL_CONF) ./files/fancontrol.config $(1)/etc/config/fancontrol $(INSTALL_DIR) $(1)/etc/init.d $(INSTALL_BIN) ./files/fancontrol.init $(1)/etc/init.d/fancontrol endef diff --git a/fancontrol/files/fancontrol.config b/fancontrol/files/fancontrol.config index 5225eded..dddab40c 100644 --- a/fancontrol/files/fancontrol.config +++ b/fancontrol/files/fancontrol.config @@ -1,8 +1,19 @@ -config fancontrol settings - option enabled '0' - option thermal_file '/sys/devices/virtual/thermal/thermal_zone0/temp' - option fan_file '/sys/devices/virtual/thermal/cooling_device0/cur_state' - option start_temp '45' - option start_speed '35' - option max_speed '255' - option temp_div '1000' \ No newline at end of file +config fancontrol 'settings' + option enabled '0' + option thermal_file 'auto' + option thermal_zone 'auto' + option fan_file 'auto' + option fan_hwmon 'auto' + option enable_file 'auto' + option start_temp '45' + option full_speed_temp '85' + option hysteresis '3' + option start_speed '64' + option max_speed '255' + option kick_speed '255' + option kick_ms '500' + option fail_safe_speed '255' + option exit_speed '255' + option temp_div '1000' + option interval '5' + option debug '0' diff --git a/fancontrol/files/fancontrol.init b/fancontrol/files/fancontrol.init index de6a0e68..d8b875a4 100755 --- a/fancontrol/files/fancontrol.init +++ b/fancontrol/files/fancontrol.init @@ -5,53 +5,51 @@ STOP=01 USE_PROCD=1 NAME=fancontrol -PROG=/usr/bin/$NAME +PROG=/usr/bin/fancontrol + +append_option() { + local option="$1" + local argument="$2" + local value + + config_get value settings "$option" + [ -n "$value" ] && procd_append_param command "$argument" "$value" +} start_service() { - config_load "$NAME" + local enabled + local debug - local enabled - config_get enabled settings enabled - [ "$enabled" = "1" ] || { _info "Instance \"$NAME\" disabled."; return 1; } + config_load "$NAME" + config_get_bool enabled settings enabled 0 + [ "$enabled" -eq 1 ] || return 0 - local thermal_file - local fan_file - local start_temp - local start_speed - local max_speed - local temp_div - - config_get thermal_file settings thermal_file - config_get fan_file settings fan_file - config_get start_temp settings start_temp - config_get start_speed settings start_speed - config_get max_speed settings max_speed - config_get temp_div settings temp_div - - procd_open_instance - procd_set_param command $PROG - [ -n "$thermal_file" ] && procd_append_param command -T "$thermal_file" - [ -n "$fan_file" ] && procd_append_param command -F "$fan_file" - [ -n "$start_temp" ] && procd_append_param command -t "$start_temp" - [ -n "$start_speed" ] && procd_append_param command -s "$start_speed" - [ -n "$max_speed" ] && procd_append_param command -m "$max_speed" - [ -n "$temp_div" ] && procd_append_param command -d "$temp_div" - procd_set_param respawn - procd_close_instance -} - -start() { - start_service -} - -stop() { - procd_kill_instance -} - -reload() { - procd_reload_instance + procd_open_instance + procd_set_param command "$PROG" + append_option thermal_file -T + append_option thermal_zone -Z + append_option fan_file -F + append_option fan_hwmon -N + append_option enable_file -E + append_option start_temp -t + append_option full_speed_temp -x + append_option hysteresis -H + append_option start_speed -s + append_option max_speed -m + append_option kick_speed -k + append_option kick_ms -K + append_option fail_safe_speed -f + append_option exit_speed -q + append_option temp_div -d + append_option interval -i + config_get_bool debug settings debug 0 + [ "$debug" -eq 1 ] && procd_append_param command -D + procd_set_param respawn 3600 5 5 + procd_set_param term_timeout 10 + procd_set_param file /etc/config/fancontrol + procd_close_instance } service_triggers() { - procd_add_reload_trigger "fancontrol" -} \ No newline at end of file + procd_add_reload_trigger fancontrol +} diff --git a/fancontrol/src/Makefile b/fancontrol/src/Makefile index 459df9f5..949f5984 100644 --- a/fancontrol/src/Makefile +++ b/fancontrol/src/Makefile @@ -1,5 +1,6 @@ -CC := gcc -CFLAGS := -O3 -Wall -fomit-frame-pointer +CC ?= cc +CFLAGS ?= -O2 +CFLAGS += -Wall -Wextra -Wformat=2 all: fancontrol @@ -7,7 +8,7 @@ fancontrol: %: %.o $(CC) $(LDFLAGS) -o $@ $^ %.o: %.c - $(CC) $(CFLAGS) $(INCLUDE) -o $@ -c $< + $(CC) $(CPPFLAGS) $(CFLAGS) -o $@ -c $< clean: - rm -f fancontrol *.o *~ \ No newline at end of file + rm -f fancontrol *.o *~ diff --git a/fancontrol/src/fancontrol.c b/fancontrol/src/fancontrol.c index c1f814b6..7a051cb1 100644 --- a/fancontrol/src/fancontrol.c +++ b/fancontrol/src/fancontrol.c @@ -1,200 +1,791 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include +#include +#include #include #include #include -#include #include -#include +#include +#include +#include -#define MAX_LENGTH 200 -#define MAX_TEMP 120 +#define PROGRAM_NAME "fancontrol" +#define PROGRAM_VERSION "2.0.0" +#define AUTO_VALUE "auto" +#define DEFAULT_SYSFS_ROOT "/sys" +#define DEFAULT_STATUS_FILE "/var/run/fancontrol.status" +#define MAX_HWMON_CHANNELS 32 -// 定义全局变量 -char thermal_file[MAX_LENGTH] = "/sys/devices/virtual/thermal/thermal_zone0/temp"; // -T -char fan_file[MAX_LENGTH] = "/sys/devices/virtual/thermal/cooling_device0/cur_state"; // -F +struct fan_config { + char thermal_file[PATH_MAX]; + char thermal_zone[128]; + char fan_file[PATH_MAX]; + char fan_hwmon[128]; + char enable_file[PATH_MAX]; + int start_temp; + int full_speed_temp; + int hysteresis; + int start_pwm; + int max_pwm; + int kick_pwm; + int kick_ms; + int fail_safe_pwm; + int exit_pwm; + int temp_div; + int interval; + bool debug; + bool once; +}; -int start_speed = 35; // -s -int start_temp = 45; // -t -int max_speed = 255; // -m -int temp_div = 1000; // -d -int debug_mode = 0; // -D +struct fan_runtime { + char thermal_file[PATH_MAX]; + char fan_file[PATH_MAX]; + char enable_file[PATH_MAX]; + char rpm_file[PATH_MAX]; + bool fan_active; +}; -/** - * 底层读文件 - */ -static int read_file(const char* path ,char* result ,size_t size) { - FILE* fp; - char* line = NULL; - size_t len = 0; - ssize_t read; +static const char *sysfs_root = DEFAULT_SYSFS_ROOT; +static const char *status_file = DEFAULT_STATUS_FILE; +static volatile sig_atomic_t stop_requested; - fp = fopen(path ,"r"); - if (fp == NULL) - return -1; - - if (( read = getline(&line ,&len ,fp) ) != -1) { - if (size != 0) - memcpy(result ,line ,size); - else - memcpy(result ,line ,read - 1); - } - - fclose(fp); - if (line) - free(line); - return 0; +static void usage(FILE *stream, const char *name) +{ + fprintf(stream, + "Usage: %s [options]\n" + " -T path temperature sysfs path or 'auto'\n" + " -Z type preferred thermal zone type or 'auto'\n" + " -F path PWM sysfs path or 'auto'\n" + " -N name preferred hwmon name or 'auto'\n" + " -E path pwm enable sysfs path or 'auto'\n" + " -t degrees fan start temperature\n" + " -x degrees full-speed temperature\n" + " -H degrees stop hysteresis\n" + " -s pwm minimum running PWM (1-255)\n" + " -m pwm maximum PWM (1-255)\n" + " -k pwm start kick PWM, 0 disables the kick\n" + " -K ms start kick duration in milliseconds\n" + " -f pwm PWM used after a sensor failure\n" + " -q pwm PWM used when the daemon exits\n" + " -d divisor temperature input divisor\n" + " -i seconds polling interval\n" + " -D enable debug logging\n" + " -1 run one control cycle and exit\n" + " -v print version\n" + " -h show this help\n", + name); } -/** - * 底层写文件 - */ -static size_t write_file(const char* path ,char* buf ,size_t len) { - FILE* fp = NULL; - size_t size = 0; - fp = fopen(path ,"w+"); - if (fp == NULL) { - return 0; - } - size = fwrite(buf ,len ,1 ,fp); - fclose(fp); - return size; +static int copy_string(char *dest, size_t size, const char *source) +{ + int len; + + if (!source || strpbrk(source, "\r\n")) + return -1; + + len = snprintf(dest, size, "%s", source); + return len < 0 || (size_t)len >= size ? -1 : 0; } -/** - * 读取温度 - */ -int get_temperature(char* thermal_file ,int div) { - char buf[8] = { 0 }; - if (read_file(thermal_file ,buf ,0) == 0) { - return atoi(buf) / div; - } - return -1; +static bool is_auto(const char *value) +{ + return !value[0] || strcmp(value, AUTO_VALUE) == 0; } -/** - * 读取风扇速度 - */ -int get_fanspeed(char* fan_file) { - char buf[8] = { 0 }; - if (read_file(fan_file ,buf ,0) == 0) { - return atoi(buf); - } - return -1; +static int parse_int(const char *text, int min, int max, const char *name) +{ + char *end = NULL; + long value; + + errno = 0; + value = strtol(text, &end, 10); + if (errno || !end || *end || value < min || value > max) { + fprintf(stderr, "Invalid %s: %s\n", name, text); + exit(EXIT_FAILURE); + } + + return (int)value; } -/** - * 设置风扇转速 - */ -int set_fanspeed(int fan_speed ,char* fan_file) { - char buf[8] = { 0 }; - sprintf(buf ,"%d\n" ,fan_speed); - return write_file(fan_file ,buf ,strlen(buf)); +static int read_text_file(const char *path, char *buffer, size_t size) +{ + ssize_t length; + int fd; + + if (!size) + return -1; + + fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) + return -1; + + length = read(fd, buffer, size - 1); + close(fd); + if (length <= 0) + return -1; + + buffer[length] = '\0'; + buffer[strcspn(buffer, "\r\n")] = '\0'; + return 0; } -/** - * 计算风扇转速 - */ -int calculate_speed(int current_temp ,int max_temp ,int min_temp ,int max_speed ,int min_speed) { - if (current_temp < min_temp) { - return 0; - } - int fan_speed = ( current_temp - min_temp ) * ( max_speed - min_speed ) / ( max_temp - min_temp ) + min_speed; - if (fan_speed > max_speed) { - fan_speed = max_speed; - } - return fan_speed; +static int read_long_file(const char *path, long *value) +{ + char buffer[64]; + char *end = NULL; + long parsed; + + if (read_text_file(path, buffer, sizeof(buffer))) + return -1; + + errno = 0; + parsed = strtol(buffer, &end, 10); + if (errno || end == buffer) + return -1; + + while (*end == ' ' || *end == '\t') + end++; + if (*end) + return -1; + + *value = parsed; + return 0; } -/** - * 判断文件是否存在方法 - */ -static int file_exist(const char* name) { - struct stat buffer; - return stat(name ,&buffer); +static int write_long_file(const char *path, int value) +{ + char buffer[32]; + ssize_t written; + int length; + int fd; + + length = snprintf(buffer, sizeof(buffer), "%d\n", value); + if (length < 0 || (size_t)length >= sizeof(buffer)) + return -1; + + fd = open(path, O_WRONLY | O_CLOEXEC); + if (fd < 0) + return -1; + + written = write(fd, buffer, (size_t)length); + if (close(fd) || written != length) + return -1; + + return 0; } -/** - * 信号处理函数 - */ -void handle_termination(int signum) { - // 设置风扇转速为 0 - set_fanspeed(0 ,fan_file); - exit(EXIT_SUCCESS); // 优雅地退出程序 +static void sleep_ms(unsigned int milliseconds) +{ + struct timespec remaining = { + .tv_sec = milliseconds / 1000, + .tv_nsec = (long)(milliseconds % 1000) * 1000000L, + }; + + while (!stop_requested && nanosleep(&remaining, &remaining) && errno == EINTR) + ; } -/** - * 注册信号处理函数 - */ -void register_signal_handlers( ) { - struct sigaction sa; - memset(&sa ,0 ,sizeof(sa)); - sa.sa_handler = handle_termination; - sigemptyset(&sa.sa_mask); - sigaction(SIGINT ,&sa ,NULL); - sigaction(SIGTERM ,&sa ,NULL); +static int preferred_thermal_score(const char *type) +{ + static const char * const preferred[] = { + "cpu-top-thermal", "cpu_top_thermal", "cpu-thermal", + "cpu_thermal", "soc-thermal", "soc_thermal", + }; + size_t i; + + for (i = 0; i < sizeof(preferred) / sizeof(preferred[0]); i++) + if (strcmp(type, preferred[i]) == 0) + return (int)i; + + return 100; } -/** - * 主函数 - */ -int main(int argc ,char* argv[ ]) { - // 解析命令行选项 - int opt; - while (( opt = getopt(argc ,argv ,"T:F:s:t:m:d:D:v:") ) != -1) { - switch (opt) { - case 'T': - snprintf(thermal_file ,sizeof(thermal_file) ,"%s" ,optarg); - break; - case 'F': - snprintf(fan_file ,sizeof(fan_file) ,"%s" ,optarg); - break; - case 's': - start_speed = atoi(optarg); - break; - case 't': - start_temp = atoi(optarg); - break; - case 'm': - max_speed = atoi(optarg); - break; - case 'd': - temp_div = atoi(optarg); - break; - case 'D': - debug_mode = atoi(optarg); - break; - default: - fprintf(stderr ,"Usage: %s [option]\n" - " -T sysfs # temperature sysfs file, default is '%s'\n" - " -F sysfs # fan sysfs file, default is '%s'\n" - " -s speed # initial speed for fan startup, default is %d\n" - " -t temperature # fan start temperature, default is %d°C\n" - " -m speed # fan maximum speed, default is %d\n" - " -d div # temperature divide, default is %d\n" - " -v # verbose\n" ,argv[0] ,thermal_file ,fan_file ,start_speed ,start_temp ,max_speed ,temp_div); - exit(EXIT_FAILURE); - } - } - // 检测虚拟文件是否存在 - if (file_exist(fan_file) != 0 || file_exist(thermal_file) != 0) { - fprintf(stderr ,"File: '%s' or '%s' not exist\n" ,fan_file ,thermal_file); - exit(EXIT_FAILURE); - } +static int preferred_hwmon_temp_score(const char *name) +{ + if (strstr(name, "cpu") || strstr(name, "CPU")) + return 0; + if (strstr(name, "soc") || strstr(name, "SoC")) + return 1; - // 注册退出信号 - register_signal_handlers( ); - - // 监控风扇 - while (1) { - int temperature = get_temperature(thermal_file ,temp_div); - // 有效温度时设置风扇速度 - if (temperature > 0) { - int fan_speed = calculate_speed(temperature ,MAX_TEMP ,start_temp ,max_speed ,start_speed); - set_fanspeed(fan_speed ,fan_file); - } - if (debug_mode) { - fprintf(stdout ,"Temperature: %d°C, Fan Speed: %d\n" ,get_temperature(thermal_file ,temp_div) ,get_fanspeed(fan_file)); - } - sleep(5); - } - return 0; + return 100; +} + +static int discover_hwmon_temperature(char *result, size_t result_size) +{ + char pattern[PATH_MAX]; + char name_file[PATH_MAX]; + char hwmon_name[128]; + char candidate[PATH_MAX]; + char best[PATH_MAX] = ""; + glob_t paths = { 0 }; + int best_score = INT_MAX; + size_t i; + + if (snprintf(pattern, sizeof(pattern), "%s/class/hwmon/hwmon*", sysfs_root) >= + (int)sizeof(pattern)) + return -1; + if (glob(pattern, 0, NULL, &paths)) + return -1; + + for (i = 0; i < paths.gl_pathc; i++) { + int channel; + int score; + + if (snprintf(name_file, sizeof(name_file), "%s/name", paths.gl_pathv[i]) >= + (int)sizeof(name_file) || + read_text_file(name_file, hwmon_name, sizeof(hwmon_name))) + hwmon_name[0] = '\0'; + score = preferred_hwmon_temp_score(hwmon_name); + + for (channel = 1; channel <= MAX_HWMON_CHANNELS; channel++) { + if (snprintf(candidate, sizeof(candidate), "%s/temp%d_input", + paths.gl_pathv[i], channel) >= (int)sizeof(candidate)) + continue; + if (access(candidate, R_OK)) + continue; + if (score < best_score) { + copy_string(best, sizeof(best), candidate); + best_score = score; + } + break; + } + } + + globfree(&paths); + if (!best[0]) + return -1; + + return copy_string(result, result_size, best); +} + +static int discover_thermal_file(const struct fan_config *config, + char *result, size_t result_size) +{ + char pattern[PATH_MAX]; + char type_file[PATH_MAX]; + char type[128]; + char best[PATH_MAX] = ""; + glob_t paths = { 0 }; + int best_score = INT_MAX; + size_t i; + + if (snprintf(pattern, sizeof(pattern), "%s/class/thermal/thermal_zone*/temp", + sysfs_root) >= (int)sizeof(pattern)) + return -1; + + if (glob(pattern, 0, NULL, &paths)) { + if (is_auto(config->thermal_zone)) + return discover_hwmon_temperature(result, result_size); + return -1; + } + + for (i = 0; i < paths.gl_pathc; i++) { + char *slash; + int score; + + if (access(paths.gl_pathv[i], R_OK)) + continue; + + if (copy_string(type_file, sizeof(type_file), paths.gl_pathv[i])) + continue; + slash = strrchr(type_file, '/'); + if (!slash) + continue; + copy_string(slash + 1, sizeof(type_file) - (size_t)(slash + 1 - type_file), + "type"); + if (read_text_file(type_file, type, sizeof(type))) + type[0] = '\0'; + + if (!is_auto(config->thermal_zone)) { + if (strcmp(config->thermal_zone, type) != 0) + continue; + score = 0; + } else { + score = preferred_thermal_score(type); + } + + if (score < best_score) { + copy_string(best, sizeof(best), paths.gl_pathv[i]); + best_score = score; + } + } + + globfree(&paths); + if (!best[0]) { + if (is_auto(config->thermal_zone)) + return discover_hwmon_temperature(result, result_size); + return -1; + } + + return copy_string(result, result_size, best); +} + +static bool valid_pwm_basename(const char *name, int *channel) +{ + char trailing; + int parsed; + + if (sscanf(name, "pwm%d%c", &parsed, &trailing) != 1 || + parsed < 1 || parsed > MAX_HWMON_CHANNELS) + return false; + + *channel = parsed; + return true; +} + +static int derive_fan_paths(const char *pwm_file, struct fan_runtime *runtime) +{ + char directory[PATH_MAX]; + const char *basename; + char *slash; + int channel; + int length; + + if (copy_string(directory, sizeof(directory), pwm_file)) + return -1; + slash = strrchr(directory, '/'); + if (!slash) + return -1; + basename = slash + 1; + if (!valid_pwm_basename(basename, &channel)) + return 0; + *slash = '\0'; + + if (is_auto(runtime->enable_file)) { + length = snprintf(runtime->enable_file, sizeof(runtime->enable_file), + "%s/pwm%d_enable", directory, channel); + if (length < 0 || (size_t)length >= sizeof(runtime->enable_file)) + return -1; + if (access(runtime->enable_file, W_OK)) + runtime->enable_file[0] = '\0'; + } + + length = snprintf(runtime->rpm_file, sizeof(runtime->rpm_file), + "%s/fan%d_input", directory, channel); + if (length < 0 || (size_t)length >= sizeof(runtime->rpm_file)) + return -1; + if (access(runtime->rpm_file, R_OK)) + runtime->rpm_file[0] = '\0'; + + return 0; +} + +static int discover_fan_file(const struct fan_config *config, + struct fan_runtime *runtime) +{ + char pattern[PATH_MAX]; + char name_file[PATH_MAX]; + char hwmon_name[128]; + char candidate[PATH_MAX]; + char best[PATH_MAX] = ""; + glob_t paths = { 0 }; + int best_score = INT_MAX; + size_t i; + + if (snprintf(pattern, sizeof(pattern), "%s/class/hwmon/hwmon*", sysfs_root) >= + (int)sizeof(pattern)) + return -1; + if (glob(pattern, 0, NULL, &paths)) + return -1; + + for (i = 0; i < paths.gl_pathc; i++) { + int channel; + int score; + + snprintf(name_file, sizeof(name_file), "%s/name", paths.gl_pathv[i]); + if (read_text_file(name_file, hwmon_name, sizeof(hwmon_name))) + continue; + + if (!is_auto(config->fan_hwmon)) { + if (strcmp(config->fan_hwmon, hwmon_name) != 0) + continue; + score = 0; + } else { + score = strcmp(hwmon_name, "pwmfan") == 0 ? 0 : 100; + } + + for (channel = 1; channel <= MAX_HWMON_CHANNELS; channel++) { + snprintf(candidate, sizeof(candidate), "%s/pwm%d", + paths.gl_pathv[i], channel); + if (access(candidate, R_OK | W_OK)) + continue; + if (score < best_score) { + copy_string(best, sizeof(best), candidate); + best_score = score; + } + break; + } + } + + globfree(&paths); + if (!best[0]) + return -1; + if (copy_string(runtime->fan_file, sizeof(runtime->fan_file), best)) + return -1; + + return derive_fan_paths(runtime->fan_file, runtime); +} + +static bool is_cooling_state_path(const char *path) +{ + return strstr(path, "/cooling_device") && + strcmp(strrchr(path, '/') ? strrchr(path, '/') + 1 : path, + "cur_state") == 0; +} + +static int resolve_paths(const struct fan_config *config, + struct fan_runtime *runtime) +{ + memset(runtime, 0, sizeof(*runtime)); + copy_string(runtime->enable_file, sizeof(runtime->enable_file), + config->enable_file); + + if (is_auto(config->thermal_file)) { + if (discover_thermal_file(config, runtime->thermal_file, + sizeof(runtime->thermal_file))) { + syslog(LOG_ERR, "no readable thermal sensor was found"); + return -1; + } + } else if (copy_string(runtime->thermal_file, + sizeof(runtime->thermal_file), config->thermal_file) || + access(runtime->thermal_file, R_OK)) { + syslog(LOG_ERR, "temperature input is not readable: %s", + config->thermal_file); + return -1; + } + + if (is_auto(config->fan_file)) { + if (discover_fan_file(config, runtime)) { + syslog(LOG_ERR, "no writable hwmon PWM output was found"); + return -1; + } + } else { + if (is_cooling_state_path(config->fan_file)) { + syslog(LOG_ERR, + "%s is a discrete cooling state, not a continuous PWM output", + config->fan_file); + return -1; + } + if (copy_string(runtime->fan_file, sizeof(runtime->fan_file), + config->fan_file) || access(runtime->fan_file, R_OK | W_OK)) { + syslog(LOG_ERR, "PWM output is not readable and writable: %s", + config->fan_file); + return -1; + } + if (derive_fan_paths(runtime->fan_file, runtime)) { + syslog(LOG_ERR, "failed to derive controls for PWM output: %s", + runtime->fan_file); + return -1; + } + } + + if (!is_auto(config->enable_file)) { + if (copy_string(runtime->enable_file, sizeof(runtime->enable_file), + config->enable_file) || access(runtime->enable_file, W_OK)) { + syslog(LOG_ERR, "PWM enable control is not writable: %s", + config->enable_file); + return -1; + } + } + + return 0; +} + +static int calculate_pwm(const struct fan_config *config, int64_t temp_mc, + bool fan_active) +{ + int64_t start_mc = (int64_t)config->start_temp * 1000; + int64_t stop_mc = (int64_t)(config->start_temp - config->hysteresis) * 1000; + int64_t full_mc = (int64_t)config->full_speed_temp * 1000; + int64_t pwm; + + if ((!fan_active && temp_mc < start_mc) || + (fan_active && temp_mc <= stop_mc)) + return 0; + if (temp_mc <= start_mc) + return config->start_pwm; + if (temp_mc >= full_mc) + return config->max_pwm; + + pwm = config->start_pwm + + (temp_mc - start_mc) * (config->max_pwm - config->start_pwm) / + (full_mc - start_mc); + return (int)pwm; +} + +static int set_pwm(const struct fan_runtime *runtime, int pwm) +{ + if (write_long_file(runtime->fan_file, pwm)) { + syslog(LOG_ERR, "failed to write PWM %d to %s: %s", pwm, + runtime->fan_file, strerror(errno)); + return -1; + } + return 0; +} + +static void write_status(const struct fan_runtime *runtime, int64_t temp_mc, + int pwm, long rpm, const char *error) +{ + char temporary[PATH_MAX]; + FILE *stream; + + if (snprintf(temporary, sizeof(temporary), "%s.%ld", status_file, + (long)getpid()) >= (int)sizeof(temporary)) + return; + + stream = fopen(temporary, "w"); + if (!stream) + return; + + fprintf(stream, + "running=1\n" + "fan_active=%d\n" + "temperature_mc=%lld\n" + "pwm=%d\n" + "rpm=%ld\n" + "thermal_file=%s\n" + "fan_file=%s\n" + "error=%s\n", + runtime->fan_active ? 1 : 0, (long long)temp_mc, pwm, rpm, + runtime->thermal_file, runtime->fan_file, error ? error : ""); + if (fclose(stream) || rename(temporary, status_file)) + unlink(temporary); +} + +static void signal_handler(int signal_number) +{ + (void)signal_number; + stop_requested = 1; +} + +static int install_signal_handlers(void) +{ + struct sigaction action = { + .sa_handler = signal_handler, + }; + + sigemptyset(&action.sa_mask); + return sigaction(SIGINT, &action, NULL) || + sigaction(SIGTERM, &action, NULL); +} + +static void set_defaults(struct fan_config *config) +{ + memset(config, 0, sizeof(*config)); + copy_string(config->thermal_file, sizeof(config->thermal_file), AUTO_VALUE); + copy_string(config->thermal_zone, sizeof(config->thermal_zone), AUTO_VALUE); + copy_string(config->fan_file, sizeof(config->fan_file), AUTO_VALUE); + copy_string(config->fan_hwmon, sizeof(config->fan_hwmon), AUTO_VALUE); + copy_string(config->enable_file, sizeof(config->enable_file), AUTO_VALUE); + config->start_temp = 45; + config->full_speed_temp = 85; + config->hysteresis = 3; + config->start_pwm = 64; + config->max_pwm = 255; + config->kick_pwm = 255; + config->kick_ms = 500; + config->fail_safe_pwm = 255; + config->exit_pwm = 255; + config->temp_div = 1000; + config->interval = 5; +} + +static void parse_options(int argc, char **argv, struct fan_config *config) +{ + int option; + + while ((option = getopt(argc, argv, "T:Z:F:N:E:t:x:H:s:m:k:K:f:q:d:i:D1vh")) != -1) { + switch (option) { + case 'T': + if (copy_string(config->thermal_file, + sizeof(config->thermal_file), optarg)) + exit(EXIT_FAILURE); + break; + case 'Z': + if (copy_string(config->thermal_zone, sizeof(config->thermal_zone), optarg)) + exit(EXIT_FAILURE); + break; + case 'F': + if (copy_string(config->fan_file, sizeof(config->fan_file), optarg)) + exit(EXIT_FAILURE); + break; + case 'N': + if (copy_string(config->fan_hwmon, sizeof(config->fan_hwmon), optarg)) + exit(EXIT_FAILURE); + break; + case 'E': + if (copy_string(config->enable_file, sizeof(config->enable_file), optarg)) + exit(EXIT_FAILURE); + break; + case 't': + config->start_temp = parse_int(optarg, -100, 200, "start temperature"); + break; + case 'x': + config->full_speed_temp = parse_int(optarg, -99, 250, + "full-speed temperature"); + break; + case 'H': + config->hysteresis = parse_int(optarg, 0, 50, "hysteresis"); + break; + case 's': + config->start_pwm = parse_int(optarg, 1, 255, "start PWM"); + break; + case 'm': + config->max_pwm = parse_int(optarg, 1, 255, "maximum PWM"); + break; + case 'k': + config->kick_pwm = parse_int(optarg, 0, 255, "kick PWM"); + break; + case 'K': + config->kick_ms = parse_int(optarg, 0, 10000, "kick duration"); + break; + case 'f': + config->fail_safe_pwm = parse_int(optarg, 0, 255, "fail-safe PWM"); + break; + case 'q': + config->exit_pwm = parse_int(optarg, 0, 255, "exit PWM"); + break; + case 'd': + config->temp_div = parse_int(optarg, 1, 1000000, + "temperature divisor"); + break; + case 'i': + config->interval = parse_int(optarg, 1, 300, "polling interval"); + break; + case 'D': + config->debug = true; + break; + case '1': + config->once = true; + break; + case 'v': + printf("%s %s\n", PROGRAM_NAME, PROGRAM_VERSION); + exit(EXIT_SUCCESS); + case 'h': + usage(stdout, argv[0]); + exit(EXIT_SUCCESS); + default: + usage(stderr, argv[0]); + exit(EXIT_FAILURE); + } + } +} + +static int validate_config(const struct fan_config *config) +{ + if (config->full_speed_temp <= config->start_temp) { + fprintf(stderr, "Full-speed temperature must exceed start temperature\n"); + return -1; + } + if (config->start_pwm > config->max_pwm) { + fprintf(stderr, "Start PWM must not exceed maximum PWM\n"); + return -1; + } + if (config->kick_ms && !config->kick_pwm) { + fprintf(stderr, "Kick PWM must be non-zero when kick duration is enabled\n"); + return -1; + } + return 0; +} + +int main(int argc, char **argv) +{ + struct fan_config config; + struct fan_runtime runtime; + const char *environment; + bool sensor_failed = false; + int exit_code = EXIT_SUCCESS; + + set_defaults(&config); + parse_options(argc, argv, &config); + if (validate_config(&config)) + return EXIT_FAILURE; + + environment = getenv("FANCONTROL_SYSFS_ROOT"); + if (environment && environment[0]) + sysfs_root = environment; + environment = getenv("FANCONTROL_STATUS_FILE"); + if (environment && environment[0]) + status_file = environment; + + openlog(PROGRAM_NAME, LOG_PID | LOG_CONS, LOG_DAEMON); + if (install_signal_handlers()) { + syslog(LOG_ERR, "failed to install signal handlers: %s", strerror(errno)); + return EXIT_FAILURE; + } + + if (resolve_paths(&config, &runtime)) + return EXIT_FAILURE; + + if (runtime.enable_file[0] && write_long_file(runtime.enable_file, 1)) + syslog(LOG_WARNING, "failed to select manual PWM mode through %s: %s", + runtime.enable_file, strerror(errno)); + + syslog(LOG_NOTICE, "using temperature input %s and PWM output %s", + runtime.thermal_file, runtime.fan_file); + + while (!stop_requested) { + long raw_temp; + long actual_pwm = -1; + long rpm = -1; + int64_t temp_mc = -1; + int target_pwm; + + if (read_long_file(runtime.thermal_file, &raw_temp)) { + target_pwm = config.fail_safe_pwm; + if (set_pwm(&runtime, target_pwm)) + exit_code = EXIT_FAILURE; + runtime.fan_active = target_pwm > 0; + write_status(&runtime, temp_mc, target_pwm, rpm, + "temperature-read-failed"); + if (!sensor_failed) + syslog(LOG_ERR, + "failed to read temperature from %s; using fail-safe PWM %d", + runtime.thermal_file, target_pwm); + sensor_failed = true; + } else { + if (sensor_failed) + syslog(LOG_NOTICE, "temperature input %s recovered", + runtime.thermal_file); + sensor_failed = false; + temp_mc = (int64_t)raw_temp * 1000 / config.temp_div; + target_pwm = calculate_pwm(&config, temp_mc, runtime.fan_active); + + if (target_pwm > 0 && !runtime.fan_active && + config.kick_pwm > 0 && config.kick_ms > 0) { + if (!set_pwm(&runtime, config.kick_pwm)) + sleep_ms((unsigned int)config.kick_ms); + } + + if (!stop_requested && + (read_long_file(runtime.fan_file, &actual_pwm) || + actual_pwm != target_pwm) && set_pwm(&runtime, target_pwm)) + exit_code = EXIT_FAILURE; + + runtime.fan_active = target_pwm > 0; + if (runtime.rpm_file[0]) + read_long_file(runtime.rpm_file, &rpm); + write_status(&runtime, temp_mc, target_pwm, rpm, NULL); + + if (config.debug) + syslog(LOG_DEBUG, "temperature=%lldmC pwm=%d rpm=%ld", + (long long)temp_mc, target_pwm, rpm); + } + + if (config.once) + break; + sleep_ms((unsigned int)config.interval * 1000); + } + + if (!config.once && set_pwm(&runtime, config.exit_pwm)) + exit_code = EXIT_FAILURE; + unlink(status_file); + closelog(); + return exit_code; } diff --git a/luci-app-fancontrol/Makefile b/luci-app-fancontrol/Makefile index a1960af7..d3228cbf 100644 --- a/luci-app-fancontrol/Makefile +++ b/luci-app-fancontrol/Makefile @@ -6,13 +6,16 @@ include $(TOPDIR)/rules.mk -LUCI_TITLE:=LuCI support for Fan Controller -LUCI_DEPENDS:=+luci +fancontrol +LUCI_TITLE:=LuCI support for the generic PWM fan controller +LUCI_DEPENDS:=+luci-base +fancontrol LUCI_PKGARCH:=all +PKG_LICENSE:=Apache-2.0 +PKG_MAINTAINER:=JiaY-shi + PKG_NAME:=luci-app-fancontrol -PKG_VERSION:=1.0 -PKG_RELEASE:=1 +PKG_VERSION:=2.0.0 +PKG_RELEASE:=2 include $(TOPDIR)/feeds/luci/luci.mk -# call BuildPackage - OpenWrt buildroot signature \ No newline at end of file +# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.css b/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.css new file mode 100644 index 00000000..76e3a15c --- /dev/null +++ b/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.css @@ -0,0 +1,164 @@ +.fancontrol-runtime { + margin-bottom: 1.5rem; +} + +.fancontrol-status { + width: 100%; + max-width: 1200px; + overflow: hidden; + border: 1px solid rgba(127, 127, 127, .28); + border-radius: 6px; +} + +.fancontrol-status-head { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 0; + padding: .4rem .7rem; + background: rgba(127, 127, 127, .08); + border-bottom: 1px solid rgba(127, 127, 127, .22); +} + +.fancontrol-status-title { + font-size: .88rem; + font-weight: 600; +} + +.fancontrol-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.fancontrol-metric { + min-width: 0; + min-height: 0; + padding: .55rem .7rem; + border-right: 1px solid rgba(127, 127, 127, .2); +} + +.fancontrol-metric:last-child { + border-right: 0; +} + +.fancontrol-metric-label, +.fancontrol-detail-label { + display: block; + color: var(--text-color-medium, inherit); + font-size: .72rem; +} + +.fancontrol-metric-reading { + display: flex; + align-items: baseline; + gap: .3rem; + min-height: 0; + margin-top: .12rem; + white-space: nowrap; +} + +.fancontrol-metric-value { + font-size: 1.18rem; + font-weight: 600; + line-height: 1.3; +} + +.fancontrol-metric-unit { + overflow: hidden; + color: var(--text-color-medium, inherit); + font-size: .72rem; + text-overflow: ellipsis; +} + +.fancontrol-level { + height: 4px; + margin-top: .35rem; + overflow: hidden; + background: rgba(127, 127, 127, .2); + border-radius: 3px; +} + +.fancontrol-level > div { + height: 100%; + background: var(--primary-color-medium, #1976d2); + border-radius: 3px; + transition: width .25s ease-in; +} + +.fancontrol-details { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: .3rem 1rem; + padding: .42rem .7rem; + background: rgba(127, 127, 127, .04); + border-top: 1px solid rgba(127, 127, 127, .2); +} + +.fancontrol-detail { + display: grid; + grid-template-columns: minmax(7rem, .34fr) minmax(0, 1fr); + align-items: baseline; + gap: .45rem; + min-width: 0; +} + +.fancontrol-detail:last-child { + grid-column: 1 / -1; +} + +.fancontrol-detail-value { + min-width: 0; + overflow-wrap: anywhere; +} + +.fancontrol-ok { + color: var(--success-color-medium, #00884a); +} + +@media (max-width: 700px) { + .fancontrol-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .fancontrol-metric:nth-child(2) { + border-right: 0; + } + + .fancontrol-metric:last-child { + grid-column: 1 / -1; + border-top: 1px solid rgba(127, 127, 127, .2); + } + + .fancontrol-details { + grid-template-columns: 1fr; + } + + .fancontrol-detail:last-child { + grid-column: auto; + } +} + +@media (max-width: 420px) { + .fancontrol-metrics { + grid-template-columns: 1fr; + } + + .fancontrol-metric, + .fancontrol-metric:nth-child(2) { + border-right: 0; + border-top: 1px solid rgba(127, 127, 127, .2); + } + + .fancontrol-metric:first-child { + border-top: 0; + } + + .fancontrol-metric:last-child { + grid-column: auto; + } + + .fancontrol-detail { + grid-template-columns: 1fr; + gap: .15rem; + } +} diff --git a/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js b/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js index d1485d4f..887c366e 100644 --- a/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js +++ b/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js @@ -1,50 +1,270 @@ 'use strict'; -'require view'; -'require fs'; +'require dom'; 'require form'; +'require fs'; +'require poll'; +'require rpc'; 'require uci'; -'require tools.widgets as widgets'; +'require view'; + +const callServiceList = rpc.declare({ + object: 'service', + method: 'list', + params: [ 'name' ], + expect: { '': {} } +}); + +function serviceRunning(result) { + const instances = result?.fancontrol?.instances || {}; + + return Object.keys(instances).some(function(name) { + return instances[name].running === true; + }); +} + +function parseStatus(text) { + const status = {}; + + String(text || '').split(/\n/).forEach(function(line) { + const separator = line.indexOf('='); + + if (separator > 0) + status[line.substring(0, separator)] = line.substring(separator + 1); + }); + + return status; +} + +function statusMetric(title, value, unit, level) { + const children = [ + E('span', { 'class': 'fancontrol-metric-label' }, title), + E('div', { 'class': 'fancontrol-metric-reading' }, [ + E('span', { 'class': 'fancontrol-metric-value' }, value), + unit ? E('span', { 'class': 'fancontrol-metric-unit' }, unit) : '' + ]) + ]; + + if (level != null) + children.push(E('div', { 'class': 'fancontrol-level', 'aria-hidden': 'true' }, + E('div', { 'style': 'width:%d%%'.format(level) }))); + + return E('div', { 'class': 'fancontrol-metric' }, children); +} + +function statusDetail(title, value) { + return E('div', { 'class': 'fancontrol-detail' }, [ + E('span', { 'class': 'fancontrol-detail-label' }, title), + E('span', { 'class': 'fancontrol-detail-value' }, value) + ]); +} + +function renderStatus(node, running, status) { + const temp = Number(status.temperature_mc); + const pwm = Number(status.pwm); + const rpm = Number(status.rpm); + const tempValid = Number.isFinite(temp) && temp >= 0; + const pwmValid = Number.isFinite(pwm) && pwm >= 0; + const rpmValid = Number.isFinite(rpm) && rpm >= 0; + const pwmPercent = pwmValid ? Math.round(pwm * 100 / 255) : null; + const fault = status.error + ? E('span', { 'class': 'label warning' }, status.error) + : E('span', { 'class': 'fancontrol-ok' }, _('None')); + + dom.content(node, E('div', { 'class': 'fancontrol-status' }, [ + E('div', { 'class': 'fancontrol-status-head' }, [ + E('span', { 'class': 'fancontrol-status-title' }, _('Runtime Status')), + E('span', { 'class': running ? 'label success' : 'label warning' }, + running ? _('Running') : _('Stopped')) + ]), + E('div', { 'class': 'fancontrol-metrics' }, [ + statusMetric(_('Temperature'), tempValid ? '%.1f'.format(temp / 1000) : '-', + tempValid ? '°C' : ''), + statusMetric(_('PWM output'), pwmValid ? String(pwm) : '-', + pwmValid ? '/ 255 · %d%%'.format(pwmPercent) : '', pwmPercent), + statusMetric(_('Fan speed'), rpmValid ? String(rpm) : '-', rpmValid ? 'RPM' : '') + ]), + E('div', { 'class': 'fancontrol-details' }, [ + statusDetail(_('Temperature input'), status.thermal_file || '-'), + statusDetail(_('PWM device'), status.fan_file || '-'), + statusDetail(_('Fault'), fault) + ]) + ])); +} + +function validateAutoPath(sectionId, value) { + if (value === 'auto' || value?.startsWith('/')) + return true; + + return _('Enter "auto" or an absolute sysfs path.'); +} return view.extend({ - load: function () { - return Promise.all([ - uci.load('fancontrol') - ]); + load() { + return uci.load('fancontrol'); }, - render: async function (data) { - var m, s, o; - m = new form.Map('fancontrol', _('Fan General Control')); - s = m.section(form.TypedSection, 'fancontrol', _('Settings')); + render() { + let m, s, o; + let startTemp, fullTemp, startSpeed, maxSpeed, kickSpeed, kickMs; + + m = new form.Map('fancontrol', _('Fan Control'), + _('Configure a continuous PWM curve based on device temperature. Automatic hardware detection is suitable for most devices.')); + + s = m.section(form.TypedSection, 'fancontrol', _('Runtime Status')); s.anonymous = true; + s.addremove = false; + s.render = function() { + const node = E('div', { 'class': 'cbi-section fancontrol-runtime' }, [ + E('link', { + 'rel': 'stylesheet', + 'href': L.resource('view/fancontrol.css') + }), + E('div', { 'class': 'cbi-section-node' }, _('Collecting data...')) + ]); + const statusNode = node.lastElementChild; - o = s.option(form.Flag, 'enabled', _('Enabled'), _('Enabled')); + poll.add(function() { + return Promise.all([ + L.resolveDefault(callServiceList('fancontrol'), {}), + L.resolveDefault(fs.read_direct('/var/run/fancontrol.status'), '') + ]).then(function(result) { + renderStatus(statusNode, serviceRunning(result[0]), parseStatus(result[1])); + }); + }, 5); + + return node; + }; + + s = m.section(form.NamedSection, 'settings', 'fancontrol', _('Settings')); + s.addremove = false; + s.tab('curve', _('Temperature Curve'), + _('Controls when the fan starts and how its speed rises with temperature.')); + s.tab('hardware', _('Hardware'), + _('Automatic detection works on most devices. Select explicit sysfs entries only when necessary.')); + s.tab('safety', _('Safety'), + _('Controls startup assistance and fail-safe behavior. Full-speed safety defaults are recommended.')); + + o = s.taboption('curve', form.Flag, 'enabled', _('Enable')); + o.default = o.disabled; o.rmempty = false; + o.description = _('Run the controller and apply this temperature curve.'); - o = s.option(form.Value, 'thermal_file', _('Thermal File'), _('Thermal File')); - o.placeholder = '/sys/devices/virtual/thermal/thermal_zone0/temp'; - var temp_div = uci.get('fancontrol', 'settings', 'temp_div'); - var temp = parseInt(await fs.read_direct(uci.get('fancontrol', 'settings', 'thermal_file'))); - if (temp_div > 0 && temp > 0) { - o.description = _('Current temperature:') + ' ' + (temp / temp_div) + '°C'; - } else { - o.description = _('Thermal File'); - } + startTemp = s.taboption('curve', form.Value, 'start_temp', _('Start temperature')); + startTemp.default = '45'; + startTemp.datatype = 'range(-40,200)'; + startTemp.rmempty = false; + startTemp.description = _('The fan remains off below this temperature and starts at the minimum running PWM when it is reached. Unit: °C.'); - o = s.option(form.Value, 'fan_file', _('Fan File'), _('Fan Speed File')); - o.placeholder = '/sys/devices/virtual/thermal/cooling_device0/cur_state'; - var speed = parseInt(await fs.read_direct(uci.get('fancontrol', 'settings', 'fan_file'))); - o.description = _('Current speed:') + ' ' + (speed) + ''; + fullTemp = s.taboption('curve', form.Value, 'full_speed_temp', _('Full-speed temperature')); + fullTemp.default = '85'; + fullTemp.datatype = 'range(-39,250)'; + fullTemp.rmempty = false; + fullTemp.description = _('PWM rises linearly and reaches the configured maximum at this temperature. Unit: °C.'); + fullTemp.validate = function(sectionId, value) { + if (Number(value) <= Number(startTemp.formvalue(sectionId))) + return _('Full-speed temperature must be higher than start temperature.'); + return true; + }; - o = s.option(form.Value, 'start_speed', _('Initial Speed'), _('Please enter the initial speed for fan startup.')); - o.placeholder = '35'; + o = s.taboption('curve', form.Value, 'hysteresis', _('Stop hysteresis')); + o.default = '3'; + o.datatype = 'range(0,50)'; + o.rmempty = false; + o.description = _('After starting, the fan stops only after temperature falls this many degrees below the start temperature. This prevents rapid on/off cycling.'); - o = s.option(form.Value, 'max_speed', _('Max Speed'), _('Please enter maximum fan speed.')); - o.placeholder = '255'; + startSpeed = s.taboption('curve', form.Value, 'start_speed', _('Minimum running PWM')); + startSpeed.default = '64'; + startSpeed.datatype = 'range(1,255)'; + startSpeed.rmempty = false; + startSpeed.description = _('Lowest PWM used while the fan is running. Increase it if the fan stalls or cannot maintain rotation. Range: 1–255.'); - o = s.option(form.Value, 'start_temp', _('Start Temperature'), _('Please enter the fan start temperature.')); - o.placeholder = '45'; + maxSpeed = s.taboption('curve', form.Value, 'max_speed', _('Maximum PWM')); + maxSpeed.default = '255'; + maxSpeed.datatype = 'range(1,255)'; + maxSpeed.rmempty = false; + maxSpeed.description = _('Highest PWM allowed by the curve. A value of 255 means 100% duty cycle.'); + maxSpeed.validate = function(sectionId, value) { + if (Number(value) < Number(startSpeed.formvalue(sectionId))) + return _('Maximum PWM must not be lower than minimum running PWM.'); + return true; + }; + + o = s.taboption('curve', form.Value, 'interval', _('Polling interval')); + o.default = '5'; + o.datatype = 'range(1,300)'; + o.rmempty = false; + o.description = _('How often the temperature is read and the PWM output is updated. Unit: seconds.'); + + o = s.taboption('hardware', form.Value, 'thermal_file', _('Temperature input')); + o.default = 'auto'; + o.rmempty = false; + o.validate = validateAutoPath; + o.description = _('Use "auto" to select a CPU or SoC thermal sensor, or enter an absolute thermal zone or hwmon tempN_input path.'); + + o = s.taboption('hardware', form.Value, 'thermal_zone', _('Thermal zone type')); + o.default = 'auto'; + o.rmempty = false; + o.description = _('Preferred thermal zone type when temperature input is automatic, for example cpu_top_thermal. Leave as "auto" unless several sensors are available.'); + + o = s.taboption('hardware', form.Value, 'fan_file', _('PWM output')); + o.default = 'auto'; + o.rmempty = false; + o.validate = validateAutoPath; + o.description = _('Use "auto" to select a writable hwmon pwmN output, or enter its absolute path. cooling_device cur_state is not a PWM output.'); + + o = s.taboption('hardware', form.Value, 'fan_hwmon', _('Hwmon device name')); + o.default = 'auto'; + o.rmempty = false; + o.description = _('Preferred hwmon name when PWM output is automatic. The pwmfan driver is preferred by default.'); + + o = s.taboption('hardware', form.Value, 'enable_file', _('PWM mode control')); + o.default = 'auto'; + o.rmempty = false; + o.validate = validateAutoPath; + o.description = _('Optional pwmN_enable path used to select manual PWM mode. With "auto", the matching control is used when available.'); + + o = s.taboption('hardware', form.Value, 'temp_div', _('Temperature divisor')); + o.default = '1000'; + o.value('1'); + o.value('1000'); + o.datatype = 'range(1,1000000)'; + o.rmempty = false; + o.description = _('Divides the raw sensor value before control calculations. Standard Linux temperature inputs use 1000; sensors reporting whole degrees use 1.'); + + kickSpeed = s.taboption('safety', form.Value, 'kick_speed', _('Start kick PWM')); + kickSpeed.default = '255'; + kickSpeed.datatype = 'range(0,255)'; + kickSpeed.rmempty = false; + kickSpeed.description = _('Brief PWM applied when a stopped fan starts. It helps fans that cannot start reliably at low duty. Set to 0 together with a zero duration to disable.'); + + kickMs = s.taboption('safety', form.Value, 'kick_ms', _('Start kick duration')); + kickMs.default = '500'; + kickMs.datatype = 'range(0,10000)'; + kickMs.rmempty = false; + kickMs.description = _('How long the startup kick is applied. Unit: milliseconds.'); + kickMs.validate = function(sectionId, value) { + if (Number(value) > 0 && Number(kickSpeed.formvalue(sectionId)) === 0) + return _('Start kick PWM must be non-zero when a kick duration is configured.'); + return true; + }; + + o = s.taboption('safety', form.Value, 'fail_safe_speed', _('Sensor failure PWM')); + o.default = '255'; + o.datatype = 'range(0,255)'; + o.rmempty = false; + o.description = _('PWM used when the temperature sensor cannot be read. Keeping 255 is the safest choice.'); + + o = s.taboption('safety', form.Value, 'exit_speed', _('Service exit PWM')); + o.default = '255'; + o.datatype = 'range(0,255)'; + o.rmempty = false; + o.description = _('PWM written when the service stops. Keeping the fan at full speed avoids losing cooling after an unexpected exit.'); + + o = s.taboption('safety', form.Flag, 'debug', _('Debug logging')); + o.default = o.disabled; + o.rmempty = false; + o.description = _('Write temperature, PWM and RPM values to the system log at every polling interval.'); return m.render(); } -}); \ No newline at end of file +}); diff --git a/luci-app-fancontrol/po/zh_Hans/fancontrol.po b/luci-app-fancontrol/po/zh_Hans/fancontrol.po index fd56e948..f7f092e4 100644 --- a/luci-app-fancontrol/po/zh_Hans/fancontrol.po +++ b/luci-app-fancontrol/po/zh_Hans/fancontrol.po @@ -1,38 +1,287 @@ -msgid "Fan General Control" -msgstr "风扇通用控制小程序" +msgid "" +msgstr "" +"Project-Id-Version: luci-app-fancontrol 2.0.0\n" +"PO-Revision-Date: 2026-07-30 00:00+0800\n" +"Last-Translator: JiaY-shi \n" +"Language-Team: Chinese (Simplified Han script)\n" +"Language: zh_Hans\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" -msgid "Enabled" -msgstr "是否开启" +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:173 +msgid "" +"After starting, the fan stops only after temperature falls this many degrees " +"below the start temperature. This prevents rapid on/off cycling." +msgstr "风扇启动后,温度需要降到比启动温度低这么多度时才会停转,避免风扇频繁启停。" +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:143 +msgid "" +"Automatic detection works on most devices. Select explicit sysfs entries " +"only when necessary." +msgstr "自动检测适用于大多数设备,仅在必要时手动指定 sysfs 项。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:238 +msgid "" +"Brief PWM applied when a stopped fan starts. It helps fans that cannot start " +"reliably at low duty. Set to 0 together with a zero duration to disable." +msgstr "风扇从停止状态启动时短暂使用的 PWM,可帮助低占空比下无法可靠启动的风扇。将此项和助推时长都设为 0 可禁用。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:122 +msgid "Collecting data..." +msgstr "正在采集数据..." + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:111 +msgid "" +"Configure a continuous PWM curve based on device temperature. Automatic " +"hardware detection is suitable for most devices." +msgstr "根据设备温度配置连续 PWM 调速曲线。自动硬件检测适用于大多数设备。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:145 +msgid "" +"Controls startup assistance and fail-safe behavior. Full-speed safety " +"defaults are recommended." +msgstr "控制启动助推和故障保护行为,建议保留默认的全速安全设置。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:141 +msgid "Controls when the fan starts and how its speed rises with temperature." +msgstr "控制风扇何时启动,以及转速如何随温度升高。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:263 +msgid "Debug logging" +msgstr "调试日志" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:232 +msgid "" +"Divides the raw sensor value before control calculations. Standard Linux " +"temperature inputs use 1000; sensors reporting whole degrees use 1." +msgstr "进行控制计算前用于换算传感器原始值。标准 Linux 温度输入使用 1000,直接报告摄氏度的传感器使用 1。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:147 +msgid "Enable" +msgstr "启用" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:98 +msgid "Enter \"auto\" or an absolute sysfs path." +msgstr "请输入 \"auto\" 或 sysfs 绝对路径。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:110 +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/root/usr/share/luci/menu.d/luci-app-fancontrol.json:3 +msgid "Fan Control" +msgstr "风扇控制" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:84 +msgid "Fan speed" +msgstr "风扇转速" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:89 +msgid "Fault" +msgstr "故障" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:158 +msgid "Full-speed temperature" +msgstr "全速温度" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:165 +msgid "Full-speed temperature must be higher than start temperature." +msgstr "全速温度必须高于启动温度。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/root/usr/share/rpcd/acl.d/luci-app-fancontrol.json:3 +msgid "Grant access to Fan Control" +msgstr "授予风扇控制访问权限" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:142 +msgid "Hardware" +msgstr "硬件" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:185 +msgid "Highest PWM allowed by the curve. A value of 255 means 100% duty cycle." +msgstr "温控曲线允许的最高 PWM,255 表示 100% 占空比。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:244 +msgid "How long the startup kick is applied. Unit: milliseconds." +msgstr "启动助推持续的时间,单位为毫秒。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:196 +msgid "" +"How often the temperature is read and the PWM output is updated. Unit: " +"seconds." +msgstr "读取温度并更新 PWM 输出的时间间隔,单位为秒。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:215 +msgid "Hwmon device name" +msgstr "Hwmon 设备名称" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:179 +msgid "" +"Lowest PWM used while the fan is running. Increase it if the fan stalls or " +"cannot maintain rotation. Range: 1–255." +msgstr "风扇运行时使用的最低 PWM。如果风扇停转或无法持续旋转,请提高此值。范围:1–255。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:181 +msgid "Maximum PWM" +msgstr "最大 PWM" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:188 +msgid "Maximum PWM must not be lower than minimum running PWM." +msgstr "最大 PWM 不得低于最低运行 PWM。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:175 +msgid "Minimum running PWM" +msgstr "最低运行 PWM" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:71 +msgid "None" +msgstr "无" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:224 +msgid "" +"Optional pwmN_enable path used to select manual PWM mode. With \"auto\", the " +"matching control is used when available." +msgstr "用于选择手动 PWM 模式的可选 pwmN_enable 路径。设为 \"auto\" 时会自动使用匹配的控制项。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:88 +msgid "PWM device" +msgstr "PWM 设备" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:220 +msgid "PWM mode control" +msgstr "PWM 模式控制" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:82 +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:209 +msgid "PWM output" +msgstr "PWM 输出" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:162 +msgid "" +"PWM rises linearly and reaches the configured maximum at this temperature. " +"Unit: °C." +msgstr "PWM 随温度线性升高,并在此温度达到设定的最大值,单位为 °C。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:255 +msgid "" +"PWM used when the temperature sensor cannot be read. Keeping 255 is the " +"safest choice." +msgstr "温度传感器读取失败时使用的 PWM。保持 255 是最安全的选择。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:261 +msgid "" +"PWM written when the service stops. Keeping the fan at full speed avoids " +"losing cooling after an unexpected exit." +msgstr "服务停止时写入的 PWM。保持风扇全速可避免服务意外退出后失去散热。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:192 +msgid "Polling interval" +msgstr "轮询间隔" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:218 +msgid "" +"Preferred hwmon name when PWM output is automatic. The pwmfan driver is " +"preferred by default." +msgstr "自动选择 PWM 输出时优先使用的 hwmon 名称,默认优先选择 pwmfan 驱动。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:207 +msgid "" +"Preferred thermal zone type when temperature input is automatic, for example " +"cpu_top_thermal. Leave as \"auto\" unless several sensors are available." +msgstr "自动选择温度输入时优先使用的 thermal zone 类型,例如 cpu_top_thermal。除非存在多个传感器,否则保持 \"auto\"。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:150 +msgid "Run the controller and apply this temperature curve." +msgstr "运行风扇控制器并应用此温控曲线。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:77 +msgid "Running" +msgstr "运行中" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:75 +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:113 +msgid "Runtime Status" +msgstr "运行状态" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:144 +msgid "Safety" +msgstr "安全" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:251 +msgid "Sensor failure PWM" +msgstr "传感器故障 PWM" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:257 +msgid "Service exit PWM" +msgstr "服务退出 PWM" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:138 msgid "Settings" msgstr "设置" -msgid "Thermal File" -msgstr "温度虚拟文件" +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:234 +msgid "Start kick PWM" +msgstr "启动助推 PWM" -msgid "Fan Speed File" -msgstr "风扇速度虚拟文件" +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:247 +msgid "Start kick PWM must be non-zero when a kick duration is configured." +msgstr "配置启动助推时长时,启动助推 PWM 不能为零。" -msgid "Initial Speed" -msgstr "启动初始速度" +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:240 +msgid "Start kick duration" +msgstr "启动助推时长" -msgid "Please enter the initial speed for fan startup." -msgstr "请输入风扇启动的初始速度。" - -msgid "Max Speed" -msgstr "最大速度" - -msgid "Please enter maximum fan speed." -msgstr "请输入风扇最大速度" - -msgid "Start Temperature" +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:152 +msgid "Start temperature" msgstr "启动温度" -msgid "Please enter the fan start temperature." -msgstr "请输入风扇启动温度。" +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:169 +msgid "Stop hysteresis" +msgstr "停转回差" -msgid "Current speed:" -msgstr "当前速度:" +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:77 +msgid "Stopped" +msgstr "已停止" -msgid "Current temperature:" -msgstr "当前温度:" +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:80 +msgid "Temperature" +msgstr "温度" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:140 +msgid "Temperature Curve" +msgstr "温控曲线" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:226 +msgid "Temperature divisor" +msgstr "温度除数" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:87 +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:198 +msgid "Temperature input" +msgstr "温度输入" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:156 +msgid "" +"The fan remains off below this temperature and starts at the minimum running " +"PWM when it is reached. Unit: °C." +msgstr "低于此温度时风扇保持停止;达到此温度时,以最低运行 PWM 启动。单位为 °C。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:204 +msgid "Thermal zone type" +msgstr "温度区域类型" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:202 +msgid "" +"Use \"auto\" to select a CPU or SoC thermal sensor, or enter an absolute " +"thermal zone or hwmon tempN_input path." +msgstr "使用 \"auto\" 自动选择 CPU 或 SoC 温度传感器,也可输入 thermal zone 或 hwmon tempN_input 的绝对路径。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:213 +msgid "" +"Use \"auto\" to select a writable hwmon pwmN output, or enter its absolute " +"path. cooling_device cur_state is not a PWM output." +msgstr "使用 \"auto\" 自动选择可写的 hwmon pwmN 输出,也可输入其绝对路径。cooling_device cur_state 不是 PWM 输出。" + +#: /home/shi/document/c-service/fancontrol/luci-app-fancontrol/htdocs/luci-static/resources/view/fancontrol.js:266 +msgid "" +"Write temperature, PWM and RPM values to the system log at every polling " +"interval." +msgstr "每次轮询时将温度、PWM 和 RPM 数值写入系统日志。" diff --git a/luci-app-fancontrol/root/usr/share/luci/menu.d/luci-app-fancontrol.json b/luci-app-fancontrol/root/usr/share/luci/menu.d/luci-app-fancontrol.json index 22c6d252..88d2f9e1 100644 --- a/luci-app-fancontrol/root/usr/share/luci/menu.d/luci-app-fancontrol.json +++ b/luci-app-fancontrol/root/usr/share/luci/menu.d/luci-app-fancontrol.json @@ -1,6 +1,6 @@ { "admin/services/fancontrol": { - "title": "Fan General Control", + "title": "Fan Control", "order": 90, "action": { "type": "view", @@ -10,4 +10,4 @@ "acl": [ "luci-app-fancontrol" ] } } -} \ No newline at end of file +} diff --git a/luci-app-fancontrol/root/usr/share/rpcd/acl.d/luci-app-fancontrol.json b/luci-app-fancontrol/root/usr/share/rpcd/acl.d/luci-app-fancontrol.json index f10803a6..579af283 100644 --- a/luci-app-fancontrol/root/usr/share/rpcd/acl.d/luci-app-fancontrol.json +++ b/luci-app-fancontrol/root/usr/share/rpcd/acl.d/luci-app-fancontrol.json @@ -1,20 +1,17 @@ { "luci-app-fancontrol": { - "description": "Fan General Control", + "description": "Grant access to Fan Control", "read": { - "uci": [ - "fancontrol" - ], + "uci": [ "fancontrol" ], + "ubus": { + "service": [ "list" ] + }, "file": { - "/sys/devices/virtual/thermal/*/*": [ - "read" - ] + "/var/run/fancontrol.status": [ "read" ] } }, "write": { - "uci": [ - "fancontrol" - ] + "uci": [ "fancontrol" ] } } -} \ No newline at end of file +} diff --git a/luci-app-passwall/Makefile b/luci-app-passwall/Makefile index 3298b688..6294fa74 100644 --- a/luci-app-passwall/Makefile +++ b/luci-app-passwall/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-passwall PKG_VERSION:=26.7.24 -PKG_RELEASE:=196 +PKG_RELEASE:=197 PKG_PO_VERSION:=$(PKG_VERSION) PKG_CONFIG_DEPENDS:= \ diff --git a/luci-app-passwall/luasrc/passwall/api.lua b/luci-app-passwall/luasrc/passwall/api.lua index 4571ceb0..f63632be 100644 --- a/luci-app-passwall/luasrc/passwall/api.lua +++ b/luci-app-passwall/luasrc/passwall/api.lua @@ -1745,16 +1745,21 @@ function fetch_cert_sha256(host, port, sni, timeout, http3) end function vps_domain_exclude(domain) - if trim(domain) == "" then return true end + domain = trim(domain) + if domain == "" then return true end local map = { - ["engage.cloudflareclient.com"] = 1, - ["google.com"] = 1, ["www.google.com"] = 1, - ["youtube.com"] = 1, ["www.youtube.com"] = 1, - ["github.com"] = 1, ["telegram.org"] = 1, - ["cloudflare.com"] = 1, ["www.cloudflare.com"] = 1, - ["bing.com"] = 1, ["www.bing.com"] = 1, ["x.com"] = 1 + ["engage.cloudflareclient.com"] = 1, ["google.com"] = 1, ["youtube.com"] = 1, + ["github.com"] = 1, ["telegram.org"] = 1, ["cloudflare.com"] = 1, + ["bing.com"] = 1, ["x.com"] = 1, ["dns.google"] = 1, ["opendns.com"] = 1, + ["cloudflare-dns.com"] = 1, ["one.one.one.one"] = 1, ["quad9.net"] = 1, + ["adguard-dns.com"] = 1, ["nextdns.io"] = 1, ["libredns.gr"] = 1 } - if map[domain] then return true end + while true do + if map[domain] then return true end + local p = domain:find("%.") + if not p then break end + domain = domain:sub(p + 1) + end return false end diff --git a/luci-app-passwall/root/usr/share/passwall/helper_chinadns_add.lua b/luci-app-passwall/root/usr/share/passwall/helper_chinadns_add.lua index e511c704..7ab88d6e 100644 --- a/luci-app-passwall/root/usr/share/passwall/helper_chinadns_add.lua +++ b/luci-app-passwall/root/usr/share/passwall/helper_chinadns_add.lua @@ -178,6 +178,10 @@ if not is_file_nonzero(file_vpslist) then uci:foreach(appname, "nodes", function(t) process_address(t.address) process_address(t.download_address) + local dns, _ = api.get_domain_port_from_url(t.domain_resolver_dns or t.domain_resolver_dns_https or "") + if dns and dns ~= "" then + process_address(dns) + end end) uci:foreach(appname, "subscribe_list", function(t) --订阅链接 local url, _ = api.get_domain_port_from_url(t.url or "") diff --git a/luci-app-passwall/root/usr/share/passwall/helper_dnsmasq.lua b/luci-app-passwall/root/usr/share/passwall/helper_dnsmasq.lua index 93873262..0571b832 100644 --- a/luci-app-passwall/root/usr/share/passwall/helper_dnsmasq.lua +++ b/luci-app-passwall/root/usr/share/passwall/helper_dnsmasq.lua @@ -381,6 +381,10 @@ function add_rule(var) uci:foreach(appname, "nodes", function(t) process_address(t.address) process_address(t.download_address) + local dns, _ = api.get_domain_port_from_url(t.domain_resolver_dns or t.domain_resolver_dns_https or "") + if dns and dns ~= "" then + process_address(dns) + end end) uci:foreach(appname, "subscribe_list", function(t) --订阅链接 local url, _ = api.get_domain_port_from_url(t.url or "") diff --git a/luci-app-passwall/root/usr/share/passwall/helper_smartdns_add.lua b/luci-app-passwall/root/usr/share/passwall/helper_smartdns_add.lua index fa9a3649..e3a0ef20 100644 --- a/luci-app-passwall/root/usr/share/passwall/helper_smartdns_add.lua +++ b/luci-app-passwall/root/usr/share/passwall/helper_smartdns_add.lua @@ -337,6 +337,10 @@ if not is_file_nonzero(file_vpslist) then uci:foreach(appname, "nodes", function(t) process_address(t.address) process_address(t.download_address) + local dns, _ = api.get_domain_port_from_url(t.domain_resolver_dns or t.domain_resolver_dns_https or "") + if dns and dns ~= "" then + process_address(dns) + end end) uci:foreach(appname, "subscribe_list", function(t) --订阅链接 local url, _ = api.get_domain_port_from_url(t.url or "") diff --git a/luci-app-passwall/root/usr/share/passwall/iptables.sh b/luci-app-passwall/root/usr/share/passwall/iptables.sh index 5dc54d31..40bddf26 100755 --- a/luci-app-passwall/root/usr/share/passwall/iptables.sh +++ b/luci-app-passwall/root/usr/share/passwall/iptables.sh @@ -747,13 +747,13 @@ filter_haproxy() { filter_vpsip() { local EXCLUDE_VPSIP="^(0\.0\.0\.0|127\.0\.0\.1|1\.1\.1\.1|1\.1\.1\.2|8\.8\.8\.8|8\.8\.4\.4|9\.9\.9\.9)$" - uci show $CONFIG | grep -E "(\.address=|\.download_address=)" | cut -d "'" -f 2 | grep -E "^([0-9]{1,3}\.){3}[0-9]{1,3}$" | grep -Ev "$EXCLUDE_VPSIP" | sed "s/^/add $IPSET_VPS /" | awk '1; END{print "COMMIT"}' | ipset -! -R + uci show $CONFIG | grep -E "(\.address=|\.download_address=|\.domain_resolver_dns=|\.domain_resolver_dns_https=)" | cut -d "'" -f 2 | grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" | grep -Ev "$EXCLUDE_VPSIP" | sed "s/^/add $IPSET_VPS /" | awk '1; END{print "COMMIT"}' | ipset -! -R echolog " - [$?]加入所有IPv4节点到ipset[$IPSET_VPS]直连完成" - uci show $CONFIG | grep -E "(\.address=|\.download_address=)" | cut -d "'" -f 2 | grep -E "([A-Fa-f0-9]{1,4}::?){1,7}[A-Fa-f0-9]{1,4}" | sed "s/^/add $IPSET_VPS6 /" | awk '1; END{print "COMMIT"}' | ipset -! -R + uci show $CONFIG | grep -E "(\.address=|\.download_address=|\.domain_resolver_dns=|\.domain_resolver_dns_https=)" | cut -d "'" -f 2 | grep -Eo "\[?[A-Fa-f0-9:]*:[A-Fa-f0-9:]+\]?" | sed "s/^/add $IPSET_VPS6 /" | awk '1; END{print "COMMIT"}' | ipset -! -R echolog " - [$?]加入所有IPv6节点到ipset[$IPSET_VPS6]直连完成" #订阅方式为直连时 - get_subscribe_host | grep -E "([0-9]{1,3}[\.]){3}[0-9]{1,3}" | grep -Ev "$EXCLUDE_VPSIP" | sed "s/^/add $IPSET_VPS /" | awk '{print $0} END{print "COMMIT"}' | ipset -! -R - get_subscribe_host | grep -E "([A-Fa-f0-9]{1,4}::?){1,7}[A-Fa-f0-9]{1,4}" | sed "s/^/add $IPSET_VPS6 /" | awk '{print $0} END{print "COMMIT"}' | ipset -! -R + get_subscribe_host | grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" | grep -Ev "$EXCLUDE_VPSIP" | sed "s/^/add $IPSET_VPS /" | awk '{print $0} END{print "COMMIT"}' | ipset -! -R + get_subscribe_host | grep -Eo "\[?[A-Fa-f0-9:]*:[A-Fa-f0-9:]+\]?" | sed "s/^/add $IPSET_VPS6 /" | awk '{print $0} END{print "COMMIT"}' | ipset -! -R } filter_server_port() { diff --git a/luci-app-passwall/root/usr/share/passwall/nftables.sh b/luci-app-passwall/root/usr/share/passwall/nftables.sh index 9c0dbfac..6326035f 100755 --- a/luci-app-passwall/root/usr/share/passwall/nftables.sh +++ b/luci-app-passwall/root/usr/share/passwall/nftables.sh @@ -817,13 +817,13 @@ filter_vps_addr() { filter_vpsip() { local EXCLUDE_VPSIP="^(0\.0\.0\.0|127\.0\.0\.1|1\.1\.1\.1|1\.1\.1\.2|8\.8\.8\.8|8\.8\.4\.4|9\.9\.9\.9)$" - uci show $CONFIG | grep -E "(\.address=|\.download_address=)" | cut -d "'" -f 2 | grep -E "([0-9]{1,3}[\.]){3}[0-9]{1,3}" | grep -Ev "$EXCLUDE_VPSIP" | insert_nftset $NFTSET_VPS + uci show $CONFIG | grep -E "(\.address=|\.download_address=|\.domain_resolver_dns=|\.domain_resolver_dns_https=)" | cut -d "'" -f 2 | grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" | grep -Ev "$EXCLUDE_VPSIP" | insert_nftset $NFTSET_VPS echolog " - [$?]加入所有IPv4节点到nftset[$NFTSET_VPS]直连完成" - uci show $CONFIG | grep -E "(\.address=|\.download_address=)" | cut -d "'" -f 2 | grep -E "([A-Fa-f0-9]{1,4}::?){1,7}[A-Fa-f0-9]{1,4}" | insert_nftset $NFTSET_VPS6 + uci show $CONFIG | grep -E "(\.address=|\.download_address=|\.domain_resolver_dns=|\.domain_resolver_dns_https=)" | cut -d "'" -f 2 | grep -Eo "\[?[A-Fa-f0-9:]*:[A-Fa-f0-9:]+\]?" | insert_nftset $NFTSET_VPS6 echolog " - [$?]加入所有IPv6节点到nftset[$NFTSET_VPS6]直连完成" #订阅方式为直连时 - get_subscribe_host | grep -E "([0-9]{1,3}[\.]){3}[0-9]{1,3}" | grep -Ev "$EXCLUDE_VPSIP" | insert_nftset $NFTSET_VPS - get_subscribe_host | grep -E "([A-Fa-f0-9]{1,4}::?){1,7}[A-Fa-f0-9]{1,4}" | insert_nftset $NFTSET_VPS6 + get_subscribe_host | grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" | grep -Ev "$EXCLUDE_VPSIP" | insert_nftset $NFTSET_VPS + get_subscribe_host | grep -Eo "\[?[A-Fa-f0-9:]*:[A-Fa-f0-9:]+\]?" | insert_nftset $NFTSET_VPS6 } filter_server_port() { diff --git a/luci-app-qemu-vms/Makefile b/luci-app-qemu-vms/Makefile index 311bd5f7..fc248284 100644 --- a/luci-app-qemu-vms/Makefile +++ b/luci-app-qemu-vms/Makefile @@ -4,7 +4,7 @@ LUCI_TITLE:=Web UI for QEMU Virtual Machine Simple (VMS) LUCI_DEPENDS:=@TARGET_x86_64 +qemu-vms +ttyd +usbutils +pciutils PKG_LICENSE:=GPLv3 PKG_VERSION:=0.0.1 -PKG_RELEASE:=5 +PKG_RELEASE:=6 include $(TOPDIR)/feeds/luci/luci.mk diff --git a/luci-app-qemu-vms/htdocs/luci-static/resources/view/qemu-vms/networks.js b/luci-app-qemu-vms/htdocs/luci-static/resources/view/qemu-vms/networks.js index 296268e7..6c02fa20 100644 --- a/luci-app-qemu-vms/htdocs/luci-static/resources/view/qemu-vms/networks.js +++ b/luci-app-qemu-vms/htdocs/luci-static/resources/view/qemu-vms/networks.js @@ -20,7 +20,8 @@ return view.extend({ load: function() { return Promise.all([ callListNetDrivers(), - uci.load('qemu-vms') + uci.load('qemu-vms'), + uci.load('network') ]); }, @@ -63,8 +64,21 @@ return view.extend({ return true; }; + // В render: + var bridgeList = []; + uci.sections('network', 'device').forEach(function(s) { + if (s.type === 'bridge') { + bridgeList.push(s.name); + } + }); + var bridge = s.option(form.Value, 'bridge', _('Bridge (optional)')); - bridge.placeholder = _('leave empty to keep the interface standalone'); + bridge.value('', _('leave empty')); + bridgeList.forEach(function(name) { + bridge.value(name, name); + }); + bridge.rmempty = true; + //bridge.nocreate = false; var driver = s.option(form.ListValue, 'driver', _('Network driver')); @@ -81,4 +95,4 @@ return view.extend({ return m.render(); } -}); \ No newline at end of file +}); diff --git a/luci-app-qemu-vms/htdocs/luci-static/resources/view/qemu-vms/passthrough.js b/luci-app-qemu-vms/htdocs/luci-static/resources/view/qemu-vms/passthrough.js index 7471c20b..1b6b8f89 100644 --- a/luci-app-qemu-vms/htdocs/luci-static/resources/view/qemu-vms/passthrough.js +++ b/luci-app-qemu-vms/htdocs/luci-static/resources/view/qemu-vms/passthrough.js @@ -432,7 +432,7 @@ return view.extend({ _('PCI passthrough requires IOMMU enabled in the host BIOS and kernel command line (intel_iommu=on / amd_iommu=on), and the vfio-pci kernel module loaded.') + '
' + _('Creating a passthrough section will bind the device to vfio-pci (if possible). Removing it will attempt to re-bind to the original driver.') + '
' + _('Note: Some devices (especially Intel network cards) may require the vfio-pci module option "disable_idle_d3=1" to prevent them from entering a low-power state.') + - '
' + _('Add "options vfio-pci disable_idle_d3=1" to /etc/modprobe.d/vfio-pci.conf if you encounter "No such device" errors.')), + '
' + _('Change to "vfio-pci disable_idle_d3=1" in /etc/modules.d/vfio-pci if you encounter "No such device" errors.')), this.renderPciTable(hw.pci || []) ]); diff --git a/luci-app-qemu-vms/root/usr/libexec/rpcd/luci.qemu-vms b/luci-app-qemu-vms/root/usr/libexec/rpcd/luci.qemu-vms index 3be81a0d..bc8e5fdd 100755 --- a/luci-app-qemu-vms/root/usr/libexec/rpcd/luci.qemu-vms +++ b/luci-app-qemu-vms/root/usr/libexec/rpcd/luci.qemu-vms @@ -4,6 +4,7 @@ . /lib/functions.sh STATUS_DIR="/var/run/qemu-vms" +LOG_DIR="/var/log/qemu-vms" is_anonymous_section() { # UCI auto-generated names look like: cfgXXXXXX (cfg + hex) @@ -85,7 +86,7 @@ find_free_port() { console_open() { local vm="$1" - local sock="/var/run/qemu-$vm.sock" + local sock="$STATUS_DIR/qemu-$vm.sock" local pidfile="$STATUS_DIR/$vm.ttyd.pid" local portfile="$STATUS_DIR/$vm.ttyd.port" @@ -145,7 +146,7 @@ console_close() { readlog() { local vm="$1" local lines="${2:-200}" - local log="/var/log/qemu-vms/$vm.log" + local log="$LOG_DIR/$vm.log" json_init if [ -f "$log" ]; then diff --git a/qemu-vms/Makefile b/qemu-vms/Makefile index 52ebfa94..739fec9f 100644 --- a/qemu-vms/Makefile +++ b/qemu-vms/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=qemu-vms PKG_VERSION:=0.0.1 -PKG_RELEASE:=5 +PKG_RELEASE:=6 PKG_MAINTAINER:=Konstantine Shevlakov include $(INCLUDE_DIR)/package.mk diff --git a/qemu-vms/root/etc/config/qemu-vms b/qemu-vms/root/etc/config/qemu-vms index b2edb9db..c156e31d 100644 --- a/qemu-vms/root/etc/config/qemu-vms +++ b/qemu-vms/root/etc/config/qemu-vms @@ -15,6 +15,7 @@ config network 'tap_example_bridge' option mac '52:54:00:31:6c:4e' option ifname 'tap1' option bridge 'br-tap' + option driver 'pcnet' config vm 'vm_example' option enabled '1' @@ -34,6 +35,7 @@ config vm 'winxp_example' option display_type 'vnc' option vnc_port '5902' list network 'tap_example_bridge' + list usb_passthrough 'example_usb' option cdrom '/mnt/disk/vm/wxp.iso' option image '/mnt/disk/vm/xp.img' option disk_bus 'ide' diff --git a/qemu-vms/root/etc/init.d/qemu-vms b/qemu-vms/root/etc/init.d/qemu-vms index 8d97d3f0..034a122d 100755 --- a/qemu-vms/root/etc/init.d/qemu-vms +++ b/qemu-vms/root/etc/init.d/qemu-vms @@ -244,9 +244,9 @@ start_vm() { config_get vnc_port "$cfg" vnc_port config_get machine "$cfg" machine "q35" - local console_sock="/var/run/qemu-$cfg.sock" - local console_log="/var/log/qemu-$cfg-console.log" - local qmp_sock="/var/run/qemu-$cfg.qmp" + local console_sock="$STAUS_DIR/qemu-$cfg.sock" + local console_log="$LOG_DIR/qemu-$cfg-console.log" + local qmp_sock="$STATUS_DIR/qemu-$cfg.qmp" build_netargs "$cfg" build_usbargs "$cfg" diff --git a/qemu-vms/root/etc/qemu-ifup b/qemu-vms/root/etc/qemu-ifup index d1cd1906..7f1440c8 100755 --- a/qemu-vms/root/etc/qemu-ifup +++ b/qemu-vms/root/etc/qemu-ifup @@ -1,22 +1,63 @@ #!/bin/sh # /etc/qemu-ifup -# -# Invoked by QEMU itself (via -netdev tap,...,script=/etc/qemu-ifup) right -# after it creates the tap interface. $1 is the tap ifname QEMU created; -# $2 (bridge) is passed through via the netdev's "downscript"-adjacent -# vhost/helper convention used by build_netargs in /etc/init.d/qemu-vms, -# which appends the bridge name as a second positional argument. -# -# We only enslave the interface into a bridge if one was actually -# configured on the network section - a tap interface with no "bridge" -# option stays standalone (e.g. for point-to-point or manually managed -# setups). +# $1 - tap iface name +# $2 - bridge name (optional) -ifname="$1" -bridge="$2" +. /lib/functions.sh -ip link set "$ifname" up - -if [ -n "$bridge" ]; then - ip link set "$ifname" master "$bridge" +if [ -n "$2" ]; then + # if custom user bridge + if ! ip link show "$2" >/dev/null 2>&1; then + ip link add "$2" type bridge + ip link set "$2" up + logger -t qemu-ifup "Bridge $2 created" + fi + ip link set "$1" master "$2" + exit 0 fi + + +network_bridges="" + +collect_network_bridge() { + local cfg="$1" + local type + config_get type "$cfg" type + [ "$type" = "bridge" ] || return 0 + local name + config_get name "$cfg" name + [ -n "$name" ] && network_bridges="$network_bridges $name" +} + +config_load network +config_foreach collect_network_bridge device + +qemu_bridges="" + +collect_qemu_bridge() { + local cfg="$1" + local bridge + config_get bridge "$cfg" bridge + [ -n "$bridge" ] && qemu_bridges="$qemu_bridges $bridge" +} + +config_load qemu-vms +config_foreach collect_qemu_bridge network + +defined_bridges="$network_bridges $qemu_bridges" + +existing_bridges=$(ls -d /sys/devices/virtual/net/*/brif/ 2>/dev/null | awk -F '/' '{print $(NF-2)}') + +echo "$existing_bridges" | awk -v def="$defined_bridges" ' +BEGIN { + split(def, arr, " "); + for (i in arr) declared[arr[i]] = 1; +} +!($0 in declared) { print } +' | while read bridge; do + if [ -z "$(ls -A "/sys/devices/virtual/net/$bridge/brif" 2>/dev/null)" ]; then + ip link set "$bridge" down 2>/dev/null + ip link delete "$bridge" 2>/dev/null + logger -t qemu-ifup "Removed orphan bridge $bridge" + fi +done diff --git a/qemu-vms/root/usr/bin/vm-console b/qemu-vms/root/usr/bin/vm-console index 76971d0a..3d789593 100755 --- a/qemu-vms/root/usr/bin/vm-console +++ b/qemu-vms/root/usr/bin/vm-console @@ -10,6 +10,9 @@ # log: just tails the always-on boot/console logfile (safe to run at # any time, does not touch the live console socket at all). +STATUS_DIR=/var/run/qemu-vms +LOG_DIR=/var/log/qemu-vms + VM="$1" MODE="${2:-attach}" @@ -18,9 +21,9 @@ if [ -z "$VM" ]; then exit 1 fi -CONSOLE_SOCK="/var/run/qemu-$VM.sock" -CONSOLE_LOG="/var/log/qemu-$VM-console.log" -TMP_PTY="/var/run/qemu-$VM.console-pty.$$" +CONSOLE_SOCK="$STATUS_DIR/qemu-$VM.sock" +CONSOLE_LOG="$LOG_DIR/qemu-$VM-console.log" +TMP_PTY="$STATUS_DIR/qemu-$VM.console-pty.$$" case "$MODE" in log)