Files
fan_speed/monitor.sh
Zeb Hering 562d8e8d46 Add disk temperature monitoring, trend tracking and email alarms
Fan speed now takes the loudest request from CPU/GPU and from each drive
interpolated against its own reported temperature limit, so bay position
and drive technology stop mattering. Reported limits are clamped by class
because WD Reds report the SCT critical limit (85) rather than an
operating maximum.

Adds a rolling temperature history and four alarms (absolute, rising
trend, SMART health, disk count), rate limited per key with a one hour
cooldown, delivered by mail through the host relay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 18:36:07 -07:00

221 lines
7.9 KiB
Bash

#!/bin/bash
#
# Disk temperature monitoring, trend tracking and email alarms.
#
# Sourced by fan_speed.sh alongside functions.sh. functions.sh is vendored from
# upstream (tigerblue77/Dell_iDRAC_fan_controller_Docker); everything in here is
# local, so keep the two files separate to keep that boundary readable.
# ---------------------------------------------------------------- configuration
# Internal drives only. The DAS shelf enumerates behind a SAS expander
# (*-sas-exp*) and has its own controller - it must not drive server fans.
DISK_GLOB=${DISK_GLOB:-/dev/disk/by-path/pci-*-scsi-*}
# Each drive's fan ramp is derived from its own maximum operating temperature:
# ramp starts at limit - DISK_RAMP_LOW_OFFSET
# full speed at limit - DISK_RAMP_HIGH_OFFSET
# Larger low offset = react earlier = louder. See README before changing.
DISK_RAMP_LOW_OFFSET=${DISK_RAMP_LOW_OFFSET:-18}
DISK_RAMP_HIGH_OFFSET=${DISK_RAMP_HIGH_OFFSET:-8}
# A drive this close to its own limit raises an alarm - the fans are already
# flat out for it and it is still climbing.
DISK_ALARM_OFFSET=${DISK_ALARM_OFFSET:-5}
# Drives report their limit but do not agree on what it means: Samsung and
# Kioxia report a true operating maximum (70), Toshiba and Seagate report 60,
# WD Reds report 85 - the SCT critical limit, not an operating maximum.
# Clamp by class so one optimistic drive cannot quietly disable cooling.
HDD_LIMIT_CAP=${HDD_LIMIT_CAP:-60}
SSD_LIMIT_CAP=${SSD_LIMIT_CAP:-70}
ALERT_EMAIL=${ALERT_EMAIL:-Servers@ntfy1.izebra.xyz}
ALERT_COOLDOWN=${ALERT_COOLDOWN:-3600}
STATE_DIR=${STATE_DIR:-/root/fan_speed/state}
LOG_FILE=${LOG_FILE:-/root/fan_speed/log/fan_speed.log}
TREND_FILE=${TREND_FILE:-/root/fan_speed/log/temps.csv}
TREND_SAMPLES=${TREND_SAMPLES:-90} # 90 x 10s CHECK_INTERVAL = 15 minutes
TREND_RISE_ALARM=${TREND_RISE_ALARM:-8} # degrees of climb across that window
SMART_CHECK_INTERVAL=${SMART_CHECK_INTERVAL:-3600}
# ------------------------------------------------------------------ disk temps
log_line() {
echo "$(date +'%Y-%m-%d %H:%M:%S') || $*" >> "$LOG_FILE"
}
# Block devices behind DISK_GLOB, deduplicated. Resolved fresh every call:
# kernel names are not stable, a shelf rescan renamed sdaa-sdai to sds-sdaa.
disk_devices() {
local link dev
for link in $DISK_GLOB; do
case "$link" in *-part*) continue ;; esac
[ -e "$link" ] || continue
dev=$(readlink -f "$link")
[ -b "$dev" ] && echo "$dev"
done | sort -u
}
# smartctl -A output on stdin -> temperature in celsius, empty if it did not say.
# Drives disagree on the attribute name: 194 Temperature_Celsius (most),
# 190 Airflow_Temperature_Cel (Seagate), 194 Temperature_Internal (Intel),
# and SCSI/SAS drives report a "Current Drive Temperature" line instead.
# Temperature_Difference_from_100 is a delta, not a reading - never match it.
parse_disk_temperature() {
awk '
/Current Drive Temperature/ { print $4; exit }
/^ *[0-9]+ +[A-Za-z_]*Temperature[A-Za-z_]*/ && !/Difference/ { print $10; exit }'
}
# smartctl -x output on stdin -> the drive's own maximum operating temperature.
parse_disk_limit() {
awk '
/Min\/Max Temperature Limit/ { split($0, a, /[ \/]+/); for (i in a) if (a[i] ~ /^[0-9]+$/) v = a[i]; print v; exit }
/Drive Trip Temperature/ { print $4; exit }'
}
# -n standby: skip a sleeping drive rather than spinning it up to measure it.
disk_temperature() {
smartctl -n standby -A "$1" 2>/dev/null | parse_disk_temperature
}
# The drive's own limit, clamped by class (see HDD_LIMIT_CAP above).
disk_limit() {
local dev=$1 limit rotational cap
limit=$(smartctl -x "$dev" 2>/dev/null | parse_disk_limit)
rotational=$(cat "/sys/block/$(basename "$dev")/queue/rotational" 2>/dev/null)
if [ "$rotational" = 1 ]; then cap=$HDD_LIMIT_CAP; else cap=$SSD_LIMIT_CAP; fi
if [ -z "$limit" ] || ! [ "$limit" -le "$cap" ] 2>/dev/null; then limit=$cap; fi
echo "$limit"
}
# Limits do not change, and smartctl -x is far heavier than -A. Read them once.
declare -A DISK_LIMIT
cache_disk_limits() {
local dev
DISK_LIMIT=()
for dev in $(disk_devices); do
DISK_LIMIT[$dev]=$(disk_limit "$dev")
done
DISK_COUNT_EXPECTED=${#DISK_LIMIT[@]}
}
# Reads every drive. Sets:
# HOTTEST_DISK_TEMPERATURE / _DEVICE / _LIMIT, DISK_FAN_SPEED, DISKS_READ
# Each drive is interpolated against its OWN limit and the highest resulting fan
# speed wins - so a 50c SSD rated to 70 asks for less than a 45c disk rated to 60.
# That makes the curve independent of drive technology and of bay position.
retrieve_disk_temperatures() {
local dev temp limit speed
HOTTEST_DISK_TEMPERATURE=0
HOTTEST_DISK_DEVICE="-"
HOTTEST_DISK_LIMIT=0
DISK_FAN_SPEED=$LOW_FAN_SPEED
DISKS_READ=0
for dev in $(disk_devices); do
temp=$(disk_temperature "$dev")
[ -n "$temp" ] || continue
DISKS_READ=$((DISKS_READ + 1))
limit=${DISK_LIMIT[$dev]}
[ -n "$limit" ] || limit=$(disk_limit "$dev")
speed=$(calculate_interpolated_fan_speed "$temp" \
$((limit - DISK_RAMP_LOW_OFFSET)) $((limit - DISK_RAMP_HIGH_OFFSET)) \
"$LOW_FAN_SPEED" "$HIGH_FAN_SPEED")
[ "$speed" -gt "$DISK_FAN_SPEED" ] && DISK_FAN_SPEED=$speed
if [ "$temp" -gt "$HOTTEST_DISK_TEMPERATURE" ]; then
HOTTEST_DISK_TEMPERATURE=$temp
HOTTEST_DISK_DEVICE=$dev
HOTTEST_DISK_LIMIT=$limit
fi
done
}
# --------------------------------------------------------------------- alarms
notify() {
local subject=$1 body=$2
if [ -n "$DRY_RUN" ]; then
echo "MAIL[$ALERT_EMAIL] $subject"
else
printf '%s\n' "$body" | mail -s "$subject" "$ALERT_EMAIL"
fi
log_line "ALERT $subject"
}
# raise_alert <key> <subject> <body>
# Rate limited per key. Without this a 10s loop sends 360 mails an hour and the
# alarm becomes the outage.
raise_alert() {
local key=$1 subject=$2 body=$3
local stamp="$STATE_DIR/$key.alert" now last
mkdir -p "$STATE_DIR"
now=$(date +%s)
if [ -f "$stamp" ]; then
last=$(cat "$stamp")
[ $((now - last)) -lt "$ALERT_COOLDOWN" ] && return 0
fi
echo "$now" > "$stamp"
notify "$subject" "$body"
}
# clear_alert <key> <subject> - one recovery notice, only if the alarm was up.
clear_alert() {
local key=$1 subject=$2
local stamp="$STATE_DIR/$key.alert"
[ -f "$stamp" ] || return 0
rm -f "$stamp"
notify "$subject" "Recovered at $(date '+%Y-%m-%d %H:%M:%S')."
}
# ---------------------------------------------------------------------- trend
# record_sample <cpu> <gpu> <disk> <fan>
record_sample() {
mkdir -p "$(dirname "$TREND_FILE")"
echo "$(date +%s),$1,$2,$3,$4" >> "$TREND_FILE"
if [ "$(wc -l < "$TREND_FILE")" -gt $((TREND_SAMPLES * 2)) ]; then
tail -n "$TREND_SAMPLES" "$TREND_FILE" > "$TREND_FILE.tmp" && mv "$TREND_FILE.tmp" "$TREND_FILE"
fi
}
# rise_over_window <column> -> degrees climbed from the oldest sample in the
# window to the newest. 0 until there is a full window of history, so a restart
# cannot alarm on a partial series.
# Columns: 2 cpu, 3 gpu, 4 disk, 5 fan.
rise_over_window() {
local column=$1 oldest newest lines
lines=$(wc -l < "$TREND_FILE" 2>/dev/null || echo 0)
if [ "$lines" -lt "$TREND_SAMPLES" ]; then echo 0; return; fi
oldest=$(tail -n "$TREND_SAMPLES" "$TREND_FILE" | head -1 | cut -d, -f"$column")
newest=$(tail -n 1 "$TREND_FILE" | cut -d, -f"$column")
[ -n "$oldest" ] && [ -n "$newest" ] || { echo 0; return; }
echo $((newest - oldest))
}
# --------------------------------------------------------------- SMART health
# Expensive across two dozen drives - the caller runs this hourly, not per loop.
check_disk_health() {
local dev status failed=""
for dev in $(disk_devices); do
status=$(smartctl -H "$dev" 2>/dev/null | grep -iE "overall-health|SMART Health Status")
[ -n "$status" ] || continue
case "$status" in
*PASSED*|*OK*) ;;
*) failed+="$dev: $status"$'\n' ;;
esac
done
if [ -n "$failed" ]; then
raise_alert smart "SMART failure on $(hostname)" "$failed"
else
clear_alert smart "SMART recovered on $(hostname)"
fi
}