#!/usr/bin/env bash
# Copyright 2026 Dun
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
set -euo pipefail
# disk_inventory.sh
#
# Prints one row per physical disk using /dev/disk/by-id and smartctl.
#
# Features:
# - skips partition aliases: *-part1, *-part2, *-part9, etc.
# - deduplicates multiple aliases for the same disk
# - prefers stable /dev/disk/by-id paths for disk discovery
# - prints kernel device, model, serial, size, SMART health,
# power-on hours, temperature and estimated age
# - prints remaining SSD life when available
# - prints reallocated and uncorrectable sectors for HDDs
#
# AGE format:
# 1y 2m 10d
#
# AGE is calculated from Power_On_Hours.
# Example:
# 1y 2m 10d = approximately 1 year, 2 months, 10 days powered on
#
# TEMP format:
# 32C
#
# LIFE format:
# 95%
#
# LIFE is only shown for SSDs when a supported SMART lifetime
# indicator is available.
#
# REALLOC:
# Reallocated_Sector_Ct for HDDs.
#
# UNCORR:
# Offline_Uncorrectable for HDDs.
#
# Requirements:
# sudo apt install smartmontools util-linux
#
# Usage:
# chmod +x disk_inventory.sh
# ./disk_inventory.sh
#
# Optional:
# SUDO="" ./disk_inventory.sh
# BY_ID_GLOB="/dev/disk/by-id/scsi-SATA_*" ./disk_inventory.sh
SUDO="${SUDO:-sudo}"
BY_ID_GLOB="${BY_ID_GLOB:-/dev/disk/by-id/scsi-*}"
command -v smartctl >/dev/null 2>&1 || {
echo "ERROR: smartctl not found. Install with: sudo apt install smartmontools" >&2
exit 1
}
command -v lsblk >/dev/null 2>&1 || {
echo "ERROR: lsblk not found." >&2
exit 1
}
hours_to_age() {
local hours="$1"
if [[ ! "$hours" =~ ^[0-9]+$ ]]; then
echo "-"
return
fi
local total_days=$((hours / 24))
local years=$((total_days / 365))
local remaining_days=$((total_days % 365))
local months=$((remaining_days / 30))
local days=$((remaining_days % 30))
printf "%dy %dm %dd" "$years" "$months" "$days"
}
get_temperature() {
local smart_data="$1"
local temp=""
# ATA/SATA drives:
#
# Example:
# 194 Temperature_Celsius ... 33
#
# Some drives instead expose attribute 190:
# 190 Airflow_Temperature_Cel ... 31
temp="$(
awk '
/Temperature_Celsius/ {
print $10
exit
}
/Airflow_Temperature_Cel/ {
print $10
exit
}
' <<< "$smart_data"
)"
# SAS/SCSI drives:
#
# Example:
# Current Drive Temperature: 34 C
if [[ -z "$temp" ]]; then
temp="$(
awk '
/Current Drive Temperature:/ {
for (i = 1; i <= NF; i++) {
if ($i ~ /^[0-9]+$/) {
print $i
exit
}
}
}
' <<< "$smart_data"
)"
fi
# NVMe and some newer smartctl output:
#
# Example:
# Temperature: 35 Celsius
if [[ -z "$temp" ]]; then
temp="$(
awk '
/^[[:space:]]*Temperature:/ {
for (i = 1; i <= NF; i++) {
if ($i ~ /^[0-9]+$/) {
print $i
exit
}
}
}
' <<< "$smart_data"
)"
fi
if [[ "$temp" =~ ^-?[0-9]+$ ]]; then
printf "%sC" "$temp"
else
printf "-"
fi
}
is_ssd() {
local device="$1"
local rotational=""
rotational="$(
lsblk -dn -o ROTA "$device" 2>/dev/null |
awk '{$1=$1; print}'
)"
[[ "$rotational" == "0" ]]
}
get_ssd_life() {
local smart_data="$1"
local life=""
local used=""
local value=""
# NVMe:
#
# Percentage Used: 12%
#
# Convert endurance used into estimated life remaining.
used="$(
awk '
/^[[:space:]]*Percentage Used:/ {
for (i = 1; i <= NF; i++) {
if ($i ~ /^[0-9]+%$/) {
gsub(/%/, "", $i)
print $i
exit
}
}
}
' <<< "$smart_data"
)"
if [[ "$used" =~ ^[0-9]+$ ]]; then
value=$((10#$used))
if (( value >= 100 )); then
printf "0%%"
else
printf "%d%%" "$((100 - value))"
fi
return
fi
# SATA SSDs that directly report percentage life remaining.
life="$(
awk '
/Percent_Lifetime_Remain/ ||
/Percent_Lifetime_Remaining/ ||
/SSD_Life_Left/ {
if ($4 ~ /^[0-9]+$/) {
print $4
exit
}
}
' <<< "$smart_data"
)"
if [[ "$life" =~ ^[0-9]+$ ]]; then
value=$((10#$life))
if (( value >= 0 && value <= 100 )); then
printf "%d%%" "$value"
return
fi
fi
# Samsung SATA SSDs commonly expose SMART attribute 177:
#
# Wear_Leveling_Count
#
# The normalized VALUE field normally starts at 100 and decreases
# as NAND endurance is consumed.
life="$(
awk '
$1 == "177" && $2 == "Wear_Leveling_Count" {
if ($4 ~ /^[0-9]+$/) {
print $4
exit
}
}
' <<< "$smart_data"
)"
if [[ "$life" =~ ^[0-9]+$ ]]; then
value=$((10#$life))
if (( value >= 0 && value <= 100 )); then
printf "%d%%" "$value"
return
fi
fi
printf "-"
}
get_reallocated_sectors() {
local smart_data="$1"
local value=""
value="$(
awk '
$2 == "Reallocated_Sector_Ct" {
print $10
exit
}
' <<< "$smart_data"
)"
if [[ "$value" =~ ^[0-9]+$ ]]; then
printf "%s" "$value"
else
printf "-"
fi
}
get_uncorrectable_sectors() {
local smart_data="$1"
local value=""
value="$(
awk '
$2 == "Offline_Uncorrectable" {
print $10
exit
}
' <<< "$smart_data"
)"
if [[ "$value" =~ ^[0-9]+$ ]]; then
printf "%s" "$value"
else
printf "-"
fi
}
printf "%-8s %-26s %-20s %-8s %-10s %-10s %-8s %-12s %-7s %-8s %-8s\n" \
"KERNEL" "MODEL" "SERIAL" "SIZE" "HEALTH" "POH" "TEMP" "AGE" "LIFE" "REALLOC" "UNCORR"
printf "%-8s %-26s %-20s %-8s %-10s %-10s %-8s %-12s %-7s %-8s %-8s\n" \
"------" "-----" "------" "----" "------" "---" "----" "---" "----" "-------" "------"
declare -A seen
shopt -s nullglob
for byid in $BY_ID_GLOB; do
[[ -e "$byid" ]] || continue
[[ "$byid" =~ -part[0-9]+$ ]] && continue
realdev="$(readlink -f "$byid" 2>/dev/null || true)"
[[ -n "$realdev" ]] || continue
[[ -b "$realdev" ]] || continue
kernel="$(basename "$realdev")"
info="$($SUDO smartctl -i "$byid" 2>/dev/null || true)"
all="$($SUDO smartctl -a "$byid" 2>/dev/null || true)"
extra="$($SUDO smartctl -x "$byid" 2>/dev/null || true)"
model="$(
awk -F: '
/Device Model:/ {
gsub(/^[ \t]+/, "", $2)
print $2
exit
}
/Product:/ {
gsub(/^[ \t]+/, "", $2)
print $2
exit
}
/Model Number:/ {
gsub(/^[ \t]+/, "", $2)
print $2
exit
}
' <<< "$info"
)"
serial="$(
awk -F: '
/Serial Number:/ {
gsub(/^[ \t]+/, "", $2)
print $2
exit
}
' <<< "$info"
)"
[[ -n "$model" ]] || model="-"
[[ -n "$serial" ]] || serial="-"
if [[ "$serial" != "-" ]]; then
key="serial:$serial"
else
key="kernel:$kernel"
fi
[[ -n "${seen[$key]:-}" ]] && continue
seen[$key]=1
size="$(
lsblk -dn -o SIZE "$realdev" 2>/dev/null |
awk '{$1=$1; print}' || true
)"
[[ -n "$size" ]] || size="-"
health="$(
awk -F: '
/SMART overall-health self-assessment test result:/ {
gsub(/^[ \t]+/, "", $2)
print $2
exit
}
/SMART Health Status:/ {
gsub(/^[ \t]+/, "", $2)
print $2
exit
}
' <<< "$all"
)"
[[ -n "$health" ]] || health="-"
poh="$(
awk '
/Power_On_Hours/ {
print $10
exit
}
/Power-on Hours/ {
print $NF
exit
}
/Accumulated power on time/ {
print $0
exit
}
' <<< "$all"
)"
[[ -n "$poh" ]] || poh="-"
temp="$(get_temperature "$all")"
if [[ "$temp" == "-" ]]; then
temp="$(get_temperature "$extra")"
fi
age="$(hours_to_age "$poh")"
if is_ssd "$realdev"; then
life="$(get_ssd_life "$all")"
if [[ "$life" == "-" ]]; then
life="$(get_ssd_life "$extra")"
fi
realloc="-"
uncorr="-"
else
life="-"
realloc="$(get_reallocated_sectors "$all")"
uncorr="$(get_uncorrectable_sectors "$all")"
fi
printf "%-8s %-26.26s %-20.20s %-8s %-10.10s %-10.10s %-8.8s %-12.12s %-7.7s %-8.8s %-8.8s\n" \
"$kernel" \
"$model" \
"$serial" \
"$size" \
"$health" \
"$poh" \
"$temp" \
"$age" \
"$life" \
"$realloc" \
"$uncorr"
done