Private/Situation.ps1

# The Situation: the facts about the Target Machine that a Hint's wording may depend on.
#
# A machine at a cable was told three times in one Report to "connect by cable to test",
# because the Hint was one sentence for every machine, and because three Kinds would each
# have had to find out the connection for themselves to do better. The Situation is found
# out once, before the first Check, stated in the Report, and handed to whoever asks. What
# is not in it, a Hint does not assume.
#
# Five facts, each one of a few answers or "Unknown". Reading them from Windows is a
# Gatherer and is untested like every Gatherer; turning what was read into the five facts
# is pure (ADR-0002).

$script:SituationValues = [ordered]@{
    Connection = @('Wired', 'Wireless', 'Vpn')
    Form       = @('Notebook', 'Desktop', 'Virtual')
    Docked     = @('Yes', 'No')
    Power      = @('Battery', 'Mains')
    Domain     = @('Joined', 'NotJoined')
}

# The enclosure types of the SMBIOS specification (DMTF DSP0134, System Enclosure or
# Chassis Types), as Win32_SystemEnclosure.ChassisTypes reports them. 12 is a docking
# station and is reported by some notebooks while docked.
$script:SituationNotebookChassis = @(8, 9, 10, 11, 12, 14, 30, 31, 32)
$script:SituationDesktopChassis  = @(3, 4, 5, 6, 7, 13, 15, 16, 35, 36)

function Get-SituationReading {
    <#
    .SYNOPSIS
        Asks Windows what the Situation is made from. Reads, and decides nothing.
    .DESCRIPTION
        Every question is asked on its own and may go unanswered: a property that could
        not be read is left $null, which ConvertTo-Situation reads as "not known".
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param()

    $reading = [ordered]@{
        Adapter = $null; ChassisTypes = $null; PcSystemType = $null; Manufacturer = $null; Model = $null
        PowerOnline = $null; HasBattery = $null; BatteryStatus = $null
        DockingState = $null; DockDevicePresent = $null; PartOfDomain = $null; DomainName = $null
    }

    # The adapter the default route leaves by: the one the machine reaches everything
    # else through.
    try {
        if (Get-Command -Name Get-NetRoute -ErrorAction SilentlyContinue) {
            $route = @(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop |
                Sort-Object { [int]$_.RouteMetric + [int]$_.InterfaceMetric })[0]
            if ($route) {
                $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -ErrorAction Stop
                $reading.Adapter = [pscustomobject]@{
                    PhysicalMediaType    = "$($adapter.PhysicalMediaType)"
                    NdisPhysicalMedium   = $adapter.NdisPhysicalMedium
                    Virtual              = [bool]$adapter.Virtual
                    InterfaceDescription = "$($adapter.InterfaceDescription)"
                }
            }
        }
    }
    catch { }

    try { $reading.ChassisTypes = @((Get-CimInstance Win32_SystemEnclosure -ErrorAction Stop | ForEach-Object { $_.ChassisTypes }) | Where-Object { $null -ne $_ }) } catch { }

    try {
        $system = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
        $reading.PcSystemType = $system.PCSystemType
        $reading.Manufacturer = "$($system.Manufacturer)"
        $reading.Model        = "$($system.Model)"
        $reading.PartOfDomain = [bool]$system.PartOfDomain
        $reading.DomainName   = "$($system.Domain)"
    }
    catch { }

    try {
        $batteries = @(Get-CimInstance Win32_Battery -ErrorAction Stop)
        $reading.HasBattery = $batteries.Count -gt 0
        if ($batteries.Count) { $reading.BatteryStatus = $batteries[0].BatteryStatus }
    }
    catch { }
    # Says in one word what Win32_Battery says in eleven states. Not on every machine.
    try {
        $status = @(Get-CimInstance -Namespace 'root\wmi' -ClassName BatteryStatus -ErrorAction Stop)
        if ($status.Count) { $reading.PowerOnline = [bool]$status[0].PowerOnline }
    }
    catch { }

    # 1 undocked, 2 docked, 0 "cannot be docked or not known". Written by docking
    # stations that announce themselves to Windows, which a USB-C hub does not.
    try {
        $dock = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\IDConfigDB\CurrentDockInfo' -Name DockingState -ErrorAction Stop
        $reading.DockingState = $dock.DockingState
    }
    catch { }
    try {
        $reading.DockDevicePresent = @(Get-CimInstance Win32_PnPEntity -Filter "Name LIKE '%Dock%'" -ErrorAction Stop |
            Where-Object { $_.Present -and $_.ConfigManagerErrorCode -eq 0 }).Count -gt 0
    }
    catch { }

    [pscustomobject]$reading
}

function ConvertTo-Situation {
    <#
    .SYNOPSIS
        What was read from Windows, as the five facts. Pure.
    .DESCRIPTION
        "Unknown" is an answer like the others, and the one given whenever the reading
        does not settle the question: a Hint that takes a guess for a measurement sends a
        Technician the wrong way with confidence.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Reading)

    # How the machine is connected, by the adapter of its default route.
    # An adapter that is neither a cable nor a radio is a VPN or a virtual switch, and is
    # called Vpn: what carries it underneath is not asked. NdisPhysicalMedium: 9 is native
    # 802.11, 14 is 802.3.
    $connection = 'Unknown'
    $adapterName = ''
    $adapter = Get-DataProperty $Reading 'Adapter'
    if ($adapter) {
        $media  = "$(Get-DataProperty $adapter 'PhysicalMediaType')"
        $medium = ConvertTo-Number (Get-DataProperty $adapter 'NdisPhysicalMedium')
        $connection = if ($media -match '802\.11|Wireless' -or $medium -eq 9) { 'Wireless' }
                      elseif (-not (Get-DataProperty $adapter 'Virtual') -and ($media -match '802\.3' -or $medium -eq 14)) { 'Wired' }
                      else { 'Vpn' }
        $adapterName = "$(Get-DataProperty $adapter 'InterfaceDescription')"
    }

    # A virtual machine claims whatever enclosure its host gave it.
    $form = 'Unknown'
    $maker = '{0} {1}' -f (Get-DataProperty $Reading 'Manufacturer'), (Get-DataProperty $Reading 'Model')
    $chassis = @((Get-DataCollection $Reading 'ChassisTypes') | ForEach-Object { ConvertTo-Number $_ } | Where-Object { $null -ne $_ })
    $pcType  = ConvertTo-Number (Get-DataProperty $Reading 'PcSystemType')
    if ($maker -match '(?i)\bVirtual Machine\b|VMware|VirtualBox|\bQEMU\b|\bKVM\b|\bXen\b|Parallels') { $form = 'Virtual' }
    elseif (@($chassis | Where-Object { $_ -in $script:SituationNotebookChassis }).Count) { $form = 'Notebook' }
    elseif (@($chassis | Where-Object { $_ -in $script:SituationDesktopChassis }).Count)  { $form = 'Desktop' }
    # Win32_ComputerSystem.PCSystemType: 1 desktop, 2 mobile, 3 workstation.
    elseif ($pcType -eq 2)       { $form = 'Notebook' }
    elseif ($pcType -in 1, 3)    { $form = 'Desktop' }

    # Win32_Battery.BatteryStatus: 1, 4 and 5 are a battery being drained; 2 is "has
    # access to AC", 3 fully charged, 6 to 9 charging.
    $power  = 'Unknown'
    $online = Get-DataProperty $Reading 'PowerOnline'
    $status = ConvertTo-Number (Get-DataProperty $Reading 'BatteryStatus')
    if ($online -is [bool])                                        { $power = $(if ($online) { 'Mains' } else { 'Battery' }) }
    elseif ((Get-DataProperty $Reading 'HasBattery') -eq $false)   { $power = 'Mains' }
    elseif ($status -in 1, 4, 5)                                   { $power = 'Battery' }
    elseif ($status -in 2, 3, 6, 7, 8, 9)                          { $power = 'Mains' }

    # Only a notebook is docked. "Not docked" takes Windows saying so: nothing seen is
    # what a docking station that is a plain USB hub looks like as well.
    $docked = 'Unknown'
    $state  = ConvertTo-Number (Get-DataProperty $Reading 'DockingState')
    $seen   = Get-DataProperty $Reading 'DockDevicePresent'
    if ($form -in 'Desktop', 'Virtual')            { $docked = 'No' }
    elseif ($state -eq 2 -or $seen -eq $true)      { $docked = 'Yes' }
    elseif ($state -eq 1 -and $seen -eq $false)    { $docked = 'No' }

    $domain = 'Unknown'
    $joined = Get-DataProperty $Reading 'PartOfDomain'
    if ($joined -is [bool]) { $domain = $(if ($joined) { 'Joined' } else { 'NotJoined' }) }

    [pscustomobject]@{
        PSTypeName        = 'Gutcheck.Situation'
        Connection        = $connection
        ConnectionAdapter = $adapterName
        Form              = $form
        Docked            = $docked
        Power             = $power
        Domain            = $domain
        DomainName        = $(if ($domain -eq 'Joined') { "$(Get-DataProperty $Reading 'DomainName')" } else { '' })
    }
}

function Get-SituationRow {
    <#
    .SYNOPSIS
        The Situation as rows of the box at the top of the Report: Group, Name, Value. Pure.
    .DESCRIPTION
        All five, always: what could not be determined says so. A Technician reading a
        Hint has to be able to see what it assumed, and that it assumed nothing.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Situation)

    if ($null -eq $Situation) { return }

    $group   = Get-Text 'System.Info.Group.Situation'
    $unknown = Get-Text 'Situation.Unknown'
    $detail  = { param($text, $more) if ("$more".Trim()) { (Get-Text 'Situation.WithDetail') -f $text, "$more".Trim() } else { $text } }

    $connection = switch ("$(Get-DataProperty $Situation 'Connection')") {
        'Wired'    { & $detail (Get-Text 'Situation.Connection.Wired')    (Get-DataProperty $Situation 'ConnectionAdapter') }
        'Wireless' { & $detail (Get-Text 'Situation.Connection.Wireless') (Get-DataProperty $Situation 'ConnectionAdapter') }
        'Vpn'      { & $detail (Get-Text 'Situation.Connection.Vpn')      (Get-DataProperty $Situation 'ConnectionAdapter') }
        default    { $unknown }
    }
    $form = switch ("$(Get-DataProperty $Situation 'Form')") {
        'Notebook' { Get-Text 'Situation.Form.Notebook' }
        'Desktop'  { Get-Text 'Situation.Form.Desktop' }
        'Virtual'  { Get-Text 'Situation.Form.Virtual' }
        default    { $unknown }
    }
    $docked = switch ("$(Get-DataProperty $Situation 'Docked')") {
        'Yes'   { Get-Text 'Situation.Docked.Yes' }
        'No'    { Get-Text 'Situation.Docked.No' }
        default { $unknown }
    }
    $power = switch ("$(Get-DataProperty $Situation 'Power')") {
        'Battery' { Get-Text 'Situation.Power.Battery' }
        'Mains'   { Get-Text 'Situation.Power.Mains' }
        default   { $unknown }
    }
    $domain = switch ("$(Get-DataProperty $Situation 'Domain')") {
        'Joined'    { & $detail (Get-Text 'Situation.Domain.Joined') (Get-DataProperty $Situation 'DomainName') }
        'NotJoined' { Get-Text 'Situation.Domain.NotJoined' }
        default     { $unknown }
    }

    [pscustomobject]@{ Group = $group; Name = (Get-Text 'Situation.Connection'); Value = $connection }
    [pscustomobject]@{ Group = $group; Name = (Get-Text 'Situation.Form');       Value = $form }
    [pscustomobject]@{ Group = $group; Name = (Get-Text 'Situation.Docked');     Value = $docked }
    [pscustomobject]@{ Group = $group; Name = (Get-Text 'Situation.Power');      Value = $power }
    [pscustomobject]@{ Group = $group; Name = (Get-Text 'Situation.Domain');     Value = $domain }
}

function ConvertTo-SituationDocument {
    <#
    .SYNOPSIS
        The Situation as the text the Elevated Part is handed it in.
    #>

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

    $document = [ordered]@{}
    foreach ($fact in @($script:SituationValues.Keys) + 'ConnectionAdapter', 'DomainName') { $document[$fact] = "$(Get-DataProperty $Situation $fact)" }
    $document | ConvertTo-Json
}

function ConvertFrom-SituationDocument {
    <#
    .SYNOPSIS
        Reads the Situation the Main Part handed over. Pure.
    .DESCRIPTION
        The Elevated Part does not determine its own: it runs in another session, behind
        another logon, and both Parts' Findings are about one machine and have to be
        worded for the same one. What arrives is text from another process and is held to
        the permitted answers; anything else is "Unknown", and nothing that is not one of
        the facts is taken along.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyString()][string]$Text)

    $read = $null
    try { if ("$Text".Trim()) { $read = $Text | ConvertFrom-Json -ErrorAction Stop } } catch { $read = $null }

    $situation = [ordered]@{ PSTypeName = 'Gutcheck.Situation' }
    foreach ($fact in $script:SituationValues.Keys) {
        $value = "$(Get-DataProperty $read $fact)"
        $situation[$fact] = $(if ($value -in $script:SituationValues[$fact]) { @($script:SituationValues[$fact] | Where-Object { $_ -eq $value })[0] } else { 'Unknown' })
    }
    $situation['ConnectionAdapter'] = "$(Get-DataProperty $read 'ConnectionAdapter')"
    $situation['DomainName']        = "$(Get-DataProperty $read 'DomainName')"
    [pscustomobject]$situation
}

function Select-SituationText {
    <#
    .SYNOPSIS
        The wording for what the Situation says of one fact. Pure.
    .DESCRIPTION
        The one way a Kind varies a text by the Situation. It takes a wording for each
        answer the Kind has something particular to say about and insists on one for
        "Unknown", which is also what is said where the Situation gives an answer the
        text has no wording for: a Hint that has to guess says what fits every case.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()]$Situation,
        [Parameter(Mandatory)][ValidateSet('Connection', 'Form', 'Docked', 'Power', 'Domain')][string]$Fact,
        [Parameter(Mandatory)][hashtable]$Text
    )

    if (-not $Text.ContainsKey('Unknown')) {
        throw ("A text that varies by the Situation needs a wording for 'Unknown' ({0})." -f $Fact)
    }
    $answer = "$(Get-DataProperty $Situation $Fact)"
    if ($answer -and $Text.ContainsKey($answer)) { return "$($Text[$answer])" }
    "$($Text['Unknown'])"
}

function Get-SituationAdapterName {
    <#
    .SYNOPSIS
        The adapter the machine is connected through, to name in a Hint. Empty where it
        is not known. Pure.
    #>

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

    "$(Get-DataProperty $Situation 'ConnectionAdapter')"
}
function Get-SituationLinkText {
    <#
    .SYNOPSIS
        What there is to check on this machine's side of a connection, as the middle of a
        sentence. Pure.
    #>

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

    $wired = Select-SituationText -Situation $Situation -Fact Docked -Text @{
        Yes     = Get-Text 'Situation.Link.WiredDocked'
        No      = Get-Text 'Situation.Link.Wired'
        Unknown = Get-Text 'Situation.Link.WiredMaybeDocked'
    }
    Select-SituationText -Situation $Situation -Fact Connection -Text @{
        Wired    = $wired
        Wireless = Get-Text 'Situation.Link.Wireless'
        Vpn      = Get-Text 'Situation.Link.Vpn'
        Unknown  = Get-Text 'Situation.Link.Unknown'
    }
}

function Add-SituationPowerNote {
    <#
    .SYNOPSIS
        Says on a measurement that is not in order that it was taken on battery. Pure.
    .DESCRIPTION
        Windows holds a machine back on battery, and a slow result then says something
        about the power plan and nothing about the machine. Said after what the Finding
        says already, and only where it is WARN or FAIL: there is nothing to qualify
        about a value that is in order.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(ValueFromPipeline)]$Finding,
        [AllowNull()]$Situation
    )
    begin {
        $note = Select-SituationText -Situation $Situation -Fact Power -Text @{
            Battery = Get-Text 'Hint.Situation.OnBattery'; Mains = ''; Unknown = ''
        }
    }
    process {
        if ($null -eq $Finding) { return }
        if ($note -and "$($Finding.Severity)" -in 'WARN', 'FAIL') {
            $Finding.Hint = @(@("$($Finding.Hint)", $note) | Where-Object { $_ }) -join ' | '
        }
        $Finding
    }
}

function Add-SituationSection {
    <#
    .SYNOPSIS
        Makes sure a Report states the Situation: adds a box for it where no Section at
        the top carries it already. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$Section, [AllowNull()]$Situation)

    $sections = @($Section | Where-Object { $_ })
    $group = Get-Text 'System.Info.Group.Situation'
    $stated = @($sections | Where-Object { "$(Get-DataProperty $_ 'Placement')" -eq 'Top' } |
        Where-Object { @((Get-DataCollection $_ 'Row') | Where-Object { "$(Get-DataProperty $_ 'Group')" -eq $group }).Count })
    if ($stated.Count -or $null -eq $Situation) { return $sections }

    @(New-Section -Title (Get-Text 'Title.System.Information') -Placement Top -Row @(Get-SituationRow -Situation $Situation)) + $sections
}