Private/Kinds/DiskHealth.ps1

# The DiskHealth Kind: what the drive itself says about how much life it has left.
#
# The Storage Kind reports that a disk is Healthy, which is Windows repeating what the
# driver told it. This reads the reliability counters underneath: wear, uncorrected read
# errors, temperature and hours powered on. A solid-state drive at 85 % wear reports
# Healthy right up until it does not, and this is the Check that gives a Technician the
# weeks of notice that makes the difference between a replacement and a data recovery.

# Declared for the Kind vocabulary: the reliability counters are not readable without
# admin rights, so this Check belongs to the Elevated Part. See Private/Kind.ps1.
$script:DiskHealthNeedsAdmin = $true

function Get-DiskHealthData {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

    $disks = @()
    try {
        $disks = @(Get-PhysicalDisk -ErrorAction Stop | ForEach-Object {
            $device = $_
            $counter = $null
            $readable = $true
            try { $counter = $device | Get-StorageReliabilityCounter -ErrorAction Stop }
            catch {
                # Plenty of drives and controllers simply do not expose these counters, and
                # a Run without admin rights cannot read them at all. Which of the two this
                # was is the Judge's call, from the Privilege the Part carries.
                $readable = $false
            }

            [pscustomobject]@{
                DeviceId              = $device.DeviceId
                FriendlyName          = "$($device.FriendlyName)"
                MediaType             = "$($device.MediaType)"
                CountersReadable      = $readable
                Wear                  = $counter.Wear
                TemperatureC          = $counter.Temperature
                ReadErrorsUncorrected = $counter.ReadErrorsUncorrected
                PowerOnHours          = $counter.PowerOnHours
            }
        })
    }
    catch { }

    # Which drives were attached, and when: Windows writes one event per drive it sees,
    # with model and serial number, into a log only an administrator can read. The
    # Stability Check uses it to say which drive an error was about - one that has been
    # unplugged since is otherwise only "Datentraeger 1".
    $history = @(Get-WinEvent -FilterHashtable @{ LogName = 'Microsoft-Windows-Partition/Diagnostic'; Id = 1006 } `
            -MaxEvents 500 -ErrorAction SilentlyContinue | ForEach-Object {
        $fields = @{}
        try { foreach ($node in ([xml]$_.ToXml()).Event.EventData.Data) { $fields["$($node.Name)"] = "$($node.'#text')" } } catch { }
        [pscustomobject]@{
            Time         = $_.TimeCreated
            DiskNumber   = $fields['DiskNumber']
            Manufacturer = "$($fields['Manufacturer'])".Trim()
            Model        = "$($fields['Model'])".Trim()
            SerialNumber = "$($fields['SerialNumber'])".Trim()
            SizeBytes    = $fields['Capacity']
        }
    })

    [pscustomobject]@{
        PSTypeName    = 'Gutcheck.Data.DiskHealth'
        PhysicalDisks = $disks
        DiskHistory   = $history
        # Which rights the readings were taken with: the difference between "this drive
        # does not report wear" and "nobody was allowed to ask".
        Elevated      = (Get-CurrentPrivilege) -eq 'admin'
    }
}

function Get-DiskHealthValue {
    <#
    .SYNOPSIS
        What a drive reported about itself, and what it did not. Pure.
    .DESCRIPTION
        A drive reports some of its counters and not others: an NVMe drive behind one
        controller gives wear and temperature and no power-on hours. The Value used to be
        one sentence with a place for each, so a counter that was missing left a hole -
        "Nicht korrigierte Lesefehler | Betriebsstunden h" - which reads as a fault in
        the Report rather than as a drive that does not say. Each counter is now stated
        when it is there, and the ones that are not are named as not reported.
 
        A temperature of zero is a counter that is not there, not a drive at freezing.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Disk)

    $wear   = ConvertTo-Number (Get-DataProperty $Disk 'Wear')
    $temp   = ConvertTo-Number (Get-DataProperty $Disk 'TemperatureC')
    $errors = ConvertTo-Number (Get-DataProperty $Disk 'ReadErrorsUncorrected')
    $hours  = ConvertTo-Number (Get-DataProperty $Disk 'PowerOnHours')
    if ($null -ne $temp -and $temp -le 0) { $temp = $null }

    $said    = @()
    $missing = @()
    if ($null -ne $wear)   { $said += Get-Text 'Value.DiskHealth.Wear' $wear }           else { $missing += Get-Text 'Value.DiskHealth.Name.Wear' }
    if ($null -ne $temp)   { $said += Get-Text 'Value.DiskHealth.Temperature' $temp }    else { $missing += Get-Text 'Value.DiskHealth.Name.Temperature' }
    if ($null -ne $errors) { $said += Get-Text 'Value.DiskHealth.ReadErrors' $errors }   else { $missing += Get-Text 'Value.DiskHealth.Name.ReadErrors' }
    if ($null -ne $hours)  { $said += Get-Text 'Value.DiskHealth.PowerOnHours' $hours }  else { $missing += Get-Text 'Value.DiskHealth.Name.PowerOnHours' }

    if ($missing.Count) { $said += Get-Text 'Value.DiskHealth.NotReported' ($missing -join ', ') }
    $said -join ' | '
}

function ConvertTo-DiskHealthFinding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{}
    )

    $wearWarn = Get-Parameter $Parameters 'DiskWearWarnAbovePercent' 49
    $wearFail = Get-Parameter $Parameters 'DiskWearFailAbovePercent' 79
    $tempWarn = Get-Parameter $Parameters 'DiskTemperatureWarnAboveC' 60
    $tempFail = Get-Parameter $Parameters 'DiskTemperatureFailAboveC' 70

    $disks = Get-DataCollection $Data 'PhysicalDisks'
    if (-not $disks.Count) {
        return New-UnavailableFinding -Category Storage -Check (Get-Text 'Check.DiskHealth.DiskHealth') `
            -Hint (Get-Text 'Hint.DiskHealth.NoPhysicalDiskCouldBe')
    }

    $elevated = [bool](Get-DataProperty $Data 'Elevated')

    foreach ($disk in $disks) {
        $check = (Get-Text 'Check.DiskHealth.DiskWearErrors') -f $disk.DeviceId

        if (-not $disk.CountersReadable) {
            $why = $(if ($elevated) { (Get-Text 'Value.DiskHealth.NotSupported') } else { (Get-Text 'Value.Shared.NeedsAdmin') })
            # What this drive says of itself was not read: nothing here is "in order".
            New-Finding -Category Storage -Check $check -Severity INFO -Value $why `
                -Hint $(if ($elevated) { Get-Text 'Hint.DiskHealth.UseVendorTool' } else { Get-Text 'Hint.DiskHealth.RerunWithAdmin' }) `
                -Unobserved 'drive-health-warning' -Subject "$($disk.DeviceId)"
            continue
        }

        $wear   = ConvertTo-Number $disk.Wear
        $errors = ConvertTo-Number $disk.ReadErrorsUncorrected
        $temp   = ConvertTo-Number $disk.TemperatureC

        # Wear and uncorrected read errors both end in the same place. A single
        # uncorrected read error is data the drive could not give back, so it fails on its
        # own however young the drive is.
        $wearSeverity = 'OK'
        if ($null -ne $wear) { $wearSeverity = Get-Severity $wear $wearWarn $wearFail }

        $errorSeverity = 'OK'
        if ($null -ne $errors -and $errors -gt 0) { $errorSeverity = 'FAIL' }

        $tempSeverity = 'OK'
        if ($null -ne $temp -and $temp -gt 0) { $tempSeverity = Get-Severity $temp $tempWarn $tempFail }

        $severity = Get-WorstSeverity $wearSeverity $errorSeverity $tempSeverity
        $signal = @()
        if ($severity -in 'WARN', 'FAIL') { $signal = @('drive-health-warning') }
        New-Finding -Category Storage -Check $check -Severity $severity `
            -Value (Get-DiskHealthValue -Disk $disk) `
            -Hint (Get-Text 'Hint.DiskHealth.SSDWornOutOrReporting') `
            -Signal $signal -Subject "$($disk.DeviceId)" -Reference @(New-Reference -Signal 'disk-errors')
    }
}