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.
# Copyright (C) 2026 Wander Menezes
# Licensed under the GNU General Public License v3.0 or later.

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!

2 Likes

@Wander_Menezes nice one with the update.

Was unsure how the effect was going to be with running that script in the background while doing Kistmet but it did not feel any different.
Took an two hour walk doing kismet BLE/WIFI sniffing with around ~23°C while having the script
running the background.

CPU % avg CPU Load (15) CPU Temp GPU temp NVME Temp AIOV2 Power
2.46 2.14 40.55 40.74 54.82 4.12
Peak 39 3.16 44.6 44.6 54.9 6.3
Min 9 0.33 37.5 37 53.9 2.12

While the CM5 board seems kind of fine the NVME is a tad bit hot. It does atm not have any additional cooling but I have smaller heatsinks which might fit the NVME under the unmodified backplate.

1 Like

23°C where I live is neither a hot nor a cold day—it’s just a mild, average temperature. Think about Las Vegas in August at around 40°C, whether it’s daytime or nighttime. Here it cools down at night, and that’s the environment I’m dealing with.

That said, your numbers actually look pretty good for the climate you’re in.

I’d recommend keeping an eye on the RP1 chip. I took all my logged data, built a small RAG, and ran it through a model that generated a thermal overlay on top of a real uConsole image. The goal was to estimate the heat distribution inside the device based on my measurements.

It was pretty interesting because, in my case, the biggest “villain” turned out to be the RP1 chip. Since essentially all the I/O traffic goes through it (PCIe, USB, etc.), it actually runs hotter than both the CPU and the NVMe in my workload.

Of course, every setup is different.

The heat map isn’t perfect or scientifically precise, but it gave me a much better understanding of where I should focus additional cooling—and I’m already thinking about how to improve that.

You should also log the RP1 temperature. It would be interesting to see if yours is getting as hot as mine. And mt7921_phy1 too, Both of them will generate heat in your system, which adds to the total thermal load along with all the other components. Everything I’d read on the forum was focused on the CPU temperature, but in my use case there are components that actually run much hotter, and I think that’s the really interesting part. That heat spreads throughout the board and warms up all the components on the CM5. Measuring and seeing this was a really interesting finding for me.

There are 58 different values in that CSV, so there’s a lot of data that can still be explored and correlated. Again, the visualization isn’t perfect, but it gave me a great overview of the thermal behavior of my uConsole under my specific workload and environment.

Note: This isn’t an exact or scientifically accurate representation, but it answered an important question for my specific use case: where is the heat physically building up inside the device? And for me, that’s what really matters.

One thing I’m working on now is reducing the RP1’s workload through software configuration. I’m experimenting with things like PCIe ASPM, NVMe APST (Autonomous Power State Transition), USB autosuspend, USB Runtime PM, SATA/NVMe writeback, and a few other power management settings.

There are a lot of parameters that can be tuned to control the hardware behavior through software. Once I’m done testing, if I actually manage to reduce the RP1’s temperature, I’ll definitely share the results here.

By the way, today is actually a cold day here in Rio—it’s 19°C. For us, that’s considered cold. =)

1 Like

谢谢你的分享,我也一直在关注怎么高效率的降低uConsole温度到手持不烫的状态。但是我没有怎么去实施,只是采用了增加铜片和散热鳍片的方式,在冬天还是可接受的范围,一般38度,高负载不超过50度。
随着夏天的到来,我越来越感觉到uConsole变成了冬天的“暖手宝”,被动散热在通风良好的空调环境还能接受,如果气温超过30度,uConsole放包里不关机一段时间,整机温度可能上60度,非常烫手。
我曾尝试设计增加3004规格的风扇和散热鳍片,但是一直没有去实施。主要是我不会三维设计,不过后面我想还是得试试,因为CM5的积热问题只能通过风扇缓解。

@Lanthar

I’ve also had that feeling of holding a little oven in my hands, and it bothered me a lot. Where I live, the weather is already very hot, which made it even more uncomfortable. Well, that is the challenge: reducing the temperature. And thank you for sharing your data—this is very important for helping us truly understand how things work, thermally speaking.

In your measurement, the uConsole is working much harder: CPU usage is at 47%, it is running at 2.4 GHz, and the temperature reached 49.6°C. The RP1 is the hottest point, reaching 67.1°C—about 17.5°C hotter than the CPU—probably because of the activity going through the PCIe and USB interfaces. Since the fan was not detected and appears to be off, the system is basically relying on passive cooling. The 18650 ( or LiPo?) battery shows 24.79 Wh and 4.20 V, but the reading of only −0.04 W does not seem to represent the actual power consumption at that moment, as it is far too low for that workload. Even so, there is still no throttling, so the temperatures are high, but the system has not started limiting its performance yet. You did not tell me the ambient temperature, but I can make an inference: on hot days, you will have an oven in your hands, and your uConsole will probably continue getting even hotter.

If you cut a copper plate measuring 5.5 × 4 cm and 2,14mm thick, then combine it with a soft, conformable thermal pad (1 mm thick) to eliminate air bubbles and gaps, plus a 0.5 mm layer of thermal paste, these layers should help reduce the temperature without requiring you to cut or modify the uConsole’s case. Thickness matters, and so does the material: do not use aluminum; use copper with at least 99% purity. I strongly recommend buying a Peltier cooler, as it would help a lot in your case. If you choose a cooler with its own built-in battery —which is quite common in China—you should get even better results during the summer.

I ran a few tests and installed an original Raspberry Pi PWM fan near the batteries, The airflow is directed downward, pulling heat from the upper part of the device toward the cooler area at the base of the uConsole, while also providing airflow over the NVMe. The goal is to improve the internal thermal dynamics and keep the air circulating inside this enclosed system.. It is configured to turn on whenever the temperature exceeds 38°C, working as a backup cooling system in case the Peltier cooler is switched off. Since this fan only circulates the air inside the uConsole and I did not modify the case, it does help, but only to a certain extent. In my setup, the Peltier cooler has been the real hero, while the RP1 has been the biggest villain. I shared all the data here so anyone can analyze the thermal dynamics. I even created a comparison chart showing the results with and without the Peltier cooler. These measurements are also easy to reproduce using the monitoring tool I made available.

Note: The cutoff line marks the moment when I turned off the Peltier cooler, And I used the same workload that I normally run… the consequences are pretty obvious. There is plenty of data available for anyone who wants to measure and analyze their own system, so feel free to use it.

Note: I’m still working on the software settings—hardware and software need to work in harmony. First, however, I wanted to finalize the physical setup and establish a reliable baseline. After that, I can start working on the software hardening and optimization. I have not had much free time because of work, but as soon as I do, I will share the configurations used in my setup. Many people are reading this post, and I hope this information is helping them.

Actually, I have a solution. There is a magnetic suction cooling fan with its own battery, which can lower the temperature. This magnetic suction fan has an extremely long battery life. At the low speed setting, it can last for over 4 hours. I tested it on the CM5. Under high load and with an ambient temperature of around 30 degrees Celsius, the core temperature of the CM5 can be kept within 50 degrees, and when the fan is set to the high speed position, it can be controlled within 40 degrees.

1 Like

Wrapping up this journey, after testing and monitoring all the modifications and completing the physical, electrical, and thermal tuning/hardening, I ended up applying the following changes to my setup:

  1. Thermal layers were added to the components that required thermal control using thermal pads and copper plates:

Note: Always use copper with at least 98% purity. The main plate is 2.14 mm thick, while the auxiliary plates are 0.5 mm thick, so they do not put mechanical pressure on the back cover. Only the main plate uses thermal paste—Thermaltake TG-50, rated at 8 W/m·K.

  1. The system remains fully enclosed and retains its original external design, with no soldering, drilling, cutting, or other permanent modifications:

Note: I created a backup cooling system using an original Raspberry Pi fan. If the Peltier cooler fails, this fan circulates air inside the enclosed system.

  1. These were the parameters used to control the Raspberry Pi. At this point, CPU and NVMe temperatures were no longer a problem:

  1. All the changes I made to control temperature and power consumption, including the settings currently in my config.txt, are listed here:

    [pi5]
    
    #=====================================================
    
    #Thermal & Power Management Only
    #uConsole CM5 + Hacker Gadget AIO v2
    
    #=====================================================
    
    #---------- CPU ----------
    
    #Conservative profile
    
    arm_boost=0
    arm_freq=1200
    arm_freq_min=600
    
    #---------- GPU ----------
    gpu_freq=600
    gpu_freq_min=300
    
    #Slight undervolt
    over_voltage=-1
    
    #Thermal throttling starts earlier
    
    temp_soft_limit=65
    
    #---------- RP1 / Power ----------
    
    #Reduce idle power consumption
    
    power_force_3v3_pwm=1
    
    #PCIe enabled
    
    dtparam=pciex1
    
    #PCIe Gen3
    
    dtparam=pciex1_gen=3
    
    #---------- Battery ----------
    # ATTENTION - YOUR OWN BATERY PARAMETERS - NOT COPY AND PASTE
    dtoverlay=clockworkpi-custom-battery
    dtparam=charge_full_design_uah=6000000
    dtparam=energy_full_design_uwh=22200000
    
    #=====================================================
    #Backup Thermo Fan Control
    #=====================================================
    
    dtoverlay=gpio-fan,gpiopin=218,temp=55000,temp_hyst=5000
    
    #36°C
    dtparam=fan_temp0=36000
    dtparam=fan_temp0_hyst=2000
    dtparam=fan_temp0_speed=80
    
    #40°C
    dtparam=fan_temp1=40000
    dtparam=fan_temp1_hyst=3000
    dtparam=fan_temp1_speed=140
    
    #45°C
    dtparam=fan_temp2=45000
    dtparam=fan_temp2_hyst=4000
    dtparam=fan_temp2_speed=205
    
    #55°C
    dtparam=fan_temp3=55000
    dtparam=fan_temp3_hyst=5000
    dtparam=fan_temp3_speed=255
    
  2. These were the results I achieved. For historical reference and reproducibility, the entire process was documented in this post:

    Log (not a virus :slightly_smiling_face: ) : Download memory_report.log | LimeWire

Note: I added the final test logs to this post. To simulate the workload, I recommend using stress-ng. All tests were performed on battery power, with the Peltier cooler running continuously, under the following thermal conditions:

  1. I published the final version of the script—v3.1—with a few cosmetic improvements.

Note: Anyone is welcome to use the script. I will not provide support for this code, and you are free to modify it however you like. In next and last post.

I am now bringing this journey to an end. I achieved the results I needed some time ago, but I was also able to optimize, document, and measure the entire process so that anyone can reproduce it using materials that are easy to buy and implement in a simple way. The whole process is repeatable: you can measure each stage and share your own results.

Please remember that I applied these settings while accepting the associated risks. Although those risks are very low when everything is done correctly—please read the post—any modification must always be performed at your own risk.

The data speaks for itself. It was fun to identify problems, improve things, and achieve better results, but every journey has a beginning, a middle, and an end.

I did not create a “gold standard,” and that was never my intention. I am not the voice of reason or the owner of the absolute truth. I simply had a problem, and I am happy that I can now use my uConsole without it feeling like an oven. I can finally run my tests comfortably. However, the fact remains:

  • Every uConsole has a specific purpose.
  • Every unit has a specific hardware and software configuration.
  • Every uConsole will be used in a different environment.
  • Every uConsole will run different workloads.
  • People use them for different purposes, including engineering, gaming, work, coding, and much more.

Does it seem obvious? Yes, it does. But sometimes the obvious needs to be stated so we can stay focused on the target. Because of these differences, and because we’re talking about an open-hardware system, it is impossible to define a universal gold standard or rule. However, everyone benefits from a cooler uConsole.

I just want to make one thing very clear: the biggest source of heat is not the CPU—it is the RP1. Therefore, in my humble opinion, based on everything I tested, measured, observed and not based on opinion. …this is the real gold standard for thermal measurements on the CM5: a cool RP1 means a healthy uConsole. Those are my conclusions based on my measurements - in my use case and in my climate — and trying to solve the problem i was having.- And I’m very grateful to everyone who helped me solve this problem with a true spirit of collaboration and community—just like in the old days.

I hope all of this helps the community. I also hope that “heat” becomes a more structured topic of discussion on the forum, moving beyond debates about which cooling system is “the best” and focusing instead on effectively reducing heat.

This post contains several ideas for improving the product, creating accessories, and performing thermal tuning and hardening. I hope the ClockworkPi engineers consider these points, since this issue affects so many users.

Bring on the CM6! I will probably have fun solving my next problem—if it even becomes a problem. Either way, I am always happy to help, and I will still be around reading the forum.

Feel free to improve these ideas, modify the script, and share your own experiences. All the best to everyone!

1 Like
#!/usr/bin/env bash
# uConsole CM5 HW Monitor v3.1 - btop-like layout
# No external UI framework. Uses ANSI cursor positioning and fixed panels.
# Copyright (C) 2026 Wander Menezes
# Licensed under the GNU General Public License v3.0 or later.

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

Is this script available under license?

Nope. I made it so anyone can use it for free, with my permission. But since you asked. GNU/GPL … Just enjoy!

3 Likes

UPDATE: I don’t know what kind of black magic @Rex worked into this latest Trixie release—new drivers, hardening, power tunning, and so on— but, in fact… this update lowered the RP1 temperature by 6°C in my setup - Consequently, all the chips and the board run more cooler, since the RP1 generates less heat!! I don’t know whether the thermal and power controls parameters became more stable or were further refined, but the measurable fact is that the RP1 is now running more cooler. I strongly recommend updating the kernel, as this alone should already reduce the temperature.

Note:I’m going to run more tests: apply a heavy workload, watch it heat up and stabilize, and then see whether the RP1 cools down and dissipates the heat. But I’m excited!

Kernel Update - Help Tread

Tkz @Rex !

4 Likes

Excellent thermal recovery—this is clearly a fact. Congratulations to @Rex. Great achievement!

I love these straight lines— wifi completely stable and well-balanced.


As for the rp1,It ran below 50°C almost the entire time. I was transferring gigabytes of data—there’s no magic, it’s going to heat up. But overall, from a thermal perspective, the uConsole is running cool !

2 Likes

Well, I don’t have to please either the Greeks—the people who like visuals—or the Trojans—the people who like data. But I was asked to make a few changes to the script, so I did, and now I’m sharing it with the community.

  1. Visual improvements to make trend changes easier to notice, especially those involving the RP1.
  2. More data in the logs for creating graphs, saving system states, training models, and so on.

And yesssss! I like terminals and cyberpunk visuals! After all, the uConsole is a cyberdeck!

:grin:

Note: I discovered some pretty cool things about the RP1—how it handles the display, fan RPM, radio decoders, and more. Anyway, I put everything into the logs so you can explore the data and draw your own conclusions. Don’t forget to install NVMe SMART support.

It would be great to see screenshots from other uConsoles running this script. I think they could spark some really good discussions.

Enjoy!

Script Link (update):
Update 3.7 - If you use a Peltier cooler, press “p” to tell the script whether it is active in your system. If you don’t use one, disable this feature, pres “p” again..

ATTENTION: This LimeWire link will expire in one week. So, if you’ve downloaded it, please share it with anyone who can’t download it in case I’m not checking the forum. QAP

1 Like