Private/Kinds/Printing.ps1

# The Printing Kind: the print spooler, the default printer, and what is stuck in between.
#
# Printing is where "Word is slow" most often turns out not to be Word. Office asks the
# default printer for its capabilities when a document opens, so a default printer on a
# network that is no longer there - the office printer, from home - holds every document
# open for as long as the connection takes to time out. A spooler crashing on a bad driver
# takes every printer with it, and one job stuck at the head of a queue blocks the rest.
#
# Gathered in the Technician's own session, never the Elevated Part: printer connections
# and the default printer belong to the user, and an administrator's session would see a
# different set.

# How often the default printer is connected to. Not a Check Definition parameter, for the
# reason Private/Sampling.ps1 gives about measurements: one connect is not evidence, and a
# Customer may disagree about a threshold, not about how many attempts make a reading.
$script:PrintingConnectAttempts = 3

# Where the print system records what went wrong. Its Admin channel is enabled on every
# Windows and readable without admin rights.
$script:PrintingLog = 'Microsoft-Windows-PrintService/Admin'

# The port a printer shared from a print server is reached through.
$script:PrintingServerPort = 445

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

    $days  = [int](Get-Parameter $Parameters 'Days' 30)
    $since = (Get-Date).AddDays(-$days)

    $spooler = Get-CimInstance Win32_Service -Filter "Name='Spooler'" -ErrorAction SilentlyContinue |
        Select-Object -First 1

    $printers = @(Get-CimInstance Win32_Printer -ErrorAction SilentlyContinue | ForEach-Object {
        [pscustomobject]@{
            Name                  = "$($_.Name)"
            Default               = [bool]$_.Default
            Network               = [bool]$_.Network
            WorkOffline           = [bool]$_.WorkOffline
            PrinterStatus         = $_.PrinterStatus
            ExtendedPrinterStatus = $_.ExtendedPrinterStatus
            DetectedErrorState    = $_.DetectedErrorState
            PortName              = "$($_.PortName)"
            ServerName            = "$($_.ServerName)"
            ShareName             = "$($_.ShareName)"
            DriverName            = "$($_.DriverName)"
        }
    })

    $ports = @(Get-CimInstance Win32_TCPIPPrinterPort -ErrorAction SilentlyContinue | ForEach-Object {
        [pscustomobject]@{ Name = "$($_.Name)"; HostAddress = "$($_.HostAddress)"; PortNumber = $_.PortNumber }
    })

    $default = $printers | Where-Object { $_.Default } | Select-Object -First 1
    $reach   = $null
    if ($default) { $reach = Measure-PrinterReachability -Printer $default -Port $ports }

    $now  = Get-Date
    $jobs = @(Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue | ForEach-Object {
        $age = $null
        if ($_.TimeSubmitted -is [datetime]) { $age = [math]::Round(($now - $_.TimeSubmitted).TotalMinutes, 1) }
        [pscustomobject]@{
            # Win32_PrintJob names a job "<printer>, <id>"; the printer is what matters.
            Printer    = ("$($_.Name)" -replace ',\s*\d+$', '')
            Document   = "$($_.Document)"
            JobStatus  = "$($_.JobStatus)"
            AgeMinutes = $age
        }
    })

    $refused = @{}
    $errors  = @()
    if (Test-EventLogReadable -Log $script:PrintingLog -Unreadable $refused) {
        try {
            # Level 1 and 2 are Critical and Error. The Admin channel logs little else, but
            # the warnings it does log are about drivers being replaced, which is no problem.
            $errors = @(Get-WinEvent -FilterHashtable @{ LogName = $script:PrintingLog; Level = 1, 2; StartTime = $since } -ErrorAction Stop |
                ForEach-Object { ConvertTo-EventRow -LogEntry $_ -Tag 'Print error' })
        }
        catch { }   # "No events matched" arrives as an error too; the log was readable.
    }
    $unreadable = $refused.ContainsKey($script:PrintingLog)

    [pscustomobject]@{
        PSTypeName    = 'Gutcheck.Data.Printing'
        Days          = $days
        SpoolerFound  = $null -ne $spooler
        SpoolerState  = "$($spooler.State)"
        SpoolerMode   = "$($spooler.StartMode)"
        Printers      = $printers
        Reachability  = $reach
        Jobs          = $jobs
        Errors        = $errors
        LogUnreadable = $unreadable
    }
}

function Measure-PrinterReachability {
    <#
    .SYNOPSIS
        Connects to whatever the default printer is reached through, if anything.
    .DESCRIPTION
        A printer shared from a print server is reached through the server, over SMB. A
        printer with its own TCP/IP port is reached directly, on that port. Anything else -
        USB, WSD, a PDF writer - has no address this can connect to, and says so rather
        than reporting a connection nobody made.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)]$Printer,
        [AllowEmptyCollection()][object[]]$Port = @()
    )

    $hostName = $null
    $number   = $null
    $via      = 'None'

    if ($Printer.ServerName) {
        $hostName = $Printer.ServerName.TrimStart('\')
        $number   = $script:PrintingServerPort
        $via      = 'PrintServer'
    }
    else {
        $tcp = @($Port | Where-Object { $_.Name -eq $Printer.PortName }) | Select-Object -First 1
        if ($tcp -and $tcp.HostAddress) {
            $hostName = $tcp.HostAddress
            $number   = [int]$tcp.PortNumber
            $via      = 'TcpPort'
        }
    }

    if (-not $hostName) {
        return [pscustomobject]@{ Via = $via; HostName = $null; Port = $null; Resolved = $null; Attempts = 0; Successes = 0; AverageMs = $null }
    }

    $resolved = $true
    if ($hostName -notmatch '^\d{1,3}(\.\d{1,3}){3}$') {
        $resolved = [bool](Resolve-HostAddress -HostName $hostName).Resolved
    }

    $times = @()
    if ($resolved) {
        $times = @(1..$script:PrintingConnectAttempts | ForEach-Object {
            Measure-TcpConnect -HostName $hostName -Port $number
        } | Where-Object { $null -ne $_ })
    }

    [pscustomobject]@{
        Via       = $via
        HostName  = $hostName
        Port      = $number
        Resolved  = $resolved
        Attempts  = $script:PrintingConnectAttempts
        Successes = $times.Count
        AverageMs = $(if ($times.Count) { [math]::Round(($times | Measure-Object -Average).Average, 1) } else { $null })
    }
}

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

    if (-not (Get-DataProperty $Data 'SpoolerFound')) {
        return New-UnavailableFinding -Category System -Check (Get-Text 'Check.Printing.Spooler') `
            -Hint (Get-Text 'Hint.Printing.SpoolerNotFound')
    }

    if ((Get-DataProperty $Data 'SpoolerState') -ne 'Running') {
        # Nothing below means anything without the spooler: every printer reads as absent.
        return New-Finding -Category System -Check (Get-Text 'Check.Printing.Spooler') -Severity FAIL `
            -Value ((Get-Text 'Value.Printing.SpoolerState') -f (Get-DataProperty $Data 'SpoolerState'), (Get-DataProperty $Data 'SpoolerMode')) `
            -Hint (Get-Text 'Hint.Printing.SpoolerNotRunning')
    }
    New-Finding -Category System -Check (Get-Text 'Check.Printing.Spooler') -Severity OK -Value (Get-Text 'Value.Printing.Running')

    New-DefaultPrinterFinding  -Data $Data -Parameters $Parameters
    New-OfflinePrinterFinding  -Data $Data -Parameters $Parameters
    New-StuckPrintJobFinding   -Data $Data -Parameters $Parameters
    New-PrintErrorFinding      -Data $Data -Parameters $Parameters
}

function Test-PrinterOffline {
    <#
    .SYNOPSIS
        Whether a printer says it cannot print, by any of the three ways Windows says it.
    .DESCRIPTION
        PrinterStatus and ExtendedPrinterStatus 7 are Offline; ExtendedPrinterStatus 9 is
        Error. WorkOffline is the "use printer offline" switch a user or a driver flipped.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory)]$Printer)

    if (Get-DataProperty $Printer 'WorkOffline') { return $true }
    $status   = ConvertTo-Number (Get-DataProperty $Printer 'PrinterStatus')
    $extended = ConvertTo-Number (Get-DataProperty $Printer 'ExtendedPrinterStatus')
    ($status -eq 7) -or ($extended -eq 7) -or ($extended -eq 9)
}

function New-DefaultPrinterFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warnMs = Get-Parameter $Parameters 'PrinterConnectWarnMs' 200
    $failMs = Get-Parameter $Parameters 'PrinterConnectFailMs' ([double]::MaxValue)

    $check   = Get-Text 'Check.Printing.DefaultPrinter'
    $default = @((Get-DataCollection $Data 'Printers') | Where-Object { $_.Default }) | Select-Object -First 1

    if (-not $default) {
        return New-Finding -Category System -Check $check -Severity INFO -Value (Get-Text 'Value.Shared.None') `
            -Hint (Get-Text 'Hint.Printing.NoDefaultPrinter')
    }

    $offline = 'OK'
    if (Test-PrinterOffline -Printer $default) { $offline = 'WARN' }

    $reach = Get-DataProperty $Data 'Reachability'
    $via   = Get-DataProperty $reach 'Via'

    if (-not $reach -or $via -eq 'None') {
        # USB, WSD, a PDF writer: nothing to connect to, so the status is all there is.
        $value = (Get-Text 'Value.Printing.DefaultLocal') -f $default.Name, $default.PortName
        return New-Finding -Category System -Check $check -Severity $offline -Value $value `
            -Hint (Get-Text 'Hint.Printing.DefaultOffline')
    }

    $target    = '{0}:{1}' -f (Get-DataProperty $reach 'HostName'), (Get-DataProperty $reach 'Port')
    $successes = ConvertTo-Number (Get-DataProperty $reach 'Successes')
    $attempts  = ConvertTo-Number (Get-DataProperty $reach 'Attempts')

    if (-not (Get-DataProperty $reach 'Resolved') -or -not $successes) {
        # The case this Check exists for: every document Office opens waits on this.
        return New-Finding -Category System -Check $check -Severity FAIL `
            -Value ((Get-Text 'Value.Printing.DefaultUnreachable') -f $default.Name, $target) `
            -Hint (Get-Text 'Hint.Printing.DefaultUnreachable')
    }

    $average = ConvertTo-Number (Get-DataProperty $reach 'AverageMs')
    $missed  = 'OK'
    if ($successes -lt $attempts) { $missed = 'WARN' }
    $slow = 'OK'
    if ($null -ne $average) { $slow = Get-Severity $average $warnMs $failMs }

    New-Finding -Category System -Check $check -Severity (Get-WorstSeverity $offline $missed $slow) `
        -Value ((Get-Text 'Value.Printing.DefaultReachable') -f $default.Name, $target, $average, $successes, $attempts) `
        -Hint (Get-Text 'Hint.Printing.DefaultSlowOrOffline')
}

function New-OfflinePrinterFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'OfflinePrinterWarnAbove' 2
    $fail = Get-Parameter $Parameters 'OfflinePrinterFailAbove' ([double]::MaxValue)

    # The default printer has its own Finding; counting it here too would say it twice.
    $offline = @((Get-DataCollection $Data 'Printers') |
        Where-Object { -not $_.Default -and (Test-PrinterOffline -Printer $_) })

    $check = Get-Text 'Check.Printing.OfflinePrinters'
    if (-not $offline.Count) {
        return New-Finding -Category System -Check $check -Severity OK -Value (Get-Text 'Value.Shared.None')
    }

    $severity = Get-Severity $offline.Count $warn $fail
    if ($severity -eq 'OK') { $severity = 'INFO' }

    New-Finding -Category System -Check $check -Severity $severity `
        -Value ((Get-Text 'Value.Printing.OfflinePrinters') -f $offline.Count, (($offline | ForEach-Object { $_.Name }) -join ', ')) `
        -Hint (Get-Text 'Hint.Printing.OfflinePrinters')
}

function New-StuckPrintJobFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $minutes = Get-Parameter $Parameters 'PrintJobStuckMinutes'  10
    $warn    = Get-Parameter $Parameters 'StuckPrintJobWarnAbove' 0
    $fail    = Get-Parameter $Parameters 'StuckPrintJobFailAbove' 10

    $stuck = @((Get-DataCollection $Data 'Jobs') | Where-Object {
        $age = ConvertTo-Number $_.AgeMinutes
        $null -ne $age -and $age -gt $minutes
    })

    $check = Get-Text 'Check.Printing.StuckJobs'
    if (-not $stuck.Count) {
        return New-Finding -Category System -Check $check -Severity OK -Value (Get-Text 'Value.Shared.None')
    }

    $printers = ($stuck | ForEach-Object { $_.Printer } | Sort-Object -Unique) -join ', '
    New-Finding -Category System -Check $check -Severity (Get-Severity $stuck.Count $warn $fail) `
        -Value ((Get-Text 'Value.Printing.StuckJobs') -f $stuck.Count, $minutes, $printers) `
        -Hint (Get-Text 'Hint.Printing.StuckJobs')
}

function New-PrintErrorFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'PrintErrorWarnAbove' 10
    $fail = Get-Parameter $Parameters 'PrintErrorFailAbove' ([double]::MaxValue)

    $check = Get-Text 'Check.Printing.Errors'
    if (Get-DataProperty $Data 'LogUnreadable') {
        return New-UnavailableFinding -Category System -Check $check -Hint (Get-Text 'Hint.Printing.LogUnreadable')
    }

    $errors = Get-DataCollection $Data 'Errors'
    $days   = Get-DataProperty $Data 'Days'
    if (-not $errors.Count) {
        return New-Finding -Category System -Check $check -Severity OK -Value ((Get-Text 'Value.Printing.NoErrors') -f $days)
    }

    # The most frequent event id is the lead: 808 is a driver that will not load into the
    # spooler, 372 a document that failed to print, 315-367 a printer that would not start.
    $common = $errors | Group-Object Id | Sort-Object Count -Descending | Select-Object -First 3 |
        ForEach-Object { 'ID {0} {1}x' -f $_.Name, $_.Count }

    New-Finding -Category System -Check $check -Severity (Get-Severity $errors.Count $warn $fail) `
        -Value ((Get-Text 'Value.Printing.Errors') -f $errors.Count, $days, ($common -join ', ')) `
        -Hint (Get-Text 'Hint.Printing.Errors')
}

function ConvertTo-PrintingSection {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    New-Section -Title (Get-Text 'Title.Printing.Printers') -Row @(
        (Get-DataCollection $Data 'Printers') |
            Select-Object Name, Default, Network, WorkOffline, PrinterStatus, ExtendedPrinterStatus, PortName, ServerName, DriverName
    )
    New-Section -Title (Get-Text 'Title.Printing.Jobs') -Row (Get-DataCollection $Data 'Jobs')
}

function ConvertTo-PrintingEvent {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    (Get-DataCollection $Data 'Errors')
}