diff --git a/README.md b/README.md
index a1d677c..7f24a9b 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@ Configuration is done through a user-friendly interface, with custom thresholds,
- Control multiple PWM fans independently
- Monitor temps from array disks, NVMe, unassigned devices, and optionally the CPU
- Monitor auxiliary hwmon, storcli, NVIDIA GPU, and Mellanox NIC (`mget_temp`) temperature sensors, or any sensor of your own through a custom script
-- Sends one Unraid warning per fan per boot when a configured CPU or auxiliary sensor gives no reading for at least 60 seconds, since the fan cannot react to that sensor then and, if nothing else reads, stays at its Idle or Min Speed
+- Sends Unraid notifications when a fan has a problem, while fan control keeps running. A fan gets one warning when, for at least 30 seconds, its PWM controller disappears, the BIOS or another program takes it over, the controller reports a speed 10 or more points away from the one set, or the fan reports 0 RPM while set above 40% after spinning earlier. A fan whose controller is missing when fan control starts also gets one warning. A notice follows once the problem has stayed away for 30 seconds. A configured CPU or auxiliary sensor without a reading for at least 60 seconds also gets one warning per fan per boot, since the fan cannot react to it and, if nothing else reads, stays at its Idle or Min Speed. Alerts are on by default and can be turned off per fan under Syslog & Alerts, for example for a controller with only a few speed steps that never reads back the exact speed set
- Use independent temperature ranges for multiple disk groups on one fan
- Uses a linear control algorithm to smoothly adjust fan speed (PWM) based on the current temperature (disk or CPU) between your defined low/high values
- Identify and label PWM controllers to match physical fans easily
@@ -157,8 +157,8 @@ detected, the settings page shows a notice with the workaround. The option's
loaded state is read from the driver's `fan_control_watchdog` attribute or its
"MSI fan brute force mode" kernel log line, falling back to the modprobe
configuration. While a fan on one of those channels is set above 60% but reads
-back 60% or less for 10 seconds, the plugin also sends one Unraid
-notification per fan per boot. On Unraid, set the option on the flash drive and
+back 60% or less for 30 seconds, the plugin also sends an Unraid warning that
+names the option (see the problem alerts above). On Unraid, set the option on the flash drive and
reboot:
```bash
diff --git a/plugin/plugin.j2 b/plugin/plugin.j2
index abb57e7..5c0f8e4 100644
--- a/plugin/plugin.j2
+++ b/plugin/plugin.j2
@@ -91,7 +91,8 @@ if [[ -f "$src" ]]; then
fi
mkdir -p /var/tmp/fanctrlplus2
-rm -f /var/tmp/fanctrlplus2/*
+# Fan alert state (faults_* directories) survives updates, so recoveries are still announced.
+find /var/tmp/fanctrlplus2 -maxdepth 1 -type f -delete
pkill -f "/usr/local/emhttp/plugins/fanctrlplus2/scripts/array_monitor.sh"
rm -f /boot/config/plugins/fanctrlplus2/.config.lock # the lock now lives in /var/run
diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/css/fcp.base.css b/src/usr/local/emhttp/plugins/fanctrlplus2/css/fcp.base.css
index 9f50cd3..6a67efd 100644
--- a/src/usr/local/emhttp/plugins/fanctrlplus2/css/fcp.base.css
+++ b/src/usr/local/emhttp/plugins/fanctrlplus2/css/fcp.base.css
@@ -462,7 +462,7 @@ dt.containerSettingsDt {
line-height: 1.6;
}
-/* Identify PWM Controller and Syslog Setting wrapper. */
+/* Identify PWM Controller and Syslog & Alerts wrapper. */
.fcp-block-wrap{
display: flex;
justify-content: center;
@@ -583,7 +583,7 @@ dt.containerSettingsDt {
.fcp-airflow-switch{ display: none; }
.fcp-history-switch{ display: none; }
-.syslog-switch{ display: none; }
+.syslog-switch, .notify-switch{ display: none; }
.switch-button-label{ font-size: 12px; }
.switch-button-label.on{ display: none; }
.switch-button-background.checked ~ .switch-button-label.on{ display: inline; }
diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page b/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page
index b38eab2..a3bef3e 100644
--- a/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page
+++ b/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page
@@ -1346,9 +1346,8 @@ $(function() {
$area.find('.switch-button-button').css('left', checked ? '12px' : '-1px');
// Persist the relevant switch.
- if ($input.hasClass('syslog-switch')) {
- var cfgFile = $input.data('cfg');
- saveSyslogSwitchState(cfgFile, checked);
+ if ($input.hasClass('syslog-switch') || $input.hasClass('notify-switch')) {
+ saveFanSwitchState($input.hasClass('syslog-switch') ? 'setsyslog' : 'setnotify', $input.data('cfg'), checked);
} else if ($input.hasClass('fcp-airflow-switch')) {
saveAirflowSwitchState(checked);
} else if ($input.hasClass('fcp-history-switch')) {
@@ -1356,8 +1355,8 @@ $(function() {
}
});
- // Shared change handler for the syslog and FCP Airflow/History switches.
- $(document).on('change', '.syslog-switch, .fcp-airflow-switch, .fcp-history-switch', function() {
+ // Shared change handler for the per-fan and FCP Airflow/History switches.
+ $(document).on('change', '.syslog-switch, .notify-switch, .fcp-airflow-switch, .fcp-history-switch', function() {
var checked = $(this).prop('checked');
var $area = $(this).closest('.switch-area');
@@ -1375,12 +1374,18 @@ $(function() {
$('#syslog-settings-area').load('/Settings/fanctrlplus2 #syslog-settings-area > *');
}
- function saveSyslogSwitchState(cfgFile, checked) {
- $.post('/plugins/fanctrlplus2/include/FanctrlLogic.php', {
- op: 'setsyslog',
- cfg: cfgFile,
- enabled: checked ? 1 : 0
- });
+ // One save at a time per switch, so a quick off-on cannot land out of order.
+ var fanSwitchSaves = {};
+ function saveFanSwitchState(op, cfgFile, checked) {
+ var key = op + '|' + cfgFile;
+ var post = function() {
+ return $.post('/plugins/fanctrlplus2/include/FanctrlLogic.php', {
+ op: op,
+ cfg: cfgFile,
+ enabled: checked ? 1 : 0
+ });
+ };
+ fanSwitchSaves[key] = (fanSwitchSaves[key] || $.when()).then(post, post);
}
function saveAirflowSwitchState(checked) {
@@ -1890,7 +1895,7 @@ $(function() {
Identify PWM Controller: Use this tool to help locate which physical fan is linked to each PWM controller. After identification, adding a custom label makes it easier to recognize and configure later.
-
Syslog Setting: Enable or disable syslog logging for each fan individually. When enabled, changes in temperature and fan speed will be recorded in the system log. If a fan setup is already stable or updates too frequently, you can turn off its logging to reduce clutter.
+
Syslog & Alerts: Enable or disable syslog logging for each fan individually. When enabled, changes in temperature and fan speed will be recorded in the system log. If a fan setup is already stable or updates too frequently, you can turn off its logging to reduce clutter. Problem alerts send Unraid notifications when a fan has a problem; turn them off for a fan whose controller raises false alarms.
Notes:
@@ -2093,7 +2098,7 @@ $(function() {
- Syslog Setting
+ Syslog & Alerts
@@ -2113,6 +2118,7 @@ $(function() {
This helps you track fan behavior in real time. However, if a particular fan changes frequently and generates too many log entries, you can disable logging for that fan.
Note: The plugin only writes to syslog when the PWM value changes by more than 5 compared to the previous value — small changes are ignored to reduce log noise.
+
Problem Alerts are also enabled by default. Fan control keeps running either way; the alerts only send Unraid notifications. A fan gets one warning when, for at least 30 seconds, its PWM controller has disappeared, the BIOS or another program has taken it over, its controller reports a speed 10 or more points away from the one set, or it reports 0 RPM while set above 40% after spinning earlier. A fan whose controller is missing when fan control starts, and a configured sensor without a reading for 60 seconds, also warn. Once the problem has stayed away for 30 seconds, a notice says so. Turn the alerts off for a fan whose controller raises false alarms, such as one with only a few speed steps.
diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/include/ConfigBackup.php b/src/usr/local/emhttp/plugins/fanctrlplus2/include/ConfigBackup.php
index a1dbc56..e31fc33 100644
--- a/src/usr/local/emhttp/plugins/fanctrlplus2/include/ConfigBackup.php
+++ b/src/usr/local/emhttp/plugins/fanctrlplus2/include/ConfigBackup.php
@@ -99,7 +99,7 @@ function fcp_backup_assignments(string $text): array {
function fcp_validate_fan_config(string $text): array {
$values = fcp_backup_assignments($text);
- $boolean = ['service','syslog','log_enable','cpu_enable','aux_enable'];
+ $boolean = ['service','syslog','notify','log_enable','cpu_enable','aux_enable'];
$numeric = ['pwm','max','idle','idle_percent','low','high','interval','interval_sec','disk_group_count','cpu_min_temp','cpu_max_temp','aux_min_temp','aux_max_temp'];
$string = ['custom','label','controller','controller_identity','fan','disks','cpu_sensor','aux_sensor'];
foreach ($values as $key=>$value) {
diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/include/FanctrlLogic.php b/src/usr/local/emhttp/plugins/fanctrlplus2/include/FanctrlLogic.php
index afb2865..6e74bf7 100644
--- a/src/usr/local/emhttp/plugins/fanctrlplus2/include/FanctrlLogic.php
+++ b/src/usr/local/emhttp/plugins/fanctrlplus2/include/FanctrlLogic.php
@@ -35,7 +35,7 @@
$op = $_GET['op'] ?? $_POST['op'] ?? '';
// Serialize restore/export with every HTTP writer of these configuration files.
-if (in_array($op, ['savelabel','newtemp','delete','setsyslog','saveorder','start','stop','fcp_airflow_toggle','fcp_history_toggle'], true)) {
+if (in_array($op, ['savelabel','newtemp','delete','setsyslog','setnotify','saveorder','start','stop','fcp_airflow_toggle','fcp_history_toggle'], true)) {
try { $config_lock = fcp_config_lock($cfg_dir); }
catch (Throwable $error) { http_response_code(500); json_response(['status'=>'error','message'=>$error->getMessage()]); }
register_shutdown_function(function () use ($config_lock) {
@@ -246,7 +246,10 @@ function () use ($service, &$was_running) { if ($was_running) $service('start');
echo render_disk_group_row($fan_index, $group_index, $empty_group, $disks);
exit;
+ // Per-fan switches: Syslog, and Problem Alerts (read by the loop on each alert).
case 'setsyslog':
+ case 'setnotify':
+ $key = $op === 'setsyslog' ? 'syslog' : 'notify';
$cfg_file = basename($_POST['cfg']);
$enabled = isset($_POST['enabled']) && $_POST['enabled'] == 1 ? 1 : 0;
@@ -257,13 +260,13 @@ function () use ($service, &$was_running) { if ($was_running) $service('start');
$lines = file($cfg_path, FILE_IGNORE_NEW_LINES);
$found = false;
foreach ($lines as &$line) {
- if (strpos($line, 'syslog=') === 0) {
- $line = 'syslog="' . $enabled . '"';
+ if (strpos(ltrim($line, " \t"), "$key=") === 0) {
+ $line = $key . '="' . $enabled . '"';
$found = true;
}
}
if (!$found) {
- $lines[] = 'syslog="' . $enabled . '"';
+ $lines[] = $key . '="' . $enabled . '"';
}
file_put_contents($cfg_path, implode("\n", $lines) . "\n");
echo json_encode(['status' => 'ok']);
diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/include/update.fanctrlplus2.php b/src/usr/local/emhttp/plugins/fanctrlplus2/include/update.fanctrlplus2.php
index 7d17924..ca3d44a 100644
--- a/src/usr/local/emhttp/plugins/fanctrlplus2/include/update.fanctrlplus2.php
+++ b/src/usr/local/emhttp/plugins/fanctrlplus2/include/update.fanctrlplus2.php
@@ -185,17 +185,6 @@
exit;
}
- $syslog_val = '1'; // Enabled by default.
- if (file_exists($old_path)) {
- $lines = file($old_path, FILE_IGNORE_NEW_LINES);
- foreach ($lines as $line) {
- if (strpos($line, 'syslog=') === 0) {
- $syslog_val = trim(explode('=', $line, 2)[1], "\"' \t\r\n");
- break;
- }
- }
- }
-
// Check whether another configuration uses the same custom name.
foreach (glob("$cfgpath/{$plugin}_*.cfg") as $existing) {
$info = parse_ini_file($existing);
@@ -278,7 +267,9 @@
'interval' => (string)max(1, (int)ceil($interval_seconds / 60)),
'disks' => $legacy_disks,
'disk_group_count' => (string)$disk_group_count,
- 'syslog' => $syslog_val,
+ // Per-fan switches set outside this form: keep them, both default on.
+ 'syslog' => (string)($previous['syslog'] ?? '1'),
+ 'notify' => (string)($previous['notify'] ?? '1'),
'cpu_enable' => $cpu_enable,
'cpu_sensor' => $cpu_sensor,
'cpu_min_temp' => $cpu_min_temp,
diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/disk_group_control.sh b/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/disk_group_control.sh
index 6e81e0b..813a6fe 100644
--- a/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/disk_group_control.sh
+++ b/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/disk_group_control.sh
@@ -257,10 +257,9 @@ interval_seconds() {
# ===== MSI nct6687 stuck-fan check (#13) =====
# On MSI boards in the driver's msi_alt1 layout, system-fan channels (pwm3 and
# up) ignore speed changes unless the msi_fan_brute_force option is set, and
-# stay at about 60%. Reading pwmN back returns the chip's actual output, so a
-# fan driven above 60% that keeps reading back lower is caught here. Drivers
+# stay at about 60%. Reading pwmN back returns the chip's actual output, so
+# fcp_check_fan catches a fan driven above 60% that keeps reading back lower. Drivers
# that expose fan_control_watchdog run with the option set and are skipped.
-fcp_notify_bin="${fcp_notify_bin:-/usr/local/emhttp/webGui/scripts/notify}"
fcp_nct6687_module="${fcp_nct6687_module:-/sys/module/nct6687}"
fcp_nct6687_msi_channel() {
@@ -277,30 +276,149 @@ fcp_pwm_percent() {
echo $(( ($1 * 100 + 127) / 255 ))
}
-# Echoes when the current stuck stretch began (the given time, or the start
-# carried over), or nothing when the fan is not stuck. Stuck means set above
-# 60% but reading back 60% or less: the speed a fan ignoring its writes holds.
-# The caller warns once a stretch has lasted 10 seconds, which a chip still
-# ramping up would not.
-fcp_stuck_since() {
- local since="$1" now="$2" target="$3" actual="$4"
- if [[ "$actual" =~ ^[0-9]+$ ]] &&
- (( $(fcp_pwm_percent "$target") > 60 && $(fcp_pwm_percent "$actual") <= 60 )); then
- echo "${since:-$now}"
+# ===== Fan problem alerts =====
+# Problems with one fan never stop its loop or any other fan's: they only
+# raise Unraid notifications, which the fan's "Problem Alerts" switch
+# (notify=0 in its cfg) silences. The switch is read the way the loop reads
+# its cfg, when a notification is due, so flipping it needs no restart.
+fcp_notify_bin="${fcp_notify_bin:-/usr/local/emhttp/webGui/scripts/notify}"
+
+fcp_alerts_enabled() {
+ (unset notify; source "${cfg_file:-}" >/dev/null 2>&1; [[ "${notify:-1}" != 0 ]])
+}
+
+# Sends an Unraid notification (notify's -i, -s and -d options). A hung
+# notify must not stall fan control.
+fcp_notify() {
+ timeout 30 "$fcp_notify_bin" -e "FanCtrl Plus 2" -l "/Settings/fanctrlplus2" "$@"
+}
+
+# Seconds a problem must last before it warns, and must stay gone before it
+# counts as recovered: longer than a fan takes to spin up or a chip takes to
+# apply a new speed, and long enough that a flapping problem does not spam.
+fcp_fault_grace_seconds=30
+# Set above this share of full speed, a fan that spun before should spin. The
+# default Min Speed is 40%, where some fans stop by design.
+fcp_stopped_min_percent=40
+fcp_controller_subject="has lost its PWM controller"
+fcp_faults=()
+fcp_ok=()
+
+# Checks one fan, set to target (the PWM value last written; nct is 1 for a
+# channel fcp_nct6687_msi_channel matched). Sets fcp_faults to this round's
+# problems as "key|subject|details" entries, and fcp_ok to the keys seen to
+# be fine. A key in neither could not be checked this round. The fan's flag
+# directory remembers that it has a tachometer, across loop restarts.
+fcp_check_fan() {
+ local controller="$1" fan_input="$2" target="$3" nct="$4" dir="$5" mode actual rpm want got
+ fcp_faults=()
+ fcp_ok=()
+ want=$(fcp_pwm_percent "$target")
+ if [[ ! -e "$controller" ]]; then
+ fcp_faults+=("controller|$fcp_controller_subject|${controller:-Its controller} no longer exists, so the fan speed cannot be set. The controller may have been unplugged or renumbered, or its driver unloaded. Check the controller on the FanCtrl Plus 2 settings page.")
+ return
+ fi
+ fcp_ok+=(controller)
+ # Something else drives the fan, so its speed and RPM say nothing about us.
+ # An enable attribute that exists but cannot be read leaves all this unchecked.
+ if [[ -e "${controller}_enable" ]]; then
+ mode=$(cat "${controller}_enable" 2>/dev/null)
+ [[ -n "$mode" ]] || return
+ if [[ "$mode" != 1 ]]; then
+ fcp_faults+=("mode|is no longer under FanCtrl Plus 2 control|${controller}_enable changed from manual (1) to $mode, so the BIOS or another program sets the fan speed. FanCtrl Plus 2 takes the fan back at its next speed change.")
+ return
+ fi
+ fi
+ fcp_ok+=(mode)
+ # Most drivers read back the value last written; a chip that ignores a
+ # write, or a write that failed, reads back something else.
+ actual=$(cat "$controller" 2>/dev/null)
+ if [[ "$actual" =~ ^[0-9]+$ ]]; then
+ got=$(fcp_pwm_percent "$actual")
+ # On MSI boards the nct6687 msi_alt1 layout holds ignored fans at about 60% (#13).
+ if (( nct && want > 60 && got <= 60 )); then
+ fcp_faults+=("speed|is not reaching its set speed|Set to $want% but the nct6687 controller reports $got%. On MSI boards this driver ignores system-fan speed changes unless its msi_fan_brute_force option is set. See the FanCtrl Plus 2 settings page.")
+ elif (( want - got >= 10 || got - want >= 10 )); then
+ fcp_faults+=("speed|is not reaching its set speed|Set to $want% but the controller reports $got%. The controller may be ignoring speed changes, or another program may be setting it.")
+ else
+ fcp_ok+=(speed)
+ fi
+ fi
+ # A fan without a tachometer always reads 0, so only a fan seen spinning on
+ # this controller (.spun names it) can count as stopped; at a low speed it
+ # may have stopped by design.
+ rpm=$(cat "$fan_input" 2>/dev/null)
+ [[ "$rpm" =~ ^[0-9]+$ ]] || return
+ if (( rpm > 0 )); then
+ [[ "$(cat "$dir/.spun" 2>/dev/null)" == "$controller" ]] ||
+ printf '%s' "$controller" > "$dir/.spun" 2>/dev/null
+ fcp_ok+=(stopped)
+ elif [[ "$(cat "$dir/.spun" 2>/dev/null)" == "$controller" ]] && (( want > fcp_stopped_min_percent )); then
+ fcp_faults+=("stopped|has stopped|Set to $want% but the fan reports 0 RPM, although it was spinning earlier. It may have failed, stalled or been unplugged.")
fi
}
-# One Unraid notification per fan per boot; the flag lives on tmpfs. Without
-# a flag every tick would notify again, so no flag means no notification. A
-# failed notification drops the flag, so the next tick tries again.
-fcp_notify_stuck() {
- local name="$1" target="$2" actual="$3" flag="$4"
- [[ -e "$flag" ]] && return 0
- touch "$flag" 2>/dev/null || return 0
- "$fcp_notify_bin" -e "FanCtrl Plus 2" -i warning -l "/Settings/fanctrlplus2" \
- -s "Fan $name is not reaching its set speed" \
- -d "Set to $(fcp_pwm_percent "$target")% but the nct6687 controller reports $(fcp_pwm_percent "$actual")%. On MSI boards this driver ignores system-fan speed changes unless its msi_fan_brute_force option is set. See the FanCtrl Plus 2 settings page." \
- || rm -f "$flag"
+# Problem key -> when the current stretch of it being bad, or being fine, began.
+declare -gA fcp_fault_since=() fcp_clear_since=()
+
+# Records fcp_check_fan's results for a fan. A problem lasting the grace
+# period sends one warning; once it has been fine for the grace period, one
+# notice says it recovered. A key that could not be checked restarts both
+# clocks. A warned problem's flag file holds its subject, so a loop restart
+# neither repeats the warning nor loses the recovery notice. The flag is
+# written only once the warning is sent, so a failed or interrupted warning
+# is tried again.
+fcp_track_faults() {
+ local now="$1" name="$2" dir="$3" fault key subject details flag
+ local -A bad=() ok=()
+ for key in "${fcp_ok[@]}"; do
+ ok[$key]=1
+ : "${fcp_clear_since[$key]:=$now}"
+ done
+ for fault in "${fcp_faults[@]}"; do
+ IFS='|' read -r key subject details <<< "$fault"
+ bad[$key]=1
+ : "${fcp_fault_since[$key]:=$now}"
+ (( now - fcp_fault_since[$key] >= fcp_fault_grace_seconds )) || continue
+ flag="$dir/$key"
+ [[ -e "$flag" ]] && continue
+ fcp_alerts_enabled || continue
+ # Without a writable flag, every round would warn again.
+ [[ -d "$dir" && -w "$dir" ]] || continue
+ fcp_notify -i warning -s "Fan $name $subject" -d "$details" &&
+ printf '%s' "$subject" > "$flag" 2>/dev/null
+ done
+ for key in "${!fcp_fault_since[@]}"; do
+ [[ -n "${bad[$key]:-}" ]] || unset 'fcp_fault_since[$key]'
+ done
+ for key in "${!fcp_clear_since[@]}"; do
+ [[ -n "${ok[$key]:-}" ]] || unset 'fcp_clear_since[$key]'
+ done
+ for flag in "$dir"/*; do
+ key=${flag##*/}
+ [[ -f "$flag" && -n "${ok[$key]:-}" ]] || continue
+ (( now - fcp_clear_since[$key] >= fcp_fault_grace_seconds )) || continue
+ # Alerts switched off: drop the incident without a notice.
+ if fcp_alerts_enabled; then
+ fcp_notify -i normal -s "Fan $name has recovered" \
+ -d "This earlier warning has cleared: Fan $name $(cat "$flag")." || continue
+ fi
+ rm -f "$flag"
+ done
+}
+
+# Warns once that a fan's loop could not start because its controller is
+# missing. The flag directory is the loop's own, so the loop announces the
+# recovery once the controller is back and the fan is started again.
+fcp_warn_not_started() {
+ local cfg_file="$1" name="$2" controller="$3" dir="$4"
+ mkdir -p "$dir" 2>/dev/null
+ [[ -e "$dir/controller" ]] && return 0
+ fcp_alerts_enabled || return 0
+ [[ -d "$dir" && -w "$dir" ]] || return 0
+ fcp_notify -i warning -s "Fan $name $fcp_controller_subject" \
+ -d "${controller:-Its PWM controller} is missing or cannot be written, so FanCtrl Plus 2 did not start controlling this fan. The controller may be unplugged, renumbered or ambiguous, or its driver not loaded. Check the controller on the FanCtrl Plus 2 settings page, then apply." &&
+ printf '%s' "$fcp_controller_subject" > "$dir/controller" 2>/dev/null
}
# ===== Configured sensors without a reading (#21) =====
@@ -337,17 +455,19 @@ fcp_track_missing() {
done
}
-# One Unraid notification per fan per boot, flagged like the stuck-fan one,
-# naming the sensors given after the flag. A sensor that keeps dropping in
-# and out warns only once.
+# One Unraid notification per fan per boot, naming the sensors given after
+# the flag. The flag lives on tmpfs; without one every tick would notify
+# again, so no flag means no notification. A failed notification drops the
+# flag, so the next tick tries again. A sensor that keeps dropping in and out
+# warns only once.
fcp_notify_missing() {
local name="$1" flag="$2" sensors
shift 2
printf -v sensors '%s, ' "$@"
[[ -e "$flag" ]] && return 0
+ fcp_alerts_enabled || return 0
touch "$flag" 2>/dev/null || return 0
- "$fcp_notify_bin" -e "FanCtrl Plus 2" -i warning -l "/Settings/fanctrlplus2" \
- -s "Fan $name has no reading from a configured sensor" \
+ fcp_notify -i warning -s "Fan $name has no reading from a configured sensor" \
-d "No reading for at least $fcp_missing_grace_seconds seconds from: ${sensors%, }. The fan cannot react to these sensors while they are unread, and stays at its Idle or Min Speed when no other source has a reading." \
|| rm -f "$flag"
}
diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/fanctrlplus2_loop.sh b/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/fanctrlplus2_loop.sh
index e8eba5c..9f868b0 100644
--- a/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/fanctrlplus2_loop.sh
+++ b/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/fanctrlplus2_loop.sh
@@ -45,9 +45,11 @@ else
fan_path=""
fi
-stuck_since=""
nct6687_watch=0
fcp_nct6687_msi_channel "$controller" && nct6687_watch=1
+# Problems this fan is being warned about, one file each (fcp_track_faults).
+fault_dir="/var/tmp/${plugin}/faults_${custom}"
+mkdir -p "$fault_dir"
prev_pwm=-1
hist_file="/var/tmp/${plugin}/history_${plugin}_${custom}"
@@ -200,16 +202,11 @@ while true; do
fi
fi
- # Fans the nct6687 MSI layout may ignore: warn once one has stayed at 60% or
- # less for 10 seconds while set above it. The target is the last value
- # written, which a change under 5 leaves in place.
- if (( nct6687_watch )); then
- actual_pwm=$(cat "$controller" 2>/dev/null)
- stuck_since=$(fcp_stuck_since "$stuck_since" "$SECONDS" "$prev_pwm" "$actual_pwm")
- if [[ -n "$stuck_since" ]] && (( SECONDS - stuck_since >= 10 )); then
- fcp_notify_stuck "$custom" "$prev_pwm" "$actual_pwm" "/var/tmp/${plugin}/nct6687_stuck_${custom}"
- fi
- fi
+ # Warn about a lost controller, lost control, a speed the controller does
+ # not take or a fan that stopped. The target is the last value written,
+ # which a change under 5 leaves in place.
+ fcp_check_fan "$controller" "$fan_path" "$prev_pwm" "$nct6687_watch" "$fault_dir"
+ fcp_track_faults "$SECONDS" "$custom" "$fault_dir"
# Configured sensors that each stay unread past the grace period: warn once.
fcp_track_missing "$SECONDS" "${unread[@]}"
diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/rc.fanctrlplus2 b/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/rc.fanctrlplus2
index 0541630..f43a4d0 100644
--- a/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/rc.fanctrlplus2
+++ b/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/rc.fanctrlplus2
@@ -20,6 +20,7 @@ cfg_path="/boot/config/plugins/$plugin"
loop_script="/usr/local/emhttp/plugins/$plugin/scripts/fanctrlplus2_loop.sh"
dashboard_updater="/usr/local/emhttp/plugins/$plugin/scripts/fanctrlplus2_dashboard_update.sh"
pid_dir="/var/run"
+source "/usr/local/emhttp/plugins/$plugin/scripts/disk_group_control.sh"
fanctrlplus2.start() {
# Clear the user-requested stop marker.
@@ -36,12 +37,17 @@ fanctrlplus2.start() {
service=$(grep -Po '^service="\K[^"]+' "$cfg")
[[ "$service" != "1" ]] && continue
+ raw_custom=$(grep -Po '^custom="\K[^"]+' "$cfg")
+
# Serial resolution can leave an absent/ambiguous controller unassigned.
controller=$(grep -Po '^controller="\K[^"]+' "$cfg")
- [[ -n "$controller" && -w "$controller" ]] || continue
+ if [[ -z "$controller" || ! -w "$controller" ]]; then
+ [[ -n "$raw_custom" ]] &&
+ fcp_warn_not_started "$cfg" "$raw_custom" "$controller" "/var/tmp/$plugin/faults_$raw_custom"
+ continue
+ fi
# Sanitize the custom name before using it in a PID filename.
- raw_custom=$(grep -Po '^custom="\K[^"]+' "$cfg")
clean_custom=$(echo "$raw_custom" | tr -d '\r\n\t ')
custom_safe=$(echo "$clean_custom" | tr -cd '[:alnum:]_-')
@@ -52,6 +58,14 @@ fanctrlplus2.start() {
logger -t "$plugin" "Started [$custom_safe] from $cfg"
done
+ # Drop the alert state of fans no configuration names any more.
+ local -A names=()
+ while IFS= read -r name; do names[$name]=1; done \
+ < <(grep -hPo '^custom="\K[^"]+' "$cfg_path"/${plugin}_*.cfg 2>/dev/null)
+ for dir in /var/tmp/$plugin/faults_*; do
+ [[ -d "$dir" && -z "${names[${dir##*/faults_}]:-}" ]] && rm -rf "$dir"
+ done
+
# Start array_monitor.sh if it is not already running.
script="/usr/local/emhttp/plugins/fanctrlplus2/scripts/array_monitor.sh"
if [ -x "$script" ] && ! pgrep -f "$script" >/dev/null; then
diff --git a/tests/config_backup_test.php b/tests/config_backup_test.php
index 8402aa1..3d5aa7a 100644
--- a/tests/config_backup_test.php
+++ b/tests/config_backup_test.php
@@ -25,6 +25,7 @@
disk_group_0_low="40"
disk_group_0_high="60"
syslog="1"
+notify="0"
cpu_enable="0"
cpu_sensor=""
cpu_min_temp=""
diff --git a/tests/fan_alerts_test.sh b/tests/fan_alerts_test.sh
new file mode 100755
index 0000000..fd451be
--- /dev/null
+++ b/tests/fan_alerts_test.sh
@@ -0,0 +1,279 @@
+#!/bin/bash
+
+set -u
+
+root=$(cd "$(dirname "$0")/.." && pwd)
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+
+fcp_nct6687_module="$tmp/module"
+fcp_notify_bin="$tmp/notify"
+source "$root/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/aux_sensors.sh"
+source "$root/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/disk_group_control.sh"
+
+failures=0
+expect_equal() {
+ local expected="$1" actual="$2" message="$3"
+ if [[ "$expected" == "$actual" ]]; then
+ return
+ fi
+ printf '%s\nExpected: %s\nActual: %s\n' "$message" "$expected" "$actual" >&2
+ failures=$((failures + 1))
+}
+
+# ===== fcp_nct6687_msi_channel =====
+nct="$tmp/hwmon7" other="$tmp/hwmon3"
+mkdir -p "$nct" "$other" "$fcp_nct6687_module/parameters"
+echo nct6687 > "$nct/name"
+echo it8728 > "$other/name"
+channel() { fcp_nct6687_msi_channel "$1" && echo yes || echo no; }
+
+expect_equal no "$(channel "$nct/pwm5")" "Without the driver loaded, no channel is watched."
+echo default > "$fcp_nct6687_module/parameters/fan_config"
+expect_equal no "$(channel "$nct/pwm5")" "The default register layout applies speed changes."
+echo msi_alt1 > "$fcp_nct6687_module/parameters/fan_config"
+expect_equal yes "$(channel "$nct/pwm3")" "The first system-fan channel is watched."
+expect_equal no "$(channel "$nct/pwm2")" "The pump channel is not affected."
+expect_equal no "$(channel "$other/pwm5")" "Another chip's channel is not watched."
+echo nct6683 > "$other/name"
+expect_equal yes "$(channel "$other/pwm5")" "The driver's other chip names are watched too."
+touch "$nct/fan_control_watchdog"
+expect_equal no "$(channel "$nct/pwm5")" "A driver running with brute force on is not watched."
+
+# ===== fcp_check_fan =====
+hw="$tmp/hw" spun="$tmp/fan1"
+mkdir -p "$hw" "$spun"
+fan() { # pwm readback, pwmN_enable ('' for none), fan RPM ('' for none)
+ echo "$1" > "$hw/pwm1"
+ if [[ -n "$2" ]]; then echo "$2" > "$hw/pwm1_enable"; else rm -f "$hw/pwm1_enable"; fi
+ if [[ -n "$3" ]]; then echo "$3" > "$hw/fan1_input"; else rm -f "$hw/fan1_input"; fi
+}
+check() { # target [nct]; prints the problem keys, then the keys seen fine
+ local fault
+ fcp_check_fan "$hw/pwm1" "$hw/fan1_input" "$1" "${2:-0}" "$spun"
+ for fault in "${fcp_faults[@]}"; do printf '%s;' "${fault%%|*}"; done
+ printf '/%s' "${fcp_ok[*]}"
+}
+
+fan 255 1 0
+expect_equal '/controller mode speed' "$(check 255)" "A fan never seen spinning may have no tachometer: 0 RPM is not judged."
+fan 255 1 1200
+expect_equal '/controller mode speed stopped' "$(check 255)" "A fan at its set speed and spinning is fine on every count."
+expect_equal yes "$([[ -e "$spun/.spun" ]] && echo yes)" "A fan reporting RPM is remembered, across loop restarts, as having a tachometer."
+
+fan 120 1 1200
+expect_equal 'speed;/controller mode stopped' "$(check 255)" "A controller reading back far below the set speed is a problem."
+fan 255 1 1200
+expect_equal 'speed;/controller mode stopped' "$(check 120)" "A controller reading back far above the set speed is a problem."
+fan 230 1 1200
+expect_equal 'speed;/controller mode stopped' "$(check 255)" "A readback 10 points off is a problem."
+fan 232 1 1200
+expect_equal '/controller mode speed stopped' "$(check 255)" "A readback 9 points off is fine."
+fan 154 1 1200
+expect_equal '/controller mode speed stopped' "$(check 166)" "65% read back as 60% is within the general tolerance."
+expect_equal 'speed;/controller mode stopped' "$(check 166 1)" "An nct6687 MSI fan set to 65% and held at 60% is a problem."
+expect_equal 'speed;/controller mode stopped' "$(check 155 1)" "61% is above 60%; a reading of 154 rounds to 60%."
+expect_equal '/controller mode speed stopped' "$(check 154 1)" "An nct6687 MSI fan set to 60% at 60% is fine."
+fan 155 1 1200
+expect_equal '/controller mode speed stopped' "$(check 166 1)" "An nct6687 MSI fan reading above 60% is not held."
+fan 154 1 1200
+fcp_check_fan "$hw/pwm1" "$hw/fan1_input" 255 1 "$spun"
+expect_equal 1 "$(grep -c 'msi_fan_brute_force' <<< "${fcp_faults[0]}")" "The nct6687 warning names the driver option that fixes it."
+fcp_check_fan "$hw/pwm1" "$hw/fan1_input" 255 0 "$spun"
+expect_equal 0 "$(grep -c 'msi_fan_brute_force' <<< "${fcp_faults[0]}")" "Other controllers do not get the nct6687 advice."
+fan foo 1 1200
+expect_equal '/controller mode stopped' "$(check 255)" "An unreadable speed is not judged."
+
+fan 120 2 0
+expect_equal 'mode;/controller' "$(check 255)" "Out of manual mode, only the lost control is reported and nothing else judged."
+fan 255 '' 1200
+expect_equal '/controller mode speed stopped' "$(check 255)" "A controller without an enable attribute has no mode to lose."
+fan 255 1 1200
+: > "$hw/pwm1_enable"
+expect_equal '/controller' "$(check 255)" "An enable attribute without a value leaves the mode and everything after it unchecked."
+fcp_check_fan "$hw/pwm9" "$hw/fan9_input" 255 0 "$spun"
+expect_equal 'controller/' "${fcp_faults[0]%%|*}/${fcp_ok[*]}" "A missing controller is reported and nothing else judged."
+
+fan 255 1 0
+expect_equal 'stopped;/controller mode speed' "$(check 255)" "A fan that spun before and reads 0 RPM at full speed has stopped."
+fan 105 1 0
+expect_equal 'stopped;/controller mode speed' "$(check 105)" "Above 40% a fan is expected to spin."
+fan 102 1 0
+expect_equal '/controller mode speed' "$(check 102)" "At the default Min Speed of 40% a fan may stop by design: not judged."
+fan 255 1 ''
+expect_equal '/controller mode speed' "$(check 255)" "Without an RPM reading the fan's spinning is not judged."
+mkdir -p "$tmp/hw2" && echo 255 > "$tmp/hw2/pwm1" && echo 0 > "$tmp/hw2/fan1_input"
+fcp_check_fan "$tmp/hw2/pwm1" "$tmp/hw2/fan1_input" 255 0 "$spun"
+expect_equal '/controller mode speed' "/${fcp_ok[*]}${fcp_faults[*]}" "A fan moved to another controller does not inherit the old one's tachometer: 0 RPM is not judged."
+
+# ===== fcp_track_faults =====
+printf '#!/bin/bash\nprintf "%%s\\n" "$@" >> "%s/calls"\n' "$tmp" > "$fcp_notify_bin"
+chmod +x "$fcp_notify_bin"
+cfg_file="$tmp/fan.cfg"
+echo 'notify="1"' > "$cfg_file"
+dir="$tmp/faults" && mkdir -p "$dir"
+speed='speed|is not reaching its set speed|Set to 100% but the controller reports 47%.'
+stopped='stopped|has stopped|Set to 100% but the fan reports 0 RPM.'
+sent() { grep -c "^$1\$" "$tmp/calls"; }
+round() { # time, then problems ("key|subject|details") and keys seen fine
+ local now="$1" arg
+ shift
+ fcp_faults=() fcp_ok=()
+ for arg in "$@"; do
+ if [[ "$arg" == *'|'* ]]; then fcp_faults+=("$arg"); else fcp_ok+=("$arg"); fi
+ done
+ fcp_track_faults "$now" Bay "$dir"
+}
+: > "$tmp/calls"
+
+round 0 "$speed"
+round 29 "$speed"
+expect_equal 0 "$(sent warning)" "A problem shorter than 30 seconds does not warn."
+round 30 "$speed"
+expect_equal 1 "$(sent warning)" "A problem lasting 30 seconds warns."
+expect_equal 1 "$(sent 'Fan Bay is not reaching its set speed')" "The warning names the fan and the problem."
+round 60 "$speed"
+expect_equal 1 "$(sent warning)" "An ongoing problem warns once."
+fcp_fault_since=() fcp_clear_since=()
+round 70 "$speed"
+round 100 "$speed"
+expect_equal 1 "$(sent warning)" "A restarted loop does not repeat a warning already sent."
+round 110
+round 200
+expect_equal 0 "$(sent normal)" "A problem that cannot be checked has not recovered."
+round 210 speed
+round 239 speed
+expect_equal 0 "$(sent normal)" "A problem gone for less than 30 seconds has not recovered."
+round 240 speed
+expect_equal 1 "$(sent normal)" "A warned problem gone for 30 seconds sends a recovery notice."
+expect_equal 1 "$(sent 'This earlier warning has cleared: Fan Bay is not reaching its set speed.')" "The notice says which problem resolved."
+round 300 speed
+expect_equal 1 "$(sent normal)" "A recovery is announced once."
+
+: > "$tmp/calls"
+round 400 "$stopped"
+round 430 "$stopped"
+round 440 stopped
+round 460 "$stopped"
+round 470 stopped
+round 490 "$stopped"
+expect_equal 1 "$(sent warning)" "A flapping problem warns once."
+expect_equal 0 "$(sent normal)" "A flapping problem is not announced as recovered."
+round 500 stopped
+round 515
+round 520 stopped
+round 549 stopped
+expect_equal 0 "$(sent normal)" "A round that cannot check restarts the recovery clock."
+round 550 stopped
+expect_equal 1 "$(sent normal)" "Fine for 30 seconds in a row, the problem has recovered."
+round 600 "$speed"
+round 610 speed
+round 630 "$speed"
+expect_equal 1 "$(sent warning)" "A problem that clears starts a new stretch, and one never warned about clears silently."
+
+: > "$tmp/calls"
+echo 'notify="0"' > "$cfg_file"
+round 700 "$stopped"
+round 730 "$stopped"
+expect_equal 0 "$(sent warning)" "With Problem Alerts off, nothing is sent."
+echo 'notify=1' > "$cfg_file"
+round 740 "$stopped"
+expect_equal 1 "$(sent warning)" "Switched back on, an ongoing problem warns without a restart."
+echo ' notify=0' > "$cfg_file"
+round 750 stopped
+round 780 stopped
+expect_equal 0 "$(sent normal)" "With Problem Alerts off, in any format the cfg accepts, a recovery is not announced."
+expect_equal '' "$(ls "$dir")" "A recovery while alerts are off still closes the incident."
+echo 'custom="Bay"' > "$cfg_file"
+round 800 "$stopped"
+round 830 "$stopped"
+expect_equal 2 "$(sent warning)" "A cfg without the switch, as on every upgraded fan, has alerts on."
+rm "$cfg_file"
+round 840 stopped
+round 870 stopped
+expect_equal 1 "$(sent normal)" "Without a readable cfg, alerts stay on."
+echo 'notify="1"' > "$cfg_file"
+
+: > "$tmp/calls"
+good_notify=$fcp_notify_bin fcp_notify_bin=false
+round 900 "$stopped"
+round 930 "$stopped"
+fcp_notify_bin=$good_notify
+round 940 "$stopped"
+expect_equal 1 "$(sent warning)" "A warning that failed to send is tried again on the next round."
+fcp_notify_bin=false
+round 950 stopped
+round 980 stopped
+fcp_notify_bin=$good_notify
+round 990 stopped
+expect_equal 1 "$(sent normal)" "A recovery notice that failed to send is tried again on the next round."
+: > "$tmp/calls"
+fcp_fault_since=() fcp_clear_since=()
+fcp_faults=("$speed") fcp_ok=()
+fcp_track_faults 1000 Bay "$tmp/nodir"
+fcp_track_faults 1030 Bay "$tmp/nodir"
+fcp_track_faults 1040 Bay "$tmp/nodir"
+expect_equal 0 "$(sent warning)" "Without a flag directory no warning is sent, so rounds cannot repeat it."
+round 1100 "$speed"
+round 1130 "$speed"
+rm -f "$dir/speed"
+round 1140 "$speed"
+expect_equal 2 "$(sent warning)" "A warning whose flag is missing, as after an interrupted send, is sent again."
+
+# ===== fcp_warn_not_started =====
+: > "$tmp/calls"
+fcp_warn_not_started "$cfg_file" Bay '' "$tmp/unstarted"
+fcp_warn_not_started "$cfg_file" Bay '' "$tmp/unstarted"
+expect_equal 1 "$(sent 'Fan Bay has lost its PWM controller')" "A fan that cannot start warns once, until the loop sees its controller back."
+fcp_faults=() fcp_ok=(controller)
+fcp_track_faults 0 Bay "$tmp/unstarted"
+fcp_track_faults 30 Bay "$tmp/unstarted"
+expect_equal 1 "$(sent normal)" "Once the fan starts with its controller, the loop announces the recovery."
+
+# ===== fcp_track_missing / fcp_notify_missing (#21) =====
+overdue() { fcp_track_missing "$@"; (( ${#fcp_missing_overdue[@]} )) && printf '%s;' "${fcp_missing_overdue[@]}"; }
+expect_equal '' "$(overdue 100 A)" "A sensor unread just now is not overdue."
+fcp_track_missing 100 A
+expect_equal '' "$(overdue 159 A)" "59 seconds unread is within the grace period."
+expect_equal 'A;' "$(overdue 160 A)" "60 seconds unread is overdue."
+# Codex's case: alternating failures must not add up for any one sensor.
+fcp_track_missing 0
+fcp_track_missing 0 A
+fcp_track_missing 30 B
+expect_equal '' "$(overdue 60 A)" "A sensor that read in between starts a new stretch."
+fcp_track_missing 60 A
+expect_equal '' "$(overdue 90 A B)" "A sensor unread again restarts its own clock."
+fcp_track_missing 90 A B
+expect_equal 'A;' "$(overdue 120 A B)" "Each sensor is overdue on its own clock."
+fcp_track_missing 200
+expect_equal '' "$(overdue 200 A)" "A round with every sensor read forgets them all."
+expect_equal '' "$(overdue 300)" "No unread sensor, nothing overdue."
+
+: > "$tmp/calls"
+fcp_notify_missing HBA "$tmp/missing_flag" storcli:c0:roc custom:ambient
+fcp_notify_missing HBA "$tmp/missing_flag" storcli:c0:roc
+expect_equal 1 "$(grep -c '^-s$' "$tmp/calls")" "One missing-sensor notification per fan until the flag is cleared at boot."
+expect_equal 1 "$(grep -c 'from: storcli:c0:roc, custom:ambient\.' "$tmp/calls")" \
+ "The notification names every overdue sensor."
+expect_equal 1 "$(grep -cx 'warning' "$tmp/calls")" "The missing-sensor notification is a warning."
+fcp_notify_missing HBA "$tmp/missing/flag" storcli:c0:roc
+expect_equal 1 "$(grep -c '^-s$' "$tmp/calls")" "Without a missing-sensor flag file, no notification is sent, so ticks cannot repeat it."
+: > "$tmp/calls"
+good_notify=$fcp_notify_bin fcp_notify_bin=false
+fcp_notify_missing HBA "$tmp/retry_flag" storcli:c0:roc
+fcp_notify_bin=$good_notify
+fcp_notify_missing HBA "$tmp/retry_flag" storcli:c0:roc
+expect_equal 1 "$(grep -c '^-s$' "$tmp/calls")" "A notification that failed to send is tried again on the next tick."
+: > "$tmp/calls"
+echo 'notify="0"' > "$cfg_file"
+fcp_notify_missing HBA "$tmp/off_flag" storcli:c0:roc
+expect_equal 0 "$(grep -c '^-s$' "$tmp/calls")" "With Problem Alerts off, a missing sensor is not reported."
+echo 'notify="1"' > "$cfg_file"
+fcp_notify_missing HBA "$tmp/off_flag" storcli:c0:roc
+expect_equal 1 "$(grep -c '^-s$' "$tmp/calls")" "Switched back on, a missing sensor is still reported."
+
+if (( failures > 0 )); then
+ exit 1
+fi
+
+echo "fan alert tests passed"
diff --git a/tests/nct6687_stuck_test.sh b/tests/nct6687_stuck_test.sh
deleted file mode 100755
index 64afc5e..0000000
--- a/tests/nct6687_stuck_test.sh
+++ /dev/null
@@ -1,102 +0,0 @@
-#!/bin/bash
-
-set -u
-
-root=$(cd "$(dirname "$0")/.." && pwd)
-tmp=$(mktemp -d)
-trap 'rm -rf "$tmp"' EXIT
-
-fcp_nct6687_module="$tmp/module"
-fcp_notify_bin="$tmp/notify"
-source "$root/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/aux_sensors.sh"
-source "$root/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/disk_group_control.sh"
-
-failures=0
-expect_equal() {
- local expected="$1" actual="$2" message="$3"
- if [[ "$expected" == "$actual" ]]; then
- return
- fi
- printf '%s\nExpected: %s\nActual: %s\n' "$message" "$expected" "$actual" >&2
- failures=$((failures + 1))
-}
-
-# ===== fcp_nct6687_msi_channel =====
-nct="$tmp/hwmon7" other="$tmp/hwmon3"
-mkdir -p "$nct" "$other" "$fcp_nct6687_module/parameters"
-echo nct6687 > "$nct/name"
-echo it8728 > "$other/name"
-channel() { fcp_nct6687_msi_channel "$1" && echo yes || echo no; }
-
-expect_equal no "$(channel "$nct/pwm5")" "Without the driver loaded, no channel is watched."
-echo default > "$fcp_nct6687_module/parameters/fan_config"
-expect_equal no "$(channel "$nct/pwm5")" "The default register layout applies speed changes."
-echo msi_alt1 > "$fcp_nct6687_module/parameters/fan_config"
-expect_equal yes "$(channel "$nct/pwm3")" "The first system-fan channel is watched."
-expect_equal no "$(channel "$nct/pwm2")" "The pump channel is not affected."
-expect_equal no "$(channel "$other/pwm5")" "Another chip's channel is not watched."
-echo nct6683 > "$other/name"
-expect_equal yes "$(channel "$other/pwm5")" "The driver's other chip names are watched too."
-touch "$nct/fan_control_watchdog"
-expect_equal no "$(channel "$nct/pwm5")" "A driver running with brute force on is not watched."
-
-# ===== fcp_stuck_since =====
-expect_equal 100 "$(fcp_stuck_since '' 100 255 154)" "Set to 100%, stuck at 60%: a stretch starts now."
-expect_equal 100 "$(fcp_stuck_since 100 110 166 154)" "A new target above 60% keeps the stretch going."
-expect_equal 100 "$(fcp_stuck_since 100 110 155 154)" "61% is above 60%; a reading of 154 rounds to 60%."
-expect_equal '' "$(fcp_stuck_since 100 110 154 120)" "A target of 60% or less ends the stretch."
-expect_equal '' "$(fcp_stuck_since 100 110 255 155)" "A reading above 60% ends the stretch."
-expect_equal '' "$(fcp_stuck_since 100 110 255 '')" "An unreadable value ends the stretch."
-
-# ===== fcp_notify_stuck =====
-printf '#!/bin/bash\nprintf "%%s\\n" "$@" >> "%s/calls"\n' "$tmp" > "$fcp_notify_bin"
-chmod +x "$fcp_notify_bin"
-fcp_notify_stuck HardDriveFans 255 154 "$tmp/flag"
-fcp_notify_stuck HardDriveFans 255 154 "$tmp/flag"
-expect_equal 1 "$(grep -c '^-s$' "$tmp/calls")" "One notification per fan until the flag is cleared at boot."
-expect_equal 1 "$(grep -c '^Set to 100% but the nct6687 controller reports 60%' "$tmp/calls")" \
- "The notification reports the set and actual speeds as percentages."
-expect_equal 1 "$(grep -cx 'warning' "$tmp/calls")" "The notification is a warning."
-fcp_notify_stuck HardDriveFans 255 154 "$tmp/missing/flag"
-expect_equal 1 "$(grep -c '^-s$' "$tmp/calls")" "Without a flag file, no notification is sent, so ticks cannot repeat it."
-
-# ===== fcp_track_missing / fcp_notify_missing (#21) =====
-overdue() { fcp_track_missing "$@"; (( ${#fcp_missing_overdue[@]} )) && printf '%s;' "${fcp_missing_overdue[@]}"; }
-expect_equal '' "$(overdue 100 A)" "A sensor unread just now is not overdue."
-fcp_track_missing 100 A
-expect_equal '' "$(overdue 159 A)" "59 seconds unread is within the grace period."
-expect_equal 'A;' "$(overdue 160 A)" "60 seconds unread is overdue."
-# Codex's case: alternating failures must not add up for any one sensor.
-fcp_track_missing 0
-fcp_track_missing 0 A
-fcp_track_missing 30 B
-expect_equal '' "$(overdue 60 A)" "A sensor that read in between starts a new stretch."
-fcp_track_missing 60 A
-expect_equal '' "$(overdue 90 A B)" "A sensor unread again restarts its own clock."
-fcp_track_missing 90 A B
-expect_equal 'A;' "$(overdue 120 A B)" "Each sensor is overdue on its own clock."
-fcp_track_missing 200
-expect_equal '' "$(overdue 200 A)" "A round with every sensor read forgets them all."
-expect_equal '' "$(overdue 300)" "No unread sensor, nothing overdue."
-
-: > "$tmp/calls"
-fcp_notify_missing HBA "$tmp/missing_flag" storcli:c0:roc custom:ambient
-fcp_notify_missing HBA "$tmp/missing_flag" storcli:c0:roc
-expect_equal 1 "$(grep -c '^-s$' "$tmp/calls")" "One missing-sensor notification per fan until the flag is cleared at boot."
-expect_equal 1 "$(grep -c 'from: storcli:c0:roc, custom:ambient\.' "$tmp/calls")" \
- "The notification names every overdue sensor."
-expect_equal 1 "$(grep -cx 'warning' "$tmp/calls")" "The missing-sensor notification is a warning."
-fcp_notify_missing HBA "$tmp/missing/flag" storcli:c0:roc
-expect_equal 1 "$(grep -c '^-s$' "$tmp/calls")" "Without a missing-sensor flag file, no notification is sent, so ticks cannot repeat it."
-: > "$tmp/calls"
-good_notify=$fcp_notify_bin fcp_notify_bin=false
-fcp_notify_missing HBA "$tmp/retry_flag" storcli:c0:roc
-fcp_notify_bin=$good_notify
-fcp_notify_missing HBA "$tmp/retry_flag" storcli:c0:roc
-expect_equal 1 "$(grep -c '^-s$' "$tmp/calls")" "A notification that failed to send is tried again on the next tick."
-
-if (( failures > 0 )); then
- exit 1
-fi
-
-echo "nct6687 stuck-fan tests passed"
diff --git a/tests/settings_layout_test.php b/tests/settings_layout_test.php
index b0c10ef..acf15e1 100644
--- a/tests/settings_layout_test.php
+++ b/tests/settings_layout_test.php
@@ -6,7 +6,7 @@
$identifyStart = strpos($page, '>Identify PWM Controller');
$generalStart = strpos($page, '>General Settings');
-$syslogStart = strpos($page, '>Syslog Setting');
+$syslogStart = strpos($page, '>Syslog & Alerts');
if ($identifyStart === false || $generalStart === false || $syslogStart === false) {
$failures[] = 'Identify, General Settings, and Syslog cards must all exist.';