Say no to CM5 overheating in your uConsole: a practical, low-cost fix

You asked an excellent question, not directly, but it made me think of a possible scenario: What if I turn on ALL the radios and increase the consumption? Well, at most you can use GPS and SDRs together — if you use everything at once, it can make the system unstable, and it might even crash or reboot depending on the kernel, drivers, and the fights between IOs, they clash from time to time, they’re kind of selfish! Well, in my setup, even turning on ALL the radios, even increasing the load, nothing changed in terms of temperature… it stays stable! And thank you for this question, I hadn’t thought about turning on all the radios at the same time!

Tkz Again!

#!/usr/bin/env bash
# uConsole CM5 HW Monitor v3 - btop-like layout
# No external UI framework. Uses ANSI cursor positioning and fixed panels.

INTERVAL=${INTERVAL:-1}
SMART_INTERVAL=${SMART_INTERVAL:-30}
AIOV2_INTERVAL=${AIOV2_INTERVAL:-3}
AIOV2_TIMEOUT=${AIOV2_TIMEOUT:-2}
BAR_W=${BAR_W:-18}
LOG_INTERVAL=${LOG_INTERVAL:-5}
LOG_FILE=${LOG_FILE:-memory_report.log}
LOG_ENABLED=${LOG_ENABLED:-1}

ESC=$'\033'
RESET="${ESC}[0m"
BOLD="${ESC}[1m"
DIM="${ESC}[2m"
GREEN="${ESC}[32m"
YELLOW="${ESC}[33m"
RED="${ESC}[31m"
BLUE="${ESC}[36m"
MAG="${ESC}[35m"
WHITE="${ESC}[37m"

START_TS=$(date +%s)
LAST_TS=""
LAST_TEMP=""
FAN_ON_SEC=0
FAN_TOTAL_SEC=0
LAST_SMART_TS=0
SMART_CACHE="NVMES collecting..."
AIOV2_CACHE="N/A|N/A|N/A|N/A|N/A|not available"
LAST_AIOV2_TS=0
FAN_RESULT="N/A|not detected|--|--|off|0h00min|0"
PREV_CPU_TOTAL=""
PREV_CPU_IDLE=""
CPU_RESULT=0
GPU_TEMP="N/A"
GPU_TEMP_NOTE=""
TEMP_HISTORY_VALUES=()
TEMP_HISTORY_TIMES=()
THERMAL_TREND="baseline"
LAST_LOG_TS=0

cleanup() {
  trap - EXIT INT TERM
  printf "%b" "${ESC}[?25h${RESET}\n"
  stty sane 2>/dev/null || true
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

printf "%b" "${ESC}[?25l"

term_rows() { tput lines 2>/dev/null || echo 24; }
term_cols() { tput cols 2>/dev/null || echo 80; }

mvcur() { printf "%b" "${ESC}[$1;$2H"; }
clreol() { printf "%b" "${ESC}[K"; }

line_at() {
  local r="$1" c="$2" text="$3"
  mvcur "$r" "$c"
  printf "%b" "$text"
  clreol
}

read_sys() { [[ -r "$1" ]] && cat "$1" 2>/dev/null || echo ""; }


csv_escape() {
  local value="${1//$'\r'/ }"
  value="${value//$'\n'/ }"
  value="${value//\"/\"\"}"
  printf '"%s"' "$value"
}

init_log_file() {
  [[ "$LOG_ENABLED" == "1" ]] || return
  if [[ ! -e "$LOG_FILE" || ! -s "$LOG_FILE" ]]; then
    printf '%s\n' 'timestamp,epoch,uptime,cpu_usage_pct,load_1m,load_5m,load_15m,cpu_cores,cpu_freq_mhz,cpu_governor,ram_usage_pct,ram_used_gb,ram_total_gb,cpu_temp_c,gpu_temp_c,gpu_temp_source,nvme_temp_c,radio_temperatures,thermal_trend,throttle,gpu_clocks,battery_capacity_pct,battery_name,power_source,battery_status,battery_voltage_v,battery_current_a,battery_power_w,battery_energy_or_charge,battery_reported_health,battery_soc_status,fan_pct,fan_device,fan_type,fan_state_raw,fan_status,fan_on_time,fan_duty_pct,aiov2_gps,aiov2_lora,aiov2_sdr,aiov2_usb,aiov2_power,aiov2_raw,nvme_device,nvme_health,nvme_smart_temp_c,nvme_endurance_used_pct,nvme_spare,nvme_data_read,nvme_data_written,nvme_power_on_hours,nvme_unsafe_shutdowns,nvme_media_errors,nvme_log_entries,nvme_critical_warning,hwmon_sensors,pcie_links' >> "$LOG_FILE"
  fi
}

log_snapshot() {
  [[ "$LOG_ENABLED" == "1" ]] || return
  local now_epoch="$1"
  shift
  (( now_epoch - LAST_LOG_TS >= LOG_INTERVAL )) || return
  LAST_LOG_TS=$now_epoch
  init_log_file

  local first=1 value
  {
    for value in "$@"; do
      if (( first )); then first=0; else printf ','; fi
      csv_escape "$value"
    done
    printf '\n'
  } >> "$LOG_FILE"
}

clamp_pct() {
  local pct="${1%.*}"
  [[ -z "$pct" || "$pct" == "N/A" ]] && pct=0
  (( pct < 0 )) && pct=0
  (( pct > 100 )) && pct=100
  echo "$pct"
}

bar_plain() {
  local pct color filled empty i
  pct=$(clamp_pct "$1")
  color="$2"
  filled=$((pct * BAR_W / 100))
  empty=$((BAR_W - filled))
  printf "%b" "$color"
  for ((i=0;i<filled;i++)); do printf "█"; done
  printf "%b" "$DIM"
  for ((i=0;i<empty;i++)); do printf "░"; done
  printf "%b" "$RESET"
}

bar_high_bad() {
  local pct color
  pct=$(clamp_pct "$1")
  if (( pct < 60 )); then color="$GREEN"; elif (( pct < 85 )); then color="$YELLOW"; else color="$RED"; fi
  bar_plain "$pct" "$color"
}

bar_high_good() {
  local pct color
  pct=$(clamp_pct "$1")
  if (( pct >= 60 )); then color="$GREEN"; elif (( pct >= 30 )); then color="$YELLOW"; else color="$RED"; fi
  bar_plain "$pct" "$color"
}

status_temp_short() {
  local t="$1"
  [[ "$t" == "N/A" ]] && echo "N/A" && return
  awk -v t="$t" 'BEGIN { if (t<40) print "OK cool"; else if (t<50) print "OK warm"; else if (t<60) print "ATT warm"; else if (t<70) print "BAD hot"; else print "CRIT" }'
}

status_color() {
  case "$1" in
    OK*) printf "%b%s%b" "$GREEN" "$1" "$RESET" ;;
    ATT*|WARN*|CHECK*) printf "%b%s%b" "$YELLOW" "$1" "$RESET" ;;
    BAD*|CRIT*) printf "%b%s%b" "$RED" "$1" "$RESET" ;;
    *) printf "%b%s%b" "$BLUE" "$1" "$RESET" ;;
  esac
}

fmt_seconds() { local s="$1"; printf "%dh%02dmin" $((s/3600)) $(((s%3600)/60)); }

fmt_time_h() {
  awk -v h="$1" 'BEGIN { if(h<0)h=0; total=int(h*60+0.5); printf "%dh%02dmin", int(total/60), total%60 }'
}

find_battery() {
  for p in /sys/class/power_supply/*; do
    [[ -d "$p" ]] || continue
    [[ "$(read_sys "$p/type")" == "Battery" ]] && echo "$p" && return
  done
}

ac_online() {
  for p in /sys/class/power_supply/*; do
    [[ -d "$p" ]] || continue
    case "$(read_sys "$p/type")" in
      Mains|USB|USB_C|USB_PD|AC) [[ "$(read_sys "$p/online")" == "1" ]] && echo 1 && return ;;
    esac
  done
  echo 0
}

thermal_by_type() {
  local pattern="$1" z type v
  for z in /sys/class/thermal/thermal_zone*; do
    [[ -d "$z" ]] || continue
    type=$(read_sys "$z/type")
    [[ "$type" =~ $pattern ]] || continue
    v=$(read_sys "$z/temp")
    [[ "$v" =~ ^-?[0-9]+$ ]] || continue
    awk -v v="$v" 'BEGIN {printf "%.1f", v/1000}'
    return
  done
  echo "N/A"
}

temp_cpu() {
  local t max="" z v c
  t=$(thermal_by_type 'cpu|soc|package')
  [[ "$t" != "N/A" ]] && { echo "$t"; return; }
  for z in /sys/class/thermal/thermal_zone*/temp; do
    [[ -r "$z" ]] || continue
    v=$(read_sys "$z")
    [[ "$v" =~ ^[0-9]+$ ]] || continue
    (( v > 1000 )) || continue
    c=$(awk -v v="$v" 'BEGIN {printf "%.1f", v/1000}')
    if [[ -z "$max" ]] || awk -v c="$c" -v max="$max" 'BEGIN {exit !(c > max)}'; then max="$c"; fi
  done
  echo "${max:-N/A}"
}

temp_gpu() {
  local t f name label v
  GPU_TEMP="N/A"
  GPU_TEMP_NOTE=""
  t=$(thermal_by_type 'gpu|v3d')
  if [[ "$t" != "N/A" ]]; then
    GPU_TEMP="$t"
    GPU_TEMP_NOTE="dedicated"
    return
  fi
  for f in /sys/class/hwmon/hwmon*/temp*_input; do
    [[ -r "$f" ]] || continue
    name=$(read_sys "$(dirname "$f")/name")
    label=$(read_sys "${f%_input}_label")
    [[ "${name} ${label}" =~ [Gg][Pp][Uu]|[Vv]3[Dd] ]] || continue
    v=$(read_sys "$f")
    [[ "$v" =~ ^-?[0-9]+$ ]] || continue
    GPU_TEMP=$(awk -v v="$v" 'BEGIN {printf "%.1f", v/1000}')
    GPU_TEMP_NOTE="dedicated"
    return
  done
  # On Raspberry Pi 5/CM5 the V3D GPU is part of the SoC and commonly has
  # no separate thermal zone. In that case, use the SoC/CPU sensor explicitly.
  t=$(temp_cpu)
  if [[ "$t" != "N/A" ]]; then
    GPU_TEMP="$t"
    GPU_TEMP_NOTE="*"
  fi
}

radio_temp_summary() {
  local f name label v t found=0 out=""
  for f in /sys/class/hwmon/hwmon*/temp*_input; do
    [[ -r "$f" ]] || continue
    name=$(read_sys "$(dirname "$f")/name")
    label=$(read_sys "${f%_input}_label")
    [[ "${name} ${label}" =~ [Rr][Tt][Ll]|[Ss][Dd][Rr]|[Ll][Oo][Rr][Aa]|[Gg][Pp][Ss]|[Rr][Aa][Dd][Ii][Oo]|[Ww][Ii][Ff][Ii]|[Ww][Ll][Aa][Nn]|mt7[0-9]+ ]] || continue
    v=$(read_sys "$f")
    [[ "$v" =~ ^-?[0-9]+$ ]] || continue
    t=$(awk -v v="$v" 'BEGIN {printf "%.1f", v/1000}')
    [[ -n "$out" ]] && out+=", "
    out+="${label:-$name} ${t}C"
    found=1
  done
  (( found )) && echo "$out" || echo "N/A (no radio thermal sensor)"
}

cpu_usage() {
  local _ user nice system idle iowait irq softirq steal total idle_all diff_total diff_idle usage
  read -r _ user nice system idle iowait irq softirq steal _ < /proc/stat
  idle_all=$((idle + iowait))
  total=$((user + nice + system + idle + iowait + irq + softirq + steal))
  if [[ -n "$PREV_CPU_TOTAL" ]]; then
    diff_total=$((total - PREV_CPU_TOTAL))
    diff_idle=$((idle_all - PREV_CPU_IDLE))
    if (( diff_total > 0 )); then
      usage=$((100 * (diff_total - diff_idle) / diff_total))
    else
      usage=0
    fi
  else
    usage=0
  fi
  PREV_CPU_TOTAL=$total
  PREV_CPU_IDLE=$idle_all
  CPU_RESULT=$usage
}

update_thermal_trend() {
  local current="$1" now cutoff first_v first_t last_v last_t rate i
  if [[ ! "$current" =~ ^-?[0-9]+([.][0-9]+)?$ ]]; then
    THERMAL_TREND="N/A"
    return
  fi
  now=$(date +%s)
  TEMP_HISTORY_VALUES+=("$current")
  TEMP_HISTORY_TIMES+=("$now")
  cutoff=$((now - 30))
  while (( ${#TEMP_HISTORY_TIMES[@]} > 1 )) && (( TEMP_HISTORY_TIMES[0] < cutoff )); do
    TEMP_HISTORY_TIMES=("${TEMP_HISTORY_TIMES[@]:1}")
    TEMP_HISTORY_VALUES=("${TEMP_HISTORY_VALUES[@]:1}")
  done
  if (( ${#TEMP_HISTORY_TIMES[@]} < 2 )); then
    THERMAL_TREND="baseline"
LAST_LOG_TS=0
    return
  fi
  first_v=${TEMP_HISTORY_VALUES[0]}
  first_t=${TEMP_HISTORY_TIMES[0]}
  i=$((${#TEMP_HISTORY_TIMES[@]} - 1))
  last_v=${TEMP_HISTORY_VALUES[$i]}
  last_t=${TEMP_HISTORY_TIMES[$i]}
  if (( last_t <= first_t )); then
    THERMAL_TREND="stable"
    return
  fi
  rate=$(awk -v a="$first_v" -v b="$last_v" -v dt="$((last_t-first_t))" 'BEGIN {printf "%.1f", ((b-a)/dt)*60}')
  if awk -v r="$rate" 'BEGIN {exit !(r > -0.15 && r < 0.15)}'; then
    THERMAL_TREND="stable"
  else
    THERMAL_TREND="${rate}C/min (30s)"
  fi
}

temp_nvme() {
  local f name v t
  for f in /sys/class/hwmon/hwmon*/temp*_input; do
    [[ -r "$f" ]] || continue
    name=$(cat "$(dirname "$f")/name" 2>/dev/null)
    case "$name" in
      nvme|*nvme*)
        v=$(cat "$f" 2>/dev/null)
        [[ "$v" =~ ^[0-9]+$ ]] || continue
        t=$(awk "BEGIN {printf \"%.1f\", $v/1000}")
        echo "$t"; return ;;
    esac
  done
  echo "N/A"
}

cpu_freq() { local f="/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq"; [[ -r "$f" ]] && awk '{printf "%d", $1/1000}' "$f" || echo "N/A"; }

cpu_governor() { local f="/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor"; [[ -r "$f" ]] && cat "$f" || echo "N/A"; }

ram_info() {
  awk '
    /MemTotal:/ {total=$2}
    /MemAvailable:/ {avail=$2}
    END {used=total-avail; pct=(used/total)*100; printf "%.0f %.1f %.1f", pct, used/1048576, total/1048576}' /proc/meminfo
}

throttle_info() {
  if command -v vcgencmd >/dev/null 2>&1; then
    local out code
    out=$(vcgencmd get_throttled 2>/dev/null)
    code=${out#*=}
    [[ -z "$code" || "$code" == "$out" ]] && { echo "N/A CHECK"; return; }
    if [[ "$code" == "0x0" ]]; then echo "0x0 OK"; else echo "$code CHECK"; fi
  else
    echo "N/A no-vcgencmd"
  fi
}

gpu_info() {
  local v3d="N/A" core="N/A" out
  if command -v vcgencmd >/dev/null 2>&1; then
    out=$(vcgencmd measure_clock v3d 2>/dev/null); [[ "$out" == frequency* ]] && v3d=$(awk -F= '{printf "%d", $2/1000000}' <<<"$out")
    out=$(vcgencmd measure_clock core 2>/dev/null); [[ "$out" == frequency* ]] && core=$(awk -F= '{printf "%d", $2/1000000}' <<<"$out")
  fi
  echo "V3D ${v3d}MHz CORE ${core}MHz"
}

pcie_lines() {
  local n=0 d speed width
  for d in /sys/bus/pci/devices/*; do
    [[ -d "$d" ]] || continue
    speed=$(read_sys "$d/current_link_speed")
    width=$(read_sys "$d/current_link_width")
    [[ -z "$speed" && -z "$width" ]] && continue
    n=$((n+1))
    printf "%-12s %s x%s\n" "$(basename "$d")" "${speed:-?}" "${width:-?}"
    (( n >= 4 )) && break
  done
  (( n == 0 )) && echo "N/A"
}

sensors_lines() {
  local count=0 f name v t base line seen=""
  for f in /sys/class/hwmon/hwmon*/temp*_input; do
    [[ -r "$f" ]] || continue
    base=$(dirname "$f")
    name=$(cat "$base/name" 2>/dev/null)
    [[ -z "$name" ]] && name="$(basename "$base")"
    v=$(cat "$f" 2>/dev/null)
    [[ "$v" =~ ^[0-9]+$ ]] || continue
    t=$(awk "BEGIN {printf \"%.1f\", $v/1000}")
    line="${name} ${t}"
    [[ "$seen" == *"|$line|"* ]] && continue
    seen+="|$line|"
    count=$((count+1))
    printf "%-14s %5s C %s\n" "$name" "$t" "$(status_temp_short "$t")"
    (( count >= 7 )) && break
  done
  (( count == 0 )) && echo "N/A"
}

status_soc_short() {
  local c="$1"
  [[ -z "$c" || "$c" == "N/A" ]] && echo "N/A" && return
  if (( c >= 80 )); then echo "OK high"; elif (( c >= 40 )); then echo "OK norm"; elif (( c >= 20 )); then echo "ATT charge"; elif (( c >= 10 )); then echo "BAD low"; else echo "CRIT"; fi
}

health_from_energy_short() {
  local full="$1" design="$2"
  [[ -z "$full" ]] && echo "N/A" && return
  if [[ -n "$design" && "$design" -gt 0 ]]; then
    awk -v f="$full" -v d="$design" 'BEGIN {h=(f/d)*100; if(h>100)h=100; if(h>=90)printf "OK %.0f%%",h; else if(h>=80)printf "OK wear %.0f%%",h; else if(h>=70)printf "ATT %.0f%%",h; else printf "BAD %.0f%%",h}'
  else
    awk -v f="$full" 'BEGIN {printf "%.1fWh", f/1000000}'
  fi
}

battery_info() {
  local BAT ONLINE CAP STAT VOLT_RAW CURR_RAW PWR_RAW ENOW EFULL EDESIGN VOLT_A CURR_A PWR_W NAME ENERGY_MODE
  BAT=$(find_battery); ONLINE=$(ac_online)
  if [[ -z "$BAT" ]]; then echo "BAT N/A"; return; fi
  NAME=$(basename "$BAT")
  CAP=$(read_sys "$BAT/capacity"); [[ -z "$CAP" ]] && CAP="N/A"
  STAT=$(read_sys "$BAT/status"); [[ -z "$STAT" ]] && STAT="Unknown"
  VOLT_RAW=$(read_sys "$BAT/voltage_now"); CURR_RAW=$(read_sys "$BAT/current_now"); PWR_RAW=$(read_sys "$BAT/power_now")
  ENERGY_MODE="energy"
  ENOW=$(read_sys "$BAT/energy_now"); EFULL=$(read_sys "$BAT/energy_full"); EDESIGN=$(read_sys "$BAT/energy_full_design")
  if [[ -z "$ENOW" || -z "$EFULL" ]]; then
    ENERGY_MODE="charge"
    ENOW=$(read_sys "$BAT/charge_now")
    EFULL=$(read_sys "$BAT/charge_full")
    EDESIGN=$(read_sys "$BAT/charge_full_design")
  fi
  [[ -n "$VOLT_RAW" ]] && VOLT_A=$(awk "BEGIN {printf \"%.2f\", $VOLT_RAW/1000000}") || VOLT_A="N/A"
  [[ -n "$CURR_RAW" ]] && CURR_A=$(awk "BEGIN {printf \"%.2f\", $CURR_RAW/1000000}") || CURR_A="N/A"
  if [[ -n "$PWR_RAW" && "$PWR_RAW" =~ ^[0-9]+$ && "$PWR_RAW" -gt 0 ]]; then PWR_W=$(awk "BEGIN {printf \"%.2f\", $PWR_RAW/1000000}");
  elif [[ "$VOLT_A" != "N/A" && "$CURR_A" != "N/A" ]]; then PWR_W=$(awk "BEGIN {printf \"%.2f\", $VOLT_A*$CURR_A}"); else PWR_W="N/A"; fi
  local src="BAT"; [[ "$ONLINE" == 1 ]] && src="USB-C"
  local eng="N/A" health="N/A"
  if [[ -n "$ENOW" && -n "$EFULL" ]]; then
    if [[ "$ENERGY_MODE" == "energy" ]]; then
      eng=$(awk -v n="$ENOW" -v f="$EFULL" 'BEGIN {printf "%.2f/%.2fWh", n/1000000, f/1000000}')
    else
      eng=$(awk -v n="$ENOW" -v f="$EFULL" 'BEGIN {printf "%.2f/%.2fAh", n/1000000, f/1000000}')
    fi
    health=$(health_from_energy_short "$EFULL" "$EDESIGN")
  fi
  echo "$CAP|$NAME|$src|$STAT|$VOLT_A|$CURR_A|$PWR_W|$eng|$health|$(status_soc_short "$CAP")"
}

fan_info() {
  local dev type cur max pct delta duty
  for dev in /sys/class/thermal/cooling_device*; do
    [[ -d "$dev" ]] || continue
    type=$(read_sys "$dev/type")
    [[ "$type" =~ fan|Fan ]] || continue
    cur=$(read_sys "$dev/cur_state"); max=$(read_sys "$dev/max_state")
    [[ -z "$cur" ]] && cur=0; [[ -z "$max" || "$max" == 0 ]] && max=255
    pct=$(awk "BEGIN {printf \"%.0f\", ($cur/$max)*100}")
    FAN_TOTAL_SEC=$((FAN_TOTAL_SEC + INTERVAL))
    (( cur > 0 )) && FAN_ON_SEC=$((FAN_ON_SEC + INTERVAL))
    duty=$(awk "BEGIN {printf \"%.0f\", ($FAN_ON_SEC/$FAN_TOTAL_SEC)*100}")
    FAN_RESULT="$pct|$(basename "$dev")|$type|$cur/$max|$([[ $cur -gt 0 ]] && echo on || echo off)|$(fmt_seconds "$FAN_ON_SEC")|$duty"
    return
  done
  FAN_RESULT="N/A|not detected|--|--|off|0h00min|0"
}

aiov2_update() {
  local now out line gps lora sdr usb power
  now=$(date +%s)
  (( now - LAST_AIOV2_TS < AIOV2_INTERVAL )) && return
  LAST_AIOV2_TS=$now
  command -v aiov2_ctl >/dev/null 2>&1 || { AIOV2_CACHE="N/A|N/A|N/A|N/A|N/A|aiov2_ctl not installed"; return; }
  out=$(timeout "${AIOV2_TIMEOUT}s" aiov2_ctl --watch 2>/dev/null | tr '\r' '\n' | tail -n 1)
  [[ -z "$out" ]] && { AIOV2_CACHE="N/A|N/A|N/A|N/A|N/A|no watch output"; return; }
  line=$(sed 's/\x1b\[[0-9;]*m//g' <<<"$out")
  parse_state() {
    local key="$1" val
    val=$(grep -Eio "${key}[[:space:]:=_-]*(ON|OFF|UNKNOWN|HI|LO)" <<<"$line" | head -n1 | grep -Eio 'ON|OFF|UNKNOWN|HI|LO' | head -n1)
    case "$val" in HI) val="ON";; LO) val="OFF";; esac
    echo "${val:-N/A}"
  }
  gps=$(parse_state GPS); lora=$(parse_state LORA); sdr=$(parse_state SDR); usb=$(parse_state USB)
  power=$(grep -Eo '[-+]?[0-9]+([.][0-9]+)?[[:space:]]*W' <<<"$line" | tail -n1 | tr -d ' ')
  AIOV2_CACHE="$gps|$lora|$sdr|$usb|${power:-N/A}|$line"
}

smart_update() {
  local now dev out health temp used spare thres read written hours unsafe media log crit
  now=$(date +%s)
  (( now - LAST_SMART_TS < SMART_INTERVAL )) && return
  LAST_SMART_TS=$now
  for dev in /dev/nvme0 /dev/nvme0n1; do
    [[ -e "$dev" ]] || continue
    out=$(sudo -n smartctl -a "$dev" 2>/dev/null || smartctl -a "$dev" 2>/dev/null)
    [[ "$out" == *"SMART/Health Information"* ]] || continue
    health=$(awk -F: '/SMART overall-health/{gsub(/^ +/,"",$2); print $2; exit}' <<<"$out")
    temp=$(awk -F: '/^Temperature:/{gsub(/[^0-9]/,"",$2); print $2; exit}' <<<"$out")
    used=$(awk -F: '/Percentage Used:/{gsub(/[^0-9]/,"",$2); print $2; exit}' <<<"$out")
    spare=$(awk -F: '/Available Spare:/{gsub(/[^0-9]/,"",$2); print $2; exit}' <<<"$out")
    thres=$(awk -F: '/Available Spare Threshold:/{gsub(/[^0-9]/,"",$2); print $2; exit}' <<<"$out")
    read=$(awk -F'[][]' '/Data Units Read:/{print $2; exit}' <<<"$out")
    written=$(awk -F'[][]' '/Data Units Written:/{print $2; exit}' <<<"$out")
    hours=$(awk -F: '/Power On Hours:/{gsub(/[^0-9]/,"",$2); print $2; exit}' <<<"$out")
    unsafe=$(awk -F: '/Unsafe Shutdowns:/{gsub(/[^0-9]/,"",$2); print $2; exit}' <<<"$out")
    media=$(awk -F: '/Media and Data Integrity Errors:/{gsub(/[^0-9]/,"",$2); print $2; exit}' <<<"$out")
    log=$(awk -F: '/Error Information Log Entries:/{gsub(/[^0-9]/,"",$2); print $2; exit}' <<<"$out")
    crit=$(awk -F: '/Critical Warning:/{gsub(/^ +/,"",$2); print $2; exit}' <<<"$out")
    SMART_CACHE="$dev|${health:-N/A}|${temp:-N/A}|${used:-N/A}|${spare:-N/A}/${thres:-N/A}|${read:-N/A}|${written:-N/A}|${hours:-N/A}|${unsafe:-N/A}|${media:-N/A}|${log:-N/A}|${crit:-N/A}"
    return
  done
  SMART_CACHE="N/A|unable to read SMART|N/A|N/A|N/A|N/A|N/A|N/A|N/A|N/A|N/A|N/A"
}

box_hline() { local w="$1" i; printf "┌"; for ((i=0;i<w-2;i++)); do printf "─"; done; printf "┐"; }
bot_hline() { local w="$1" i; printf "└"; for ((i=0;i<w-2;i++)); do printf "─"; done; printf "┘"; }
sep_line() { local w="$1" i; printf "├"; for ((i=0;i<w-2;i++)); do printf "─"; done; printf "┤"; }
inner_rule() { local w="$1" i; printf ""; for ((i=0;i<w;i++)); do printf "─"; done; }

strip_ansi() { sed $'s/\033\\[[0-9;]*m//g' <<<"$1"; }

pad_line() {
  local content="$1" w="$2" plain len
  plain=$(strip_ansi "$content")
  len=${#plain}
  printf "│%b" "$content"
  if (( len < w-2 )); then printf "%*s" $((w-2-len)) ""; fi
  printf "│"
}

row2() {
  local r="$1" l="$2" rr="$3" pw="$4" gap_col=$((pw+1))
  mvcur "$r" 1; pad_line "$l" "$pw"
  mvcur "$r" "$gap_col"; pad_line "$rr" "$pw"
  clreol
}

fmt_uptime() {
  local up; up=$(cut -d. -f1 /proc/uptime 2>/dev/null || echo 0)
  printf "%02d:%02d:%02d" $((up/3600)) $(((up%3600)/60)) $((up%60))
}

kernel_short() { uname -r 2>/dev/null | cut -c1-20; }
arch_short() { uname -m 2>/dev/null; }
platform_short() {
  if [[ -r /proc/device-tree/model ]]; then tr -d '\0' </proc/device-tree/model | sed 's/Raspberry Pi/RPi/;s/Compute Module/CM/' | cut -c1-24
  else echo "CM5/Linux"; fi
}

# btop/neon-style fixed dashboard. It redraws the dashboard block in place, not the scrollback.
draw() {
  local cols rows pw w now load cores load1 loadp cpu_pct freq temp gpu_temp radio_temp nvme gov ram throt gpu batt fan smart sensors pcie uptime trend
  cols=$(term_cols); rows=$(term_rows)
  if (( cols < 120 || rows < 32 )); then
    printf "%b" "${ESC}[H${ESC}[2J"
    printf "Terminal too small. Required: 120x32 | Current: %sx%s\n" "$cols" "$rows"
    printf "Resize the terminal or reduce the font size. Press q to exit.\n"
    return
  fi
  pw=$(((cols-3)/2)); (( pw > 70 )) && pw=70; (( pw < 58 )) && pw=58
  w=$((pw*2))
  now=$(date '+%Y-%m-%d %H:%M:%S')

  load=$(cut -d' ' -f1-3 /proc/loadavg); cores=$(nproc); load1=$(awk '{print $1}' <<<"$load")
  loadp=$(awk -v l="$load1" -v c="$cores" 'BEGIN {printf "%.0f", (l/c)*100}')
  cpu_usage; cpu_pct="$CPU_RESULT"
  freq=$(cpu_freq); temp=$(temp_cpu); temp_gpu; gpu_temp="$GPU_TEMP"; radio_temp=$(radio_temp_summary); nvme=$(temp_nvme); gov=$(cpu_governor); ram=($(ram_info)); throt=$(throttle_info); gpu=$(gpu_info)
  batt=$(battery_info); fan_info; fan="$FAN_RESULT"; smart_update; aiov2_update
  sensors=$(sensors_lines); pcie=$(pcie_lines); uptime=$(fmt_uptime)
  update_thermal_trend "$temp"
  trend="$THERMAL_TREND"

  local bcap bname bsrc bstat bvolt bcurr bpwr beng bhealth bsoc
  IFS='|' read -r bcap bname bsrc bstat bvolt bcurr bpwr beng bhealth bsoc <<<"$batt"
  local fpct fdev ftype fpwm fstat ftime fduty
  IFS='|' read -r fpct fdev ftype fpwm fstat ftime fduty <<<"$fan"
  local sm_dev sm_health sm_temp sm_used sm_spare sm_read sm_wr sm_hours sm_unsafe sm_media sm_log sm_crit life_status unsafe_status
  IFS='|' read -r sm_dev sm_health sm_temp sm_used sm_spare sm_read sm_wr sm_hours sm_unsafe sm_media sm_log sm_crit <<<"$SMART_CACHE"
  if [[ "$sm_used" =~ ^[0-9]+$ ]]; then
    if (( sm_used >= 100 )); then life_status="CRIT"; elif (( sm_used >= 90 )); then life_status="BAD"; elif (( sm_used >= 70 )); then life_status="ATT"; else life_status="OK"; fi
  else life_status="N/A"; fi
  [[ "$sm_unsafe" =~ ^[0-9]+$ ]] && (( sm_unsafe >= 50 )) && unsafe_status="ATT" || unsafe_status="OK"
  local agps allora asdr ausb apower araw
  IFS='|' read -r agps allora asdr ausb apower araw <<<"$AIOV2_CACHE"

  local now_epoch load_1 load_5 load_15 sensors_csv pcie_csv
  now_epoch=$(date +%s)
  read -r load_1 load_5 load_15 <<<"$load"
  sensors_csv=$(tr '\n' ';' <<<"$sensors" | sed 's/;*$//')
  pcie_csv=$(tr '\n' ';' <<<"$pcie" | sed 's/;*$//')
  log_snapshot "$now_epoch" \
    "$now" "$now_epoch" "$uptime" "$cpu_pct" "$load_1" "$load_5" "$load_15" "$cores" "$freq" "$gov" \
    "${ram[0]}" "${ram[1]}" "${ram[2]}" "$temp" "$gpu_temp" "$GPU_TEMP_NOTE" "$nvme" "$radio_temp" "$trend" "$throt" "$gpu" \
    "$bcap" "$bname" "$bsrc" "$bstat" "$bvolt" "$bcurr" "$bpwr" "$beng" "$bhealth" "$bsoc" \
    "$fpct" "$fdev" "$ftype" "$fpwm" "$fstat" "$ftime" "$fduty" \
    "$agps" "$allora" "$asdr" "$ausb" "$apower" "$araw" \
    "$sm_dev" "$sm_health" "$sm_temp" "$sm_used" "$sm_spare" "$sm_read" "$sm_wr" "$sm_hours" "$sm_unsafe" "$sm_media" "$sm_log" "$sm_crit" \
    "$sensors_csv" "$pcie_csv"

  printf "%b" "${ESC}[H"
  line_at 1 1 "${GREEN}──────${RESET} ${BOLD}uConsole CM5 HW Monitor${RESET}   ${now}   |   q/CTRL+C exit   |   log ${LOG_INTERVAL}s: ${LOG_FILE}   |   btop-like ${GREEN}──────${RESET}"

  mvcur 2 1; printf "%b" "${GREEN}"; box_hline "$pw"; printf "%b" "$RESET"; mvcur 2 $((pw+1)); printf "%b" "${YELLOW}"; box_hline "$pw"; printf "%b" "$RESET"; clreol
  row2 3 " ${GREEN}${BOLD}CPU / SYSTEM${RESET}" " ${YELLOW}${BOLD}BATTERY / POWER / FAN${RESET}" "$pw"
  row2 4 " CPU Usage   $(bar_high_bad "$cpu_pct")      ${GREEN}${cpu_pct}%${RESET}" " Battery   $(bar_high_good "$bcap")      ${GREEN}${bcap}%${RESET}" "$pw"
  row2 5 " Load Avg                      ${GREEN}${load}${RESET}" " Source     ${GREEN}${bsrc}${RESET}        State      ${GREEN}${bstat}${RESET}" "$pw"
  row2 6 " Freq                         ${GREEN}${freq} MHz${RESET}" " Voltage    ${GREEN}${bvolt} V${RESET}        Current    ${GREEN}${bcurr} A${RESET}" "$pw"
  row2 7 " Governor                     ${GREEN}${gov}${RESET}" " Power      ${GREEN}${bpwr} W${RESET}        Energy     ${GREEN}${beng}${RESET}" "$pw"
  row2 8 " RAM         $(bar_high_bad "${ram[0]}") ${GREEN}${ram[0]}%  ${ram[1]}/${ram[2]} GB${RESET}" " Health     $(status_color "$bhealth")" "$pw"
  row2 9 " Temp (CPU)  $(bar_high_bad "${temp%.*}") ${GREEN}${temp} °C${RESET} $(status_color "$(status_temp_short "$temp")")" " Status     $(status_color "$bsoc")     Name      ${GREEN}${bname}${RESET}" "$pw"
  row2 10 " NVMe Temp   $(bar_high_bad "${nvme%.*}") ${GREEN}${nvme} °C${RESET} $(status_color "$(status_temp_short "$nvme")")" " ---------------------------------------------------- " "$pw"
  row2 11 " Throttle    $(status_color "$throt")" " Fan        $(bar_high_bad "$fpct")       ${GREEN}${fpct}%${RESET}" "$pw"
  row2 12 " GPU Temp    $(bar_high_bad "${gpu_temp%.*}") ${GREEN}${gpu_temp} °C${RESET} (${GPU_TEMP_NOTE}) ${gpu}" " Device     ${GREEN}${fdev}${RESET}      PWM       ${GREEN}${fpwm}${RESET}" "$pw"
  row2 13 " Radio Temp  ${BLUE}${radio_temp}${RESET}" " State      ${GREEN}${fstat}${RESET}                 Duty      ${GREEN}${fduty}%${RESET}" "$pw"
  mvcur 14 1; printf "%b" "${GREEN}"; bot_hline "$pw"; printf "%b" "$RESET"; mvcur 14 $((pw+1)); printf "%b" "${YELLOW}"; bot_hline "$pw"; printf "%b" "$RESET"; clreol

  mvcur 15 1; printf "%b" "${BLUE}"; box_hline "$pw"; printf "%b" "$RESET"; mvcur 15 $((pw+1)); printf "%b" "${MAG}"; box_hline "$pw"; printf "%b" "$RESET"; clreol
  row2 16 " ${BLUE}${BOLD}SENSORS (HWMON)${RESET}" " ${MAG}${BOLD}NVMe SMART${RESET}" "$pw"

  local i=0 sline name tv stat qual
  while IFS= read -r sline; do
    ((i++)); ((i>7)) && break
    name=$(awk '{print $1}' <<<"$sline"); tv=$(awk '{print $2}' <<<"$sline"); stat=$(awk '{print $4}' <<<"$sline"); qual=$(awk '{print $5}' <<<"$sline")
    local leftline=" ${name}"
    leftline+="$(printf '%*s' $((18-${#name})) '')"
    leftline+="${BLUE}${tv} °C${RESET}            $(status_color "$stat")     ${qual}"
    case $i in
      1) row2 $((16+i)) "$leftline" " Device        ${sm_dev}    Health        $(status_color "$sm_health")" "$pw" ;;
      2) row2 $((16+i)) "$leftline" " Temp          ${sm_temp} °C         Critical      ${sm_crit}" "$pw" ;;
      3) row2 $((16+i)) "$leftline" " Endurance Used ${sm_used}%          Spare      ${sm_spare} $(status_color "$life_status")" "$pw" ;;
      4) row2 $((16+i)) "$leftline" " Read          ${sm_read}     Written      ${sm_wr}" "$pw" ;;
      5) row2 $((16+i)) "$leftline" " Hours         ${sm_hours} h       Unsafe Shutdowns ${sm_unsafe} $(status_color "$unsafe_status")" "$pw" ;;
      6) row2 $((16+i)) "$leftline" " Media Errors  ${sm_media}              Log Entries    ${sm_log}" "$pw" ;;
      7) row2 $((16+i)) "$leftline" " Warning Composite Temperature Time     N/A" "$pw" ;;
    esac
  done <<<"$sensors"
  while (( i < 7 )); do
    ((i++));
    case $i in
      1) row2 $((16+i)) " --                 --                 --      --" " Device        ${sm_dev}    Health        $(status_color "$sm_health")" "$pw" ;;
      2) row2 $((16+i)) " --                 --                 --      --" " Temp          ${sm_temp} °C         Critical      ${sm_crit}" "$pw" ;;
      3) row2 $((16+i)) " --                 --                 --      --" " Endurance Used ${sm_used}%          Spare      ${sm_spare} $(status_color "$life_status")" "$pw" ;;
      4) row2 $((16+i)) " --                 --                 --      --" " Read          ${sm_read}     Written      ${sm_wr}" "$pw" ;;
      5) row2 $((16+i)) " --                 --                 --      --" " Hours         ${sm_hours} h       Unsafe Shutdowns ${sm_unsafe} $(status_color "$unsafe_status")" "$pw" ;;
      6) row2 $((16+i)) " --                 --                 --      --" " Media Errors  ${sm_media}              Log Entries    ${sm_log}" "$pw" ;;
      7) row2 $((16+i)) " --                 --                 --      --" " SMART refresh every ${SMART_INTERVAL}s" "$pw" ;;
    esac
  done
  mvcur 24 1; printf "%b" "${BLUE}"; bot_hline "$pw"; printf "%b" "$RESET"; mvcur 24 $((pw+1)); printf "%b" "${MAG}"; bot_hline "$pw"; printf "%b" "$RESET"; clreol

  mvcur 25 1; printf "%b" "$WHITE"; box_hline "$w"; printf "%b" "$RESET"; clreol
  line_at 26 1 "│ ${WHITE}${BOLD} PCIe /DIAGNOSTICS ${RESET}$(printf '%*s' $((w-22)) '')│"
  local pl row=27 diag idx
  mapfile -t pciel < <(pcie_lines)
  for idx in 0 1 2 3; do
    pl="${pciel[$idx]:---}"
    case $idx in
      0) diag="Architecture        $(arch_short)      | Thermal Trend      ${trend}" ;;
      1) diag="Kernel        $(kernel_short)      | Throttling         ${throt}" ;;
      2) diag="Platform      $(platform_short) | AIO GPS ${agps} LORA ${allora}" ;;
      3) diag="Uptime ${uptime} | AIO SDR ${asdr} USB ${ausb} Power ${apower}" ;;
    esac
    line_at "$row" 1 "│ PCIE_${pl}  | ${diag}"
    mvcur "$row" "$w"; printf "│"; clreol
    ((row++))
  done
  mvcur 31 1; printf "%b" "$WHITE"; bot_hline "$w"; printf "%b" "$RESET"; clreol
  line_at 32 1 "${BLUE}Tip:${RESET} terminal font size is controlled by terminal/SSH client, not Bash.        ${BLUE}CSV log: ${LOG_FILE} every ${LOG_INTERVAL}s. SMART may need sudo NOPASSWD.${RESET}"
}

init_log_file
printf "%b" "${ESC}[2J${ESC}[H"
cpu_usage
sleep 0.2
while true; do
  draw
  if read -rsn1 -t "$INTERVAL" key; then
    case "$key" in q|Q) break ;; esac
  fi
done

It will create one new entry every 5 second (Or you can define the capture interval), recording all monitored values (58 values) into the memory_report.log file. The script is fully parameterized, so the logging interval, directories, and all other configuration settings can be easily adjusted to suit your needs.

I also included the GPU Temp and Radios Power. Since the temperature comes from a shared SoC sensor, it took a bit more work to measure it accurately.

Enjoy!

1 Like