#!/usr/bin/env perl # Copyright (c) 2025, R. W. Rodolico # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, this # list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # Use smartctl, lsblk and geom to get disk information # Author: R. W. Rodolico # Date: 2018-04-02 # # will decode lsblk first. Then use geom to get information on BSD systems # finally, smartctl will be used to gather additional information. # # grabs smart readings from all drives in system # note that in the case of some hardware RAID controller, will list the components of a "drive" and the drive itself, # so you may have up to twice the number of entries than you have physical drives, for example, if you have a megaraid # controller where each physical drive is also a single logical drive. # # Monthly (Spec 2.5): emits the old attributes keys exactly as v3.7.1 # all_smart, plus fixes F1 (writes = bytes written) and F2 (Sector Size # from the logical block size), and storage.disks..smart (Spec 1.4, # rules W and E) from smartctl -j -x. Without smartctl JSON support # (smartmontools < 7.0) only the old keys, by the v3.7.1 text logic. # # Revision History # # 20200224 RWR v2.1 # large re-organization of code and inclusion to get basic information from lsblk if it is on the system # # 2026-10-12 Claude v3.0.0 (F2b, not yet committed) # Rewritten for v3.8.0: template form, parsing in testable functions, list-form commands, # smartctl -j -x per device, storage.disks..smart with rules W and E, gated monthly # with "not due" on STDERR, old attributes keys kept (v3.7.1 all_smart logic, F1, F2) # 2026-10-18 Claude renamed from unix_smart (bug 210) package all_smart; use strict; use warnings; use version; our $VERSION = version->declare('v3.0.0'); use FindBin; use File::Spec; use lib File::Spec->catdir($FindBin::Bin), File::Spec->catdir("$FindBin::Bin/.."); use library qw(:all); # category we will use for the old values; the new ones go in storage my $CATEGORY = 'attributes'; # a list of drive "types" used by virtuals that we just ignore. my %ignoreDriveTypes = ( 'VBOX HARDDISK' => 1, ); # ATA attributes whose normalised value counts down from 100 as the # rated life is used (rule E) my %wearAttributes = map { $_ => 1 } qw(Media_Wearout_Indicator Wear_Leveling_Count SSD_Life_Left Percent_Lifetime_Remain); ####################################################### # # parseScanOld( $text ) # # Parses smartctl --scan as v3.7.1 all_smart did: device path, then # everything up to the '#' as the type (trailing space kept). A device # listed twice keeps its last type. # # Parameters: # $text - smartctl --scan output # # Returns: # hash reference device path => raw type string # # Side Effects: # none # ####################################################### sub parseScanOld { my $text = shift; my %drives; foreach my $line (split(/\n/, defined($text) ? $text : '')) { $drives{$1} = $2 if $line =~ m/(^[a-z0-9\/]+)\s+(.*)\#/; } return \%drives; } ####################################################### # # parseLsblkOld( $json, $defs ) # # acquires information using lsblk (v3.7.1 lsblk): Capacity, Model and # Serial for every top-level device of type disk. # # Parameters: # $json - lsblk -J -b -O text # $defs - hash reference of drive definitions (mutated) # # Returns: # $defs # # Side Effects: # mutates $defs # ####################################################### sub parseLsblkOld { my ($json, $defs) = @_; my $parsed = decodeJson($json); return $defs unless ref($parsed) eq 'HASH' && ref($parsed->{'blockdevices'}) eq 'ARRAY'; foreach my $thisDrive (@{$parsed->{'blockdevices'}}) { next unless ref($thisDrive) eq 'HASH' && defined($thisDrive->{'type'}) && $thisDrive->{'type'} eq 'disk'; my $key = '/dev/' . $thisDrive->{'name'}; $defs->{$key}{'Capacity'} = $thisDrive->{'size'}; $defs->{$key}{'Model'} = $thisDrive->{'model'} if defined $thisDrive->{'model'}; $defs->{$key}{'Serial'} = $thisDrive->{'serial'} if defined $thisDrive->{'serial'}; } return $defs; } ####################################################### # # parseGeomOld( $text, $defs ) # # grab data from geom disk list (BSD) and import parts of it into the # drive definitions, as v3.7.1 geom/doGeomBlock: each "key: value" line # is split on ':' (the value ends at a second colon), "1. Name" becomes # "Name", a block ends at its first blank line. # # Parameters: # $text - geom disk list output # $defs - hash reference of drive definitions (mutated) # # Returns: # $defs # # Side Effects: # mutates $defs # ####################################################### sub parseGeomOld { my ($text, $defs) = @_; my @blocks; my $ended = 1; foreach my $line (split(/\n/, defined($text) ? $text : '')) { if ($line =~ m/^Geom name:/) { push @blocks, {}; $ended = 0; } next if $ended || !@blocks; if ($line eq '') { $ended = 1; next; } my ($key, $value) = split(':', $line); $key = $1 if $key =~ m/^\d+\.\s+(.*)$/; $blocks[-1]{trim($key)} = trim(defined($value) ? $value : ''); } foreach my $thisDrive (@blocks) { my $key = '/dev/' . ($thisDrive->{'Name'} ? $thisDrive->{'Name'} : 'Unknown'); if (defined($thisDrive->{'Mediasize'})) { $defs->{$key}{'Capacity'} = $thisDrive->{'Mediasize'}; $defs->{$key}{'Capacity'} = $1 if $defs->{$key}{'Capacity'} =~ m/^(\d+)/; } $defs->{$key}{'Model'} = $thisDrive->{'descr'} if defined $thisDrive->{'descr'}; $defs->{$key}{'Serial'} = $thisDrive->{'ident'} if defined $thisDrive->{'ident'}; $defs->{$key}{'Sector Size'} = $thisDrive->{'Sectorsize'} if defined $thisDrive->{'Sectorsize'}; $defs->{$key}{'Rotation'} = $thisDrive->{'rotationrate'} if defined $thisDrive->{'rotationrate'}; } return $defs; } ####################################################### # # oldFromJson( $json, $def ) # # Fills the old attribute keys of one drive from smartctl -j -x, using # only the fields the v3.7.1 smartctl -i/-A text parse would have found: # Make (Model Family), Model (ATA Device Model), Serial, Capacity (User # Capacity), Sector Size (F2: the logical block size), Rotation (Rotation # Rate text), uptime (leading digits of attribute 9 raw). # # Parameters: # $json - decoded smartctl JSON # $def - hash reference of this drive's definitions (mutated) # # Returns: # $def # # Side Effects: # mutates $def # ####################################################### sub oldFromJson { my ($json, $def) = @_; my $protocol = ref($json->{'device'}) eq 'HASH' && defined($json->{'device'}{'protocol'}) ? $json->{'device'}{'protocol'} : ''; my $ataOrScsi = $protocol =~ /^(ATA|SCSI)$/; $def->{'Make'} = $json->{'model_family'} if defined($json->{'model_family'}); $def->{'Model'} = $json->{'model_name'} if defined($json->{'model_name'}) && $protocol eq 'ATA'; $def->{'Serial'} = $json->{'serial_number'} if defined($json->{'serial_number'}); if ($ataOrScsi && ref($json->{'user_capacity'}) eq 'HASH' && defined($json->{'user_capacity'}{'bytes'})) { $def->{'Capacity'} = $json->{'user_capacity'}{'bytes'}; } $def->{'Sector Size'} = $json->{'logical_block_size'} if $ataOrScsi && defined($json->{'logical_block_size'}); if ($ataOrScsi && defined($json->{'rotation_rate'}) && $json->{'rotation_rate'} =~ /^\d+$/) { $def->{'Rotation'} = $json->{'rotation_rate'} == 0 ? 'Solid State Device' : "$json->{'rotation_rate'} rpm"; } foreach my $attr (@{ataAttributes($json)}) { next unless $attr->{'id'} == 9 && ref($attr->{'raw'}) eq 'HASH' && defined($attr->{'raw'}{'string'}); $def->{'uptime'} = $1 if $attr->{'raw'}{'string'} =~ m/^(\d+)/; } return $def; } ####################################################### # # parseInfoText( $text, $def ) # # smartmontools < 7.0: fills the old keys from smartctl -i text, as # v3.7.1 getSmartInformationReport/getInformation ("key: value", the # key ending at the last colon; F2 adds the logical size of "Sector Sizes"). # # Parameters: # $text - smartctl -i output # $def - hash reference of this drive's definitions (mutated) # # Returns: # $def # # Side Effects: # mutates $def # ####################################################### sub parseInfoText { my ($text, $def) = @_; my %report; foreach my $line (split(/\n/, defined($text) ? $text : '')) { $report{$1} = trim($2) if $line =~ m/^(.*):(.*)$/; } my %keys = ( 'Model Family' => ['Make', '(.*)'], 'Device Model' => ['Model', '(.*)'], 'Serial number' => ['Serial', '(.*)'], 'Serial Number' => ['Serial', '(.*)'], 'User Capacity' => ['Capacity', '([0-9,]+)'], 'Logical block size' => ['Sector Size', '(\d+)'], 'Sector Size' => ['Sector Size', '(\d+)'], 'Sector Sizes' => ['Sector Size', '(\d+) bytes logical'], 'Rotation Rate' => ['Rotation', '(.*)'], ); foreach my $key (sort keys %keys) { my ($tag, $regex) = @{$keys{$key}}; $def->{$tag} = $1 if defined($report{$key}) && $report{$key} =~ m/$regex/; } return $def; } ####################################################### # # parseAttributeText( $text, $def ) # # smartmontools < 7.0: uptime from smartctl -A text, as v3.7.1 # getSmartAttributeReport (columns located from the ID# header line). # # Parameters: # $text - smartctl -A output # $def - hash reference of this drive's definitions (mutated) # # Returns: # $def # # Side Effects: # mutates $def # ####################################################### sub parseAttributeText { my ($text, $def) = @_; my @report = split(/\n/, defined($text) ? $text : ''); my $i; # bypass all the header information for ($i = 0; $i < @report && $report[$i] !~ m/^ID#/; $i++) {} return $def unless $i < @report; # some drives will not give us a report my %headers; my $char = 0; while ($char < length($report[$i])) { substr($report[$i], $char) =~ m/^([^ ]+\s*)/; my $header = $1; my $start = $char; my $length = length($header); if ($header = trim($header)) { $headers{$header} = {'start' => $start, 'length' => $length - 1}; } $char += $length; } return $def unless $headers{'ID#'} && $headers{'RAW_VALUE'}; while (++$i < @report) { last unless $report[$i]; my $id = trim(substr($report[$i], $headers{'ID#'}{'start'}, $headers{'ID#'}{'length'})); next unless $id eq '9'; my $value = trim(substr($report[$i], $headers{'RAW_VALUE'}{'start'})); $def->{'uptime'} = $1 if $value =~ m/^(\d+)/; } return $def; } ####################################################### # # ataAttributes( $json ) # # Returns the ATA SMART attribute table of decoded smartctl JSON. # # Parameters: # $json - decoded smartctl JSON # # Returns: # array reference of attribute hashes (empty when none) # # Side Effects: # none # ####################################################### sub ataAttributes { my $json = shift; return [] unless ref($json) eq 'HASH' && ref($json->{'ata_smart_attributes'}) eq 'HASH' && ref($json->{'ata_smart_attributes'}{'table'}) eq 'ARRAY'; return [grep { ref($_) eq 'HASH' && defined($_->{'id'}) } @{$json->{'ata_smart_attributes'}{'table'}}]; } ####################################################### # # deviceStatistic( $json, $name ) # # Returns one ATA device statistics value by name. # # Parameters: # $json - decoded smartctl JSON # $name - statistic name ("Logical Sectors Written") # # Returns: # value or undef # # Side Effects: # none # ####################################################### sub deviceStatistic { my ($json, $name) = @_; return undef unless ref($json->{'ata_device_statistics'}) eq 'HASH' && ref($json->{'ata_device_statistics'}{'pages'}) eq 'ARRAY'; foreach my $page (@{$json->{'ata_device_statistics'}{'pages'}}) { next unless ref($page) eq 'HASH' && ref($page->{'table'}) eq 'ARRAY'; foreach my $stat (@{$page->{'table'}}) { return $stat->{'value'} if ref($stat) eq 'HASH' && defined($stat->{'name'}) && $stat->{'name'} eq $name && defined($stat->{'value'}); } } return undef; } ####################################################### # # bytesWritten( $json ) # # Rule W (Spec 1.4): the first matching counter of host writes and its # size in bytes. Attribute names are matched only when they count host # writes (a *_GiB or *_32MiB attribute counting reads or NAND writes is # not used). # # Parameters: # $json - decoded smartctl JSON # # Returns: # list (bytes, source, raw counter, unit word), or empty list # # Side Effects: # none # ####################################################### sub bytesWritten { my $json = shift; my $lbs = $json->{'logical_block_size'}; my $haveLbs = defined($lbs) && $lbs =~ /^\d+$/ && $lbs > 0; my $sectors = deviceStatistic($json, 'Logical Sectors Written'); return ($sectors * $lbs, 'devstat', $sectors, 'logical_sectors') if $haveLbs && defined($sectors) && $sectors =~ /^\d+$/; my @attrs = @{ataAttributes($json)}; my $raw = sub { my $a = shift; return ref($a->{'raw'}) eq 'HASH' && defined($a->{'raw'}{'value'}) && $a->{'raw'}{'value'} =~ /^\d+$/ ? $a->{'raw'}{'value'} : undef; }; my $hostWrites = sub { my $n = shift; return $n =~ /Writ/i && $n !~ /NAND|Flash|Read/i; }; foreach my $a (@attrs) { next unless ($a->{'id'} == 241 || $a->{'id'} == 246) && defined($a->{'name'}) && $a->{'name'} eq 'Total_LBAs_Written'; my $v = $raw->($a); return ($v * $lbs, 'attr241_lba', $v, 'lba') if defined($v) && $haveLbs; } foreach my $a (@attrs) { next unless defined($a->{'name'}) && $a->{'name'} =~ /_GiB$/ && $hostWrites->($a->{'name'}); my $v = $raw->($a); return ($v * 2**30, 'attr_gib', $v, 'GiB') if defined($v); } foreach my $a (@attrs) { next unless defined($a->{'name'}) && $a->{'name'} =~ /_32MiB$/ && $hostWrites->($a->{'name'}); my $v = $raw->($a); return ($v * 2**25, 'attr_32mib', $v, '32MiB') if defined($v); } my $nvme = $json->{'nvme_smart_health_information_log'}; if (ref($nvme) eq 'HASH' && defined($nvme->{'data_units_written'}) && $nvme->{'data_units_written'} =~ /^\d+$/) { return ($nvme->{'data_units_written'} * 512000, 'nvme_data_units', $nvme->{'data_units_written'}, 'data_units'); } my $log = $json->{'scsi_error_counter_log'}; if (ref($log) eq 'HASH' && ref($log->{'write'}) eq 'HASH' && defined($log->{'write'}{'gigabytes_processed'}) && $log->{'write'}{'gigabytes_processed'} =~ /^\d+(\.\d+)?$/) { my $gb = $log->{'write'}{'gigabytes_processed'}; return (sprintf('%.0f', $gb * 1e9), 'scsi_gb_processed', $gb, 'GB'); } return (); } ####################################################### # # wearPercentUsed( $json ) # # Rule E (Spec 1.4): percentage of rated life used (0 new, 100 worn), # first match. Absent for rotating disks. # # Parameters: # $json - decoded smartctl JSON # # Returns: # list (percent, source), or empty list # # Side Effects: # none # ####################################################### sub wearPercentUsed { my $json = shift; return () if defined($json->{'rotation_rate'}) && $json->{'rotation_rate'} =~ /^\d+$/ && $json->{'rotation_rate'} > 0; my $nvme = $json->{'nvme_smart_health_information_log'}; return ($nvme->{'percentage_used'}, 'nvme') if ref($nvme) eq 'HASH' && defined($nvme->{'percentage_used'}); my $stat = deviceStatistic($json, 'Percentage Used Endurance Indicator'); return ($stat, 'devstat') if defined($stat); return ($json->{'scsi_percentage_used_endurance_indicator'}, 'scsi') if defined($json->{'scsi_percentage_used_endurance_indicator'}); foreach my $a (@{ataAttributes($json)}) { next unless defined($a->{'name'}) && $wearAttributes{$a->{'name'}} && defined($a->{'value'}) && $a->{'value'} =~ /^\d+$/; return (100 - $a->{'value'}, "attr_$a->{'name'}"); } return (); } ####################################################### # # smartJsonLines( $dev, $json ) # # storage.disks..smart lines from decoded smartctl -j -x. # # Parameters: # $dev - storage.disks key # $json - decoded smartctl JSON # # Returns: # array reference of lines # # Side Effects: # none # ####################################################### sub smartJsonLines { my ($dev, $json) = @_; my %smart = ('source' => 'json'); $smart{'model_family'} = $json->{'model_family'} if defined($json->{'model_family'}); $smart{'firmware'} = $json->{'firmware_version'} if defined($json->{'firmware_version'}); $smart{'serial'} = $json->{'serial_number'} if defined($json->{'serial_number'}); my $wwn = $json->{'wwn'}; if (ref($wwn) eq 'HASH' && defined($wwn->{'naa'}) && defined($wwn->{'oui'}) && defined($wwn->{'id'})) { $smart{'wwn'} = sprintf('%x%06x%09x', $wwn->{'naa'}, $wwn->{'oui'}, $wwn->{'id'}); } $smart{'power_on_hours'} = $json->{'power_on_time'}{'hours'} if ref($json->{'power_on_time'}) eq 'HASH' && defined($json->{'power_on_time'}{'hours'}); $smart{'temperature_c'} = $json->{'temperature'}{'current'} if ref($json->{'temperature'}) eq 'HASH' && defined($json->{'temperature'}{'current'}); my ($bytes, $source, $raw, $unit) = bytesWritten($json); if (defined($bytes)) { @smart{'bytes_written', 'bytes_written_source', 'writes_raw', 'writes_raw_unit'} = ($bytes, $source, $raw, $unit); } my ($wear, $wearSource) = wearPercentUsed($json); @smart{'wear_percent_used', 'wear_source'} = ($wear, $wearSource) if defined($wear); return [map { "storage\tdisks\t$dev\tsmart\t$_\t$smart{$_}" } sort keys %smart]; } ####################################################### # # oldAttributeLines( $defs ) # # The old attributes lines: ' ' for every drive, sorted, # skipping ignored (virtual) drive types; commas removed from Capacity. # # Parameters: # $defs - hash reference of drive definitions # # Returns: # array reference of lines # # Side Effects: # none # ####################################################### sub oldAttributeLines { my $defs = shift; my @out; foreach my $drive (sort keys %{$defs}) { # blow off our ignored types next if defined($defs->{$drive}{'Model'}) && $ignoreDriveTypes{$defs->{$drive}{'Model'}}; $defs->{$drive}{'Capacity'} =~ s/,//g if $defs->{$drive}{'Capacity'}; foreach my $key (sort keys %{$defs->{$drive}}) { push @out, "$CATEGORY\t$drive $key\t$defs->{$drive}{$key}" if defined $defs->{$drive}{$key}; } } return \@out; } ####################################################### # # processDrive( $device, $type, $json, $infoText, $attrText, $defs, $disks ) # # Applies one scanned drive's smartctl output: the old keys (JSON, or the # text path when $json is undef) and, with JSON, the smart lines. # # Parameters: # $device - device path from smartctl --scan # $type - raw scan type ('-d scsi ') # $json - decoded smartctl -j -x, or undef # $infoText - smartctl -i text (text path only) # $attrText - smartctl -A text (text path only) # $defs - hash reference of drive definitions (mutated) # $disks - hash reference from discoverDisks # # Returns: # array reference of storage lines # # Side Effects: # mutates $defs # ####################################################### sub processDrive { my ($device, $type, $json, $infoText, $attrText, $defs, $disks) = @_; $defs->{$device}{'type'} = $type; my ($scan) = values %{smartScan("$device $type")}; if (ref($json) eq 'HASH') { oldFromJson($json, $defs->{$device}); return [] unless $scan; my $dev = smartDiskKey($scan, $disks, $json->{'serial_number'}); # F1: the old writes key is the new bytes written my ($bytes) = bytesWritten($json); $defs->{$device}{'writes'} = $bytes if defined($bytes); return smartJsonLines($dev, $json); } parseInfoText($infoText, $defs->{$device}); parseAttributeText($attrText, $defs->{$device}); return [] unless $scan; return ["storage\tdisks\t" . smartDiskKey($scan, $disks, $defs->{$device}{'Serial'}) . "\tsmart\tsource\tnone"]; } ####################################################### # # main() # # Monthly: runs lsblk or geom, then smartctl on every scanned device. # # Parameters: # none # # Returns: # integer exit status # # Side Effects: # runs commands and writes STDOUT/STDERR # ####################################################### sub main { if (!checkOS({ 'linux' => undef, 'freebsd' => undef })) { print STDERR "unsupported os\n"; return 1; } unless (validCommandOnSystem({'smartctl' => undef})) { print STDERR "smartctl not found\n"; return 2; } unless (checkDate('m')) { print STDERR "not due\n"; return 3; } # disk definitions for the old keys; a missing lsblk or geom leaves them empty my %defs; my $inv = localDiskInventory(); my $disks = $inv->{'disks'}; if ($inv->{'os'} eq 'linux') { parseLsblkOld($inv->{'raw'}{'lsblkJson'}, \%defs); } else { parseGeomOld($inv->{'raw'}{'geomDisks'}, \%defs); } my @out; my $drives = parseScanOld(runCommand('smartctl', '--scan')); foreach my $device (sort keys %{$drives}) { my $type = $drives->{$device}; my @args = smartDeviceArgs($type); if ($device =~ m{^/dev/(ses|cd|sr|pass)\d}) { # enclosure and optical devices: v3.7.1 reported only their type $defs{$device}{'type'} = $type; next; } my $json = decodeJson(runCommand('smartctl', '-j', '-x', $device, @args)); if (ref($json) eq 'HASH' && defined($json->{'json_format_version'})) { push @out, @{processDrive($device, $type, $json, undef, undef, \%defs, $disks)}; } else { push @out, @{processDrive($device, $type, undef, runCommand('smartctl', '-i', $device, @args), runCommand('smartctl', '-A', $device, @args), \%defs, $disks)}; } } unshift @out, @{oldAttributeLines(\%defs)}; print join("\n", @out), "\n" if @out; return @out ? 0 : 1; } exit main() unless caller; 1;