helpers/discovery/DiscoveryProvider-CUCM.ps1

<#
.SYNOPSIS
    Cisco Unified Communications Manager discovery provider for dashboards and WUG SNMP monitor plans.
 
.DESCRIPTION
    Registers a CUCM discovery provider that uses SharpSnmpLib SNMP module
    to walk Cisco's ccmPhoneTable and derive a framework-aligned WUG monitor plan.
 
    The provider exposes helper functions so setup scripts can:
      - collect the full phone inventory for dashboards and exports
      - build a shared SNMPTable monitor definition for phone registration status
      - stamp useful aggregate CUCM counts onto the target WUG device
 
.NOTES
    Requires: DiscoveryHelpers.ps1 loaded first, WhatsUpGoldPS.Snmp module
    Encoding: UTF-8 with BOM
#>


if (-not (Get-Command -Name 'Register-DiscoveryProvider' -ErrorAction SilentlyContinue)) {
    $discoveryPath = Join-Path (Split-Path $MyInvocation.MyCommand.Path -Parent) 'DiscoveryHelpers.ps1'
    if (Test-Path $discoveryPath) {
        . $discoveryPath
    }
    else {
        throw 'DiscoveryHelpers.ps1 not found. Load it before this provider.'
    }
}

# Load WhatsUpGoldPS.Snmp module
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
$snmpModulePath = Join-Path (Split-Path $scriptDir -Parent) 'snmp\WhatsUpGoldPS.Snmp\WhatsUpGoldPS.Snmp.psd1'
if (-not (Get-Module -Name 'WhatsUpGoldPS.Snmp')) {
    if (Test-Path $snmpModulePath) {
        Import-Module $snmpModulePath -Force -ErrorAction Stop
        Write-Verbose "[CUCM] Loaded WhatsUpGoldPS.Snmp module"
    }
    else {
        throw "WhatsUpGoldPS.Snmp module not found at: $snmpModulePath"
    }
}

# Initialize SharpSnmpLib
if (Get-Command -Name 'Import-SharpSnmpLib' -ErrorAction SilentlyContinue) {
    Import-SharpSnmpLib -ErrorAction Stop | Out-Null
    Write-Verbose "[CUCM] SharpSnmpLib initialized"
}

# ============================================================================
# Shared OctetString decoder -- used by BOTH walk paths (SharpSnmpLib + COM API)
# so that binary SNMP columns produce identical output regardless of library.
#
# Column type map (script-scope so both functions can reference it):
# MAC -- 6-byte OctetString -> AA:BB:CC:DD:EE:FF
# Date -- 8/11-byte DateAndTime -> YYYY-MM-DD HH:MM:SS
# Inet -- 4-byte IPv4 or 16-byte IPv6 OctetString -> dotted / colon notation
# ============================================================================
$script:CUCMColumnDecodeType = @{
    '2'  = 'MAC'    # PhonePhysicalAddress
    '8'  = 'Date'   # PhoneTimeLastRegistered
    '12' = 'Date'   # PhoneTimeLastError
    '15' = 'Inet'   # PhoneInetAddress
    '17' = 'Date'   # PhoneTimeLastStatusUpdt
    '21' = 'Inet'   # PhoneInetAddressIPv4
    '22' = 'Inet'   # PhoneInetAddressIPv6
}

function ConvertFrom-CUCMSnmpOctetString {
    <#
    .SYNOPSIS
        Decodes a raw byte array from a binary SNMP OctetString column into a
        human-readable string. Returns $null if the byte count is not expected
        (caller should fall back to the raw string value).
    .PARAMETER Bytes
        Raw byte array extracted from the SNMP response.
    .PARAMETER ColumnType
        One of 'MAC', 'Date', or 'Inet' (from $script:CUCMColumnDecodeType).
    #>

    param(
        [Parameter(Mandatory = $true)]
        [byte[]]$Bytes,

        [Parameter(Mandatory = $true)]
        [ValidateSet('MAC','Date','Inet')]
        [string]$ColumnType
    )

    switch ($ColumnType) {
        'MAC' {
            if ($Bytes.Count -eq 6) {
                return ($Bytes | ForEach-Object { '{0:X2}' -f $_ }) -join ':'
            }
        }
        'Date' {
            if ($Bytes.Count -ge 8) {
                $year  = ([int]$Bytes[0] -shl 8) + [int]$Bytes[1]
                $month = [int]$Bytes[2]; $day  = [int]$Bytes[3]
                $hour  = [int]$Bytes[4]; $min  = [int]$Bytes[5]; $sec = [int]$Bytes[6]
                return ('{0}-{1:D2}-{2:D2} {3:D2}:{4:D2}:{5:D2}' -f $year,$month,$day,$hour,$min,$sec)
            }
        }
        'Inet' {
            if ($Bytes.Count -eq 4) {
                return "$($Bytes[0]).$($Bytes[1]).$($Bytes[2]).$($Bytes[3])"
            }
            if ($Bytes.Count -eq 16) {
                return ([System.Net.IPAddress]::new($Bytes)).ToString()
            }
        }
    }
    return $null   # unexpected byte count -- caller uses raw string
}

# ============================================================================
# Protocol code converters for WUG COM API (CoreAsp.SnmpRqst Initialize4)
# ============================================================================
function ConvertTo-CUCMComApiAuthCode {
    param([string]$Protocol)
    $map = @{
        'None'   = 0; 'MD5'    = 1; 'SHA'    = 3; 'SHA1'   = 3
        'SHA256' = 5; 'SHA384' = 6; 'SHA512' = 7
    }
    if ($map.ContainsKey($Protocol)) { return $map[$Protocol] }
    Write-Verbose "[COM] Unknown auth protocol '$Protocol', defaulting to None (0)"
    return 0
}

function ConvertTo-CUCMComApiPrivCode {
    param([string]$Protocol)
    $map = @{
        'None'      = 0; 'DES'    = 1; '3DES'  = 2; 'TripleDES' = 2
        'AES'       = 3; 'AES128' = 3; 'AES192' = 4; 'AES256'    = 5
    }
    if ($map.ContainsKey($Protocol)) { return $map[$Protocol] }
    Write-Verbose "[COM] Unknown priv protocol '$Protocol', defaulting to None (0)"
    return 0
}

# ============================================================================
# WUG COM API walk (CoreAsp.SnmpRqst) -- fallback when SharpSnmpLib fails.
# Returns the same walkData ordered hashtable consumed by Get-CUCMPhoneInventory
# Phase 2 (instance -> column -> string value, no binary decoding needed).
# ============================================================================
function Invoke-CUCMPhoneWalkComApi {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$TargetAddress,

        [Parameter(Mandatory = $true)]
        [hashtable]$Credential
    )

    $snmpVersion   = if ($Credential.SnmpVersion)      { [int]$Credential.SnmpVersion }        else { 2 }
    $community     = if ($Credential.Community)         { [string]$Credential.Community }       else { '' }
    $username      = if ($Credential.Username)          { [string]$Credential.Username }        else { '' }
    $context       = if ($Credential.Context)           { [string]$Credential.Context }         else { '' }
    $authProtoName = if ($Credential.AuthProtocol)      { [string]$Credential.AuthProtocol }    else { 'None' }
    $privProtoName = if ($Credential.PrivacyProtocol)   { [string]$Credential.PrivacyProtocol } else { 'None' }
    $authPassword  = if ($Credential.AuthPassword)      { [string]$Credential.AuthPassword }    else { '' }
    $privPassword  = if ($Credential.PrivacyPassword)   { [string]$Credential.PrivacyPassword } else { '' }
    $snmpPort      = if ($Credential.Port)              { [int]$Credential.Port }               else { 161 }
    $timeoutMs     = if ($Credential.TimeoutMs)         { [int]$Credential.TimeoutMs }          else { 5000 }
    $retries       = if ($null -ne $Credential.Retries) { [int]$Credential.Retries }            else { 1 }

    $authCode = ConvertTo-CUCMComApiAuthCode -Protocol $authProtoName
    $privCode = ConvertTo-CUCMComApiPrivCode -Protocol $privProtoName

    $tableBaseOid = '1.3.6.1.4.1.9.9.156.1.2.1.1'

    Write-Host " [COM API] Connecting via CoreAsp.SnmpRqst to ${TargetAddress}..." -ForegroundColor DarkGray
    Write-Verbose "[COM] Initialize4: Target=$TargetAddress v=$snmpVersion Port=$snmpPort Timeout=${timeoutMs}ms"
    if ($snmpVersion -eq 3) {
        Write-Verbose "[COM] v3: Username=$username AuthProto=$authProtoName ($authCode) PrivProto=$privProtoName ($privCode)"
    }

    $snmpRequest = New-Object -ComObject 'CoreAsp.SnmpRqst'
    $initResult  = $snmpRequest.Initialize4(
        $TargetAddress, $snmpVersion, $community,
        $username, $context,
        $authCode, $authPassword,
        $privCode, $privPassword
    )
    if ($initResult.Failed) {
        throw "COM API Initialize4 failed for ${TargetAddress}: $($initResult.GetErrorMsg())"
    }

    if ($snmpRequest.SetTimeoutMs($timeoutMs).Failed) { Write-Warning 'COM API: SetTimeoutMs failed (non-fatal)' }
    if ($snmpRequest.SetNumRetries($retries).Failed)  { Write-Warning 'COM API: SetNumRetries failed (non-fatal)' }
    if ($snmpRequest.SetPort($snmpPort).Failed)        { Write-Warning 'COM API: SetPort failed (non-fatal)' }

    Write-Host " [COM API] Walking ccmPhoneEntry subtree (GetNext loop)..." -ForegroundColor DarkGray
    $walkResponse = $snmpRequest.GetNext($tableBaseOid)
    if ($walkResponse.Failed) {
        throw "COM API: Initial GetNext failed for ${TargetAddress}: $($walkResponse.GetErrorMsg())"
    }

    $walkData  = [ordered]@{}
    $walkCount = 0

    # COM API GetValue() returns binary OctetStrings as raw byte strings (Latin1-encoded).
    # Use ConvertFrom-CUCMSnmpOctetString (shared decoder) with Latin1 byte extraction.

    while (-not $walkResponse.Failed) {
        $currentOid = ([string]$walkResponse.GetOid()).TrimStart('.')
        if (-not $currentOid.StartsWith("$tableBaseOid.")) {
            Write-Verbose "[COM] OID outside subtree -- walk complete: $currentOid"
            break
        }

        $suffix = $currentOid.Substring($tableBaseOid.Length + 1)
        $dotPos = $suffix.IndexOf('.')
        if ($dotPos -ge 0) {
            $colNum   = $suffix.Substring(0, $dotPos)
            $instance = $suffix.Substring($dotPos + 1)
            $value    = ''
            try { $value = [string]$walkResponse.GetValue() } catch { $value = '' }

            # Decode binary OctetString columns using shared decoder
            $decodeType = $script:CUCMColumnDecodeType[$colNum]
            if ($decodeType) {
                $raw     = [System.Text.Encoding]::Latin1.GetBytes($value)
                $decoded = ConvertFrom-CUCMSnmpOctetString -Bytes $raw -ColumnType $decodeType
                if ($null -ne $decoded) { $value = $decoded }
            }

            if (-not $walkData.Contains($instance)) { $walkData[$instance] = @{} }
            $walkData[$instance][$colNum] = $value

            $walkCount++
            if ($walkCount % 200 -eq 0) {
                Write-Host " [COM API] $walkCount OIDs collected ($($walkData.Count) phones)..." -ForegroundColor DarkGray
            }
            Write-Verbose "[COM] OID $walkCount : col=$colNum instance=$instance value='$value'"
        }

        $walkResponse = $snmpRequest.GetNext($currentOid)
    }

    Write-Host " [COM API] Walk complete: $walkCount OIDs, $($walkData.Count) phone instance(s)" -ForegroundColor Green
    Write-Verbose "[COM] Walk finished. OIDs=$walkCount Instances=$($walkData.Count)"

    return $walkData
}

function Get-CUCMPhoneColumnMap {
    [CmdletBinding()]
    param()

    return [ordered]@{
        'CUCM.PhoneIndex'              = '1.3.6.1.4.1.9.9.156.1.2.1.1.1'
        'CUCM.PhonePhysicalAddress'    = '1.3.6.1.4.1.9.9.156.1.2.1.1.2'
        'CUCM.PhoneType'               = '1.3.6.1.4.1.9.9.156.1.2.1.1.3'
        'CUCM.PhoneDescription'        = '1.3.6.1.4.1.9.9.156.1.2.1.1.4'
        'CUCM.PhoneUserName'           = '1.3.6.1.4.1.9.9.156.1.2.1.1.5'
        'CUCM.PhoneIpAddress'          = '1.3.6.1.4.1.9.9.156.1.2.1.1.6'
        'CUCM.PhoneStatus'             = '1.3.6.1.4.1.9.9.156.1.2.1.1.7'
        'CUCM.PhoneTimeLastRegistered' = '1.3.6.1.4.1.9.9.156.1.2.1.1.8'
        'CUCM.PhoneE911Location'       = '1.3.6.1.4.1.9.9.156.1.2.1.1.9'
        'CUCM.PhoneLoadID'             = '1.3.6.1.4.1.9.9.156.1.2.1.1.10'
        'CUCM.PhoneLastError'          = '1.3.6.1.4.1.9.9.156.1.2.1.1.11'
        'CUCM.PhoneTimeLastError'      = '1.3.6.1.4.1.9.9.156.1.2.1.1.12'
        'CUCM.PhoneDevicePoolIndex'    = '1.3.6.1.4.1.9.9.156.1.2.1.1.13'
        'CUCM.PhoneInetAddressType'    = '1.3.6.1.4.1.9.9.156.1.2.1.1.14'
        'CUCM.PhoneInetAddress'        = '1.3.6.1.4.1.9.9.156.1.2.1.1.15'
        'CUCM.PhoneStatusReason'       = '1.3.6.1.4.1.9.9.156.1.2.1.1.16'
        'CUCM.PhoneTimeLastStatusUpdt' = '1.3.6.1.4.1.9.9.156.1.2.1.1.17'
        'CUCM.PhoneProductTypeIndex'   = '1.3.6.1.4.1.9.9.156.1.2.1.1.18'
        'CUCM.PhoneProtocol'           = '1.3.6.1.4.1.9.9.156.1.2.1.1.19'
        'CUCM.PhoneName'               = '1.3.6.1.4.1.9.9.156.1.2.1.1.20'
        'CUCM.PhoneInetAddressIPv4'    = '1.3.6.1.4.1.9.9.156.1.2.1.1.21'
        'CUCM.PhoneInetAddressIPv6'    = '1.3.6.1.4.1.9.9.156.1.2.1.1.22'
        'CUCM.PhoneIPv4Attribute'      = '1.3.6.1.4.1.9.9.156.1.2.1.1.23'
        'CUCM.PhoneIPv6Attribute'      = '1.3.6.1.4.1.9.9.156.1.2.1.1.24'
        'CUCM.PhoneActiveLoadID'       = '1.3.6.1.4.1.9.9.156.1.2.1.1.25'
        'CUCM.PhoneUnregReason'        = '1.3.6.1.4.1.9.9.156.1.2.1.1.26'
        'CUCM.PhoneRegFailReason'      = '1.3.6.1.4.1.9.9.156.1.2.1.1.27'
    }
}

function ConvertFrom-CUCMSnmpVarBindPayload {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$PayloadXml
    )

    $result = [ordered]@{}
    if ([string]::IsNullOrWhiteSpace($PayloadXml)) {
        return $result
    }

    [xml]$xmlDoc = $PayloadXml
    $varBinds = @($xmlDoc.VarBindList.SnmpVarBind)
    foreach ($varBind in $varBinds) {
        if ($null -eq $varBind) { continue }

        $oid = [string]$varBind.sOid
        if ([string]::IsNullOrWhiteSpace($oid)) { continue }

        $hasError = "$($varBind.bHasError)" -eq 'true'
        if ($hasError) {
            $errorText = [string]$varBind.sError
            if ([string]::IsNullOrWhiteSpace($errorText)) { $errorText = 'SNMP error' }
            $result[$oid] = "N/A ($errorText)"
        }
        else {
            $result[$oid] = [string]$varBind.sValue
        }
    }

    return $result
}

function ConvertFrom-CUCMEnumValue {
    [CmdletBinding()]
    param(
        [Parameter()]
        [string]$Value,

        [Parameter(Mandatory = $true)]
        [hashtable]$Map
    )

    if ([string]::IsNullOrWhiteSpace($Value)) { return $Value }
    if ($Map.ContainsKey($Value)) { return $Map[$Value] }
    return $Value
}

function ConvertFrom-CUCMDateValue {
    [CmdletBinding()]
    param(
        [Parameter()]
        [string]$Value
    )

    if ([string]::IsNullOrWhiteSpace($Value)) { return $Value }

    if ($Value -match '^(\d{4})-(\d{1,2})-(\d{1,2}),(\d{1,2}):(\d{1,2}):(\d{1,2})') {
        try {
            return (Get-Date -Year ([int]$Matches[1]) -Month ([int]$Matches[2]) -Day ([int]$Matches[3]) -Hour ([int]$Matches[4]) -Minute ([int]$Matches[5]) -Second ([int]$Matches[6])).ToString('yyyy-MM-dd HH:mm:ss')
        }
        catch {
            return $Value
        }
    }

    return $Value
}

function Get-CUCMPhoneStatusCounts {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [object[]]$PhoneRows
    )

    $counts = [ordered]@{
        total               = 0
        registered          = 0
        unregistered        = 0
        rejected            = 0
        partiallyregistered = 0
        unknown             = 0
    }

    foreach ($row in $PhoneRows) {
        $counts.total++
        $status = [string]$row.Status
        if ([string]::IsNullOrWhiteSpace($status)) {
            $status = 'unknown'
        }
        if ($counts.Contains($status)) {
            $counts[$status]++
        }
        else {
            $counts.unknown++
        }
    }

    return $counts
}

function Get-CUCMPhoneInventory {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$DeviceName,

        [Parameter(Mandatory = $true)]
        [string]$TargetAddress,

        [Parameter(Mandatory = $true)]
        [hashtable]$Credential
    )

    $snmpVersion = if ($Credential.SnmpVersion) { [int]$Credential.SnmpVersion } else { 2 }
    $community = if ($Credential.Community) { [string]$Credential.Community } else { '' }
    $username = if ($Credential.Username) { [string]$Credential.Username } else { '' }
    $context = if ($Credential.Context) { [string]$Credential.Context } else { '' }
    $authProtocol = if ($Credential.AuthProtocol) { [string]$Credential.AuthProtocol } else { 'None' }
    $authPassword = if ($Credential.AuthPassword) { [string]$Credential.AuthPassword } else { '' }
    $privacyProtocol = if ($Credential.PrivacyProtocol) { [string]$Credential.PrivacyProtocol } else { 'None' }
    $privacyPassword = if ($Credential.PrivacyPassword) { [string]$Credential.PrivacyPassword } else { '' }
    $snmpPort = if ($Credential.Port) { [int]$Credential.Port } else { 161 }
    $timeoutMs = if ($Credential.TimeoutMs) { [int]$Credential.TimeoutMs } else { 5000 }
    $retries = if ($null -ne $Credential.Retries) { [int]$Credential.Retries } else { 1 }

    $statusMap = @{
        '1' = 'unknown'
        '2' = 'registered'
        '3' = 'unregistered'
        '4' = 'rejected'
        '5' = 'partiallyregistered'
    }
    $protocolMap = @{
        '1' = 'unknown'
        '2' = 'sccp'
        '3' = 'sip'
    }
    $addressScopeMap = @{
        '0' = 'unknown'
        '1' = 'adminOnly'
        '2' = 'controlOnly'
        '3' = 'adminAndControl'
    }

    # CcmDevFailCauseCode — used by StatusReason, UnregReason, RegFailReason
    $causeCodeMap = @{
        '0'  = 'noError'
        '1'  = 'unknown'
        '2'  = 'noEntryInDatabase'
        '3'  = 'databaseConfigurationError'
        '4'  = 'deviceNameUnresolveable'
        '5'  = 'maxDevRegReached'
        '6'  = 'connectivityError'
        '7'  = 'initializationError'
        '8'  = 'deviceInitiatedReset'
        '9'  = 'callManagerReset'
        '10' = 'authenticationError'
        '11' = 'invalidX509NameInCertificate'
        '12' = 'invalidTLSCipher'
        '13' = 'directoryNumberMismatch'
        '14' = 'malformedRegisterMsg'
        '15' = 'protocolMismatch'
        '16' = 'deviceNotActive'
        '17' = 'registrationSequenceError'
        '18' = 'keepAliveTimeout'
        '19' = 'configurationMismatch'
        '20' = 'callManagerRestart'
        '21' = 'duplicateRegistration'
        '22' = 'callManagerApplyConfig'
        '23' = 'deviceNoResponse'
        '24' = 'emsLoginFailed'
        '25' = 'phoneDnDEnabled'
        '26' = 'sourceIPAddrChanged'
        '27' = 'sourcePortChanged'
        '28' = 'registrationRevoked'
        '29' = 'fallbackInitiated'
        '30' = 'fallbackCompleted'
    }

    # ccmPhoneType (column 3) — common Cisco phone type enum values
    $phoneTypeMap = @{
        '1'   = 'otherPhone'
        '2'   = 'cisco30SPPhone'
        '3'   = 'cisco12SPPhone'
        '4'   = 'cisco12SPPlusPhone'
        '5'   = 'cisco12SPhone'
        '6'   = 'cisco30VIPPhone'
        '30'  = 'cisco7960'
        '31'  = 'cisco7940'
        '61'  = 'cisco7935'
        '72'  = 'cisco7902'
        '73'  = 'cisco7912'
        '100' = 'cisco7961'
        '104' = 'ciscoSoftPhone'
        '115' = 'cisco6921'
        '119' = 'cisco7936'
        '124' = 'cisco7911'
        '148' = 'cisco7931'
        '165' = 'cisco7921'
        '196' = 'cisco8961'
        '254' = 'cisco7925'
        '255' = 'cisco7937'
        '279' = 'cisco6961'
        '282' = 'cisco6945'
        '284' = 'cisco6941'
        '285' = 'cisco6911'
        '302' = 'cisco7841'
        '307' = 'cisco7821'
        '308' = 'cisco7861'
        '334' = 'ciscoJabber'
        '335' = 'cisco8845'
        '336' = 'cisco8865'
        '365' = 'cisco7832'
        '562' = 'cisco8832'
        '621' = 'ciscoWebexDesk'
        '683' = 'ciscoDeskPhone9800'
    }

    $columns = Get-CUCMPhoneColumnMap
    $tableBaseOid = '1.3.6.1.4.1.9.9.156.1.2.1.1'

    # Build reverse lookup: column number string -> attribute name
    $columnToName = @{}
    foreach ($attrName in $columns.Keys) {
        $colNum = $columns[$attrName].Substring($tableBaseOid.Length + 1)
        $columnToName[$colNum] = $attrName
    }

    $phoneRows = [System.Collections.ArrayList]@()

    Write-Host " Initiating SNMP walk: $TargetAddress (v$snmpVersion, port $snmpPort, timeout ${timeoutMs}ms)" -ForegroundColor Cyan

    # ---- Phase 1: Walk ccmPhoneEntry -- SharpSnmpLib, with WUG COM API fallback ----
    $walkData   = $null
    $walkMethod = 'SharpSnmpLib'
    try {
        Write-Verbose "[SNMP] Using SharpSnmpLib for CUCM table walk"
        Write-Verbose "[SNMP] Target=$TargetAddress Version=$snmpVersion Port=$snmpPort Timeout=${timeoutMs}ms"
        if ($snmpVersion -eq 3) {
            Write-Verbose "[SNMP] v3 params: Username=$username AuthProto=$authProtocol PrivProto=$privacyProtocol"
        }

        Write-Host " Bulk walking ccmPhoneEntry OID tree (SharpSnmpLib)..." -ForegroundColor DarkGray

        $walkParams = @{
            Target  = $TargetAddress
            Table   = $tableBaseOid
            Port    = $snmpPort
            Timeout = $timeoutMs
        }

        if ($snmpVersion -in @(1, 2)) {
            $walkParams['Version']   = "V$snmpVersion"
            $walkParams['Community'] = $community
            $walkResult = Invoke-SNMPBulkWalk @walkParams -ErrorAction Stop
        }
        else {
            $walkParams['Version']      = 'V3'
            $walkParams['Username']     = $username
            $walkParams['AuthProtocol'] = $authProtocol
            $walkParams['PrivProtocol'] = $privacyProtocol

            if ($authPassword -is [string] -and -not [string]::IsNullOrWhiteSpace($authPassword)) {
                $walkParams['AuthPassword'] = ConvertTo-SecureString -String $authPassword -AsPlainText -Force
            }
            elseif ($authPassword -is [System.Security.SecureString]) {
                $walkParams['AuthPassword'] = $authPassword
            }

            if ($privacyPassword -is [string] -and -not [string]::IsNullOrWhiteSpace($privacyPassword)) {
                $walkParams['PrivPassword'] = ConvertTo-SecureString -String $privacyPassword -AsPlainText -Force
            }
            elseif ($privacyPassword -is [System.Security.SecureString]) {
                $walkParams['PrivPassword'] = $privacyPassword
            }

            $walkResult = Invoke-SNMPBulkWalkFriendly @walkParams -ErrorAction Stop
        }

        Write-Verbose "[SNMP] Bulk walk completed. Variables retrieved: $($walkResult.Variables.Count)"
        Write-Host " Walk response: $($walkResult.Variables.Count) variables -- processing..." -ForegroundColor DarkGray

        # Normalise into walkData (instance -> colNum -> value)
        $walkData  = [ordered]@{}
        $walkCount = 0

        foreach ($var in $walkResult.Variables) {
            $currentOid = $var.Id.ToString().TrimStart('.')
            if (-not $currentOid.StartsWith("$tableBaseOid.")) { continue }

            $suffix = $currentOid.Substring($tableBaseOid.Length + 1)
            $dotPos = $suffix.IndexOf('.')
            if ($dotPos -lt 0) { continue }

            $colNum   = $suffix.Substring(0, $dotPos)
            $instance = $suffix.Substring($dotPos + 1)

            # Decode binary OctetString columns using shared decoder
            if ($var.Data -is [Lextm.SharpSnmpLib.OctetString]) {
                $decodeType = $script:CUCMColumnDecodeType[$colNum]
                if ($decodeType) {
                    $raw     = $var.Data.GetRaw()
                    $decoded = ConvertFrom-CUCMSnmpOctetString -Bytes $raw -ColumnType $decodeType
                    $value   = if ($null -ne $decoded) { $decoded } else { [string]$var.Data }
                }
                else {
                    $value = [string]$var.Data
                }
            }
            else {
                $value = [string]$var.Data
            }

            if (-not $walkData.Contains($instance)) { $walkData[$instance] = @{} }
            $walkData[$instance][$colNum] = $value

            $walkCount++
            if ($walkCount -le 3 -or $walkCount % 500 -eq 0) {
                Write-Verbose "[SNMP] Walk progress: $walkCount OIDs (col=$colNum instance=$instance)"
            }
        }

        Write-Host " SharpSnmpLib walk done: $walkCount OIDs, $($walkData.Count) phone instance(s)" -ForegroundColor Green
        Write-Verbose "[SNMP] Walk finished. OIDs processed: $walkCount Unique instances: $($walkData.Count)"
    }
    catch {
        # end-of-MIB-view produces an empty-bytes error from SharpSnmpLib — this is normal
        # SNMP termination behavior, not a real failure. If we already have data, use it.
        $isEndOfMib = ($_.Exception.Message -like '*empty array*' -or
                       $_.Exception.Message -like '*endOfMibView*' -or
                       $_.Exception.Message -like '*NoSuchObject*')

        if ($isEndOfMib -and $null -ne $walkData -and $walkData.Count -gt 0) {
            Write-Verbose "[SNMP] End-of-MIB signal received after collecting $($walkData.Count) instance(s) -- treating as success."
        }
        else {
            Write-Warning "SharpSnmpLib walk failed for ${TargetAddress}: $_ -- trying WUG COM API fallback"
            Write-Verbose "[SNMP] SharpSnmpLib error: $($_.Exception.GetType().FullName): $($_.Exception.Message)"
            $walkData = $null
            try {
                $walkData   = Invoke-CUCMPhoneWalkComApi -TargetAddress $TargetAddress -Credential $Credential
                $walkMethod = 'WUG COM API'
            }
            catch {
                Write-Warning "COM API fallback also failed for ${TargetAddress}: $_"
                Write-Verbose "[COM] Exception: $($_.Exception.GetType().FullName): $($_.Exception.Message)"
                Write-Verbose "[COM] Stack: $($_.ScriptStackTrace)"
            }
        }
    }

    # ---- Phase 2: Assemble phone rows (runs regardless of which walk method succeeded) ----
    if ($null -ne $walkData -and $walkData.Count -gt 0) {
        Write-Host " Assembling phone rows from $($walkData.Count) instance(s) [$walkMethod]..." -ForegroundColor DarkGray

        $rowCount = 0
        foreach ($instance in $walkData.Keys) {
            $instanceData = $walkData[$instance]
            $attributes   = [ordered]@{
                'DiscoveryHelper.CUCM' = 'true'
                'CUCM.CallManager'     = $DeviceName
                'CUCM.Target'          = $TargetAddress
                'CUCM.PhoneIndex'      = $instance
            }

            foreach ($colNum in $instanceData.Keys) {
                if (-not $columnToName.ContainsKey($colNum)) { continue }
                $attrName = $columnToName[$colNum]
                if ($attrName -eq 'CUCM.PhoneIndex') { continue }
                $value = $instanceData[$colNum]

                switch ($attrName) {
                    'CUCM.PhoneStatus'             { $value = ConvertFrom-CUCMEnumValue -Value $value -Map $statusMap }
                    'CUCM.PhoneType'               { $value = ConvertFrom-CUCMEnumValue -Value $value -Map $phoneTypeMap }
                    'CUCM.PhoneProtocol'           { $value = ConvertFrom-CUCMEnumValue -Value $value -Map $protocolMap }
                    'CUCM.PhoneIPv4Attribute'      { $value = ConvertFrom-CUCMEnumValue -Value $value -Map $addressScopeMap }
                    'CUCM.PhoneIPv6Attribute'      { $value = ConvertFrom-CUCMEnumValue -Value $value -Map $addressScopeMap }
                    'CUCM.PhoneStatusReason'       { $value = ConvertFrom-CUCMEnumValue -Value $value -Map $causeCodeMap }
                    'CUCM.PhoneUnregReason'        { $value = ConvertFrom-CUCMEnumValue -Value $value -Map $causeCodeMap }
                    'CUCM.PhoneRegFailReason'      { $value = ConvertFrom-CUCMEnumValue -Value $value -Map $causeCodeMap }
                    'CUCM.PhoneTimeLastRegistered' { $value = ConvertFrom-CUCMDateValue -Value $value }
                    'CUCM.PhoneTimeLastError'      { $value = ConvertFrom-CUCMDateValue -Value $value }
                    'CUCM.PhoneTimeLastStatusUpdt' { $value = ConvertFrom-CUCMDateValue -Value $value }
                }

                $attributes[$attrName] = $value
            }

            $phoneName        = [string]$attributes['CUCM.PhoneName']
            $phoneDescription = [string]$attributes['CUCM.PhoneDescription']
            if ([string]::IsNullOrWhiteSpace($phoneName)) {
                $phoneName = "Phone-$instance"
                $attributes['CUCM.PhoneName'] = $phoneName
            }

            $displayName = $phoneName
            if (-not [string]::IsNullOrWhiteSpace($phoneDescription)) {
                $displayName = "$phoneName ($phoneDescription)"
            }

            $statusName = [string]$attributes['CUCM.PhoneStatus']
            if ([string]::IsNullOrWhiteSpace($statusName)) {
                $statusName = 'unknown'
                $attributes['CUCM.PhoneStatus'] = $statusName
            }

            [void]$phoneRows.Add([PSCustomObject]@{
                DeviceName  = $DeviceName
                Target      = $TargetAddress
                PhoneIndex  = $instance
                PhoneName   = $phoneName
                Description = $phoneDescription
                DisplayName = $displayName
                Status      = $statusName
                Attributes  = $attributes
            })

            $rowCount++
            if ($rowCount % 50 -eq 0) {
                Write-Host " $rowCount phones assembled..." -ForegroundColor DarkGray
            }
            if ($rowCount -le 3 -or $rowCount % 100 -eq 0) {
                Write-Verbose "[SNMP] Row $rowCount : $phoneName Status=$statusName IP=$($attributes['CUCM.PhoneIpAddress'])"
            }
        }

        $phoneColor = if ($rowCount -gt 0) { 'Green' } else { 'Yellow' }
        Write-Host " Done: $rowCount phone row(s) assembled" -ForegroundColor $phoneColor
        Write-Verbose "[SNMP] Total phone rows built: $rowCount"
    }
    else {
        Write-Warning "No phone data returned from SNMP walk for ${TargetAddress}"
    }

    return @($phoneRows)
}

function New-CUCMDiscoveryPlanFromPhoneInventory {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [int]$DeviceId,

        [Parameter(Mandatory = $true)]
        [string]$DeviceName,

        [Parameter(Mandatory = $true)]
        [string]$TargetAddress,

        [Parameter(Mandatory = $true)]
        [object[]]$PhoneRows
    )

    if (-not $PhoneRows -or $PhoneRows.Count -eq 0) {
        return @()
    }

    $columns = Get-CUCMPhoneColumnMap
    $counts = Get-CUCMPhoneStatusCounts -PhoneRows $PhoneRows
    $attributes = [ordered]@{
        'DiscoveryHelper.CUCM' = 'true'
        'CUCM.Target' = $TargetAddress
        'CUCM.PhoneCount' = [string]$counts.total
        'CUCM.PhoneRegisteredCount' = [string]$counts.registered
        'CUCM.PhoneUnregisteredCount' = [string]$counts.unregistered
        'CUCM.PhoneRejectedCount' = [string]$counts.rejected
        'CUCM.PhonePartiallyRegisteredCount' = [string]$counts.partiallyregistered
        'CUCM.PhoneUnknownCount' = [string]$counts.unknown
        'CUCM.PhoneStatusSummary' = "registered=$($counts.registered); unregistered=$($counts.unregistered); rejected=$($counts.rejected); partiallyregistered=$($counts.partiallyregistered); unknown=$($counts.unknown)"
        'CUCM.InventoryTimestamp' = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
    }

    $monitorParams = @{
        SnmpTableDiscOID = $columns['CUCM.PhoneIndex']
        SnmpTableDiscOperator = 'gt'
        SnmpTableDiscValue = '0'
        SnmpTableDiscCommentOID = $columns['CUCM.PhoneName']
        SnmpTableDiscIndexOID = $columns['CUCM.PhoneIndex']
        SnmpTableDiscCreates = 'true'
        SnmpTableMonitoredOID = $columns['CUCM.PhoneStatus']
        SnmpTableMonitorOperator = 'constant'
        SnmpTableMonitoredValue = '2'
        SnmpTableMonitorUpIfMatch = 'upifmatch'
    }

    $item = New-DiscoveredItem `
        -Name "CUCM Phone Registration Status [$DeviceName]" `
        -ItemType 'ActiveMonitor' `
        -MonitorType 'SNMPTable' `
        -MonitorParams $monitorParams `
        -UniqueKey "CUCM:$TargetAddress:monitor:registration" `
        -DeviceId $DeviceId `
        -Attributes $attributes `
        -Tags @('cucm', 'snmp', 'phone-registration')

    return @($item)
}

Register-DiscoveryProvider -Name 'CUCM' `
    -MatchAttribute 'DiscoveryHelper.CUCM' `
    -AuthType 'BasicAuth' `
    -DefaultPort 161 `
    -DefaultProtocol 'snmp' `
    -IgnoreCertErrors $false `
    -DiscoverScript {
        param($ctx)

        $credential = $ctx.Credential
        if (-not $credential) {
            Write-Warning 'CUCM: No SNMP settings supplied.'
            return @()
        }

        $targetAddress = if ($credential.Address) { [string]$credential.Address } else { [string]$ctx.DeviceIP }
        if ([string]::IsNullOrWhiteSpace($targetAddress)) {
            Write-Warning 'CUCM: No target address supplied.'
            return @()
        }

        $phoneRows = Get-CUCMPhoneInventory -DeviceName $ctx.DeviceName -TargetAddress $targetAddress -Credential $credential
        return (New-CUCMDiscoveryPlanFromPhoneInventory -DeviceId $ctx.DeviceId -DeviceName $ctx.DeviceName -TargetAddress $targetAddress -PhoneRows $phoneRows)
    }

# ---- END OF SCRIPT (do not remove this line or the closing braces abov
# SIG # Begin signature block
# MIIr+wYJKoZIhvcNAQcCoIIr7DCCK+gCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCD+Dhg3eR69DnKY
# remEnq1txNtsIoOemstOtOJkfUg5saCCJQ0wggVvMIIEV6ADAgECAhBI/JO0YFWU
# jTanyYqJ1pQWMA0GCSqGSIb3DQEBDAUAMHsxCzAJBgNVBAYTAkdCMRswGQYDVQQI
# DBJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAOBgNVBAcMB1NhbGZvcmQxGjAYBgNVBAoM
# EUNvbW9kbyBDQSBMaW1pdGVkMSEwHwYDVQQDDBhBQUEgQ2VydGlmaWNhdGUgU2Vy
# dmljZXMwHhcNMjEwNTI1MDAwMDAwWhcNMjgxMjMxMjM1OTU5WjBWMQswCQYDVQQG
# EwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMS0wKwYDVQQDEyRTZWN0aWdv
# IFB1YmxpYyBDb2RlIFNpZ25pbmcgUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEBAQUA
# A4ICDwAwggIKAoICAQCN55QSIgQkdC7/FiMCkoq2rjaFrEfUI5ErPtx94jGgUW+s
# hJHjUoq14pbe0IdjJImK/+8Skzt9u7aKvb0Ffyeba2XTpQxpsbxJOZrxbW6q5KCD
# J9qaDStQ6Utbs7hkNqR+Sj2pcaths3OzPAsM79szV+W+NDfjlxtd/R8SPYIDdub7
# P2bSlDFp+m2zNKzBenjcklDyZMeqLQSrw2rq4C+np9xu1+j/2iGrQL+57g2extme
# me/G3h+pDHazJyCh1rr9gOcB0u/rgimVcI3/uxXP/tEPNqIuTzKQdEZrRzUTdwUz
# T2MuuC3hv2WnBGsY2HH6zAjybYmZELGt2z4s5KoYsMYHAXVn3m3pY2MeNn9pib6q
# RT5uWl+PoVvLnTCGMOgDs0DGDQ84zWeoU4j6uDBl+m/H5x2xg3RpPqzEaDux5mcz
# mrYI4IAFSEDu9oJkRqj1c7AGlfJsZZ+/VVscnFcax3hGfHCqlBuCF6yH6bbJDoEc
# QNYWFyn8XJwYK+pF9e+91WdPKF4F7pBMeufG9ND8+s0+MkYTIDaKBOq3qgdGnA2T
# OglmmVhcKaO5DKYwODzQRjY1fJy67sPV+Qp2+n4FG0DKkjXp1XrRtX8ArqmQqsV/
# AZwQsRb8zG4Y3G9i/qZQp7h7uJ0VP/4gDHXIIloTlRmQAOka1cKG8eOO7F/05QID
# AQABo4IBEjCCAQ4wHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQwHQYD
# VR0OBBYEFDLrkpr/NZZILyhAQnAgNpFcF4XmMA4GA1UdDwEB/wQEAwIBhjAPBgNV
# HRMBAf8EBTADAQH/MBMGA1UdJQQMMAoGCCsGAQUFBwMDMBsGA1UdIAQUMBIwBgYE
# VR0gADAIBgZngQwBBAEwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21v
# ZG9jYS5jb20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEE
# KDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZI
# hvcNAQEMBQADggEBABK/oe+LdJqYRLhpRrWrJAoMpIpnuDqBv0WKfVIHqI0fTiGF
# OaNrXi0ghr8QuK55O1PNtPvYRL4G2VxjZ9RAFodEhnIq1jIV9RKDwvnhXRFAZ/ZC
# J3LFI+ICOBpMIOLbAffNRk8monxmwFE2tokCVMf8WPtsAO7+mKYulaEMUykfb9gZ
# pk+e96wJ6l2CxouvgKe9gUhShDHaMuwV5KZMPWw5c9QLhTkg4IUaaOGnSDip0TYl
# d8GNGRbFiExmfS9jzpjoad+sPKhdnckcW67Y8y90z7h+9teDnRGWYpquRRPaf9xH
# +9/DUp/mBlXpnYzyOmJRvOwkDynUWICE5EV7WtgwggWNMIIEdaADAgECAhAOmxiO
# +dAt5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYD
# VQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAi
# BgNVBAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAw
# MDBaFw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdp
# Q2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERp
# Z2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC
# AgoCggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsb
# hA3EMB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iT
# cMKyunWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGb
# NOsFxl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclP
# XuU15zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCr
# VYJBMtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFP
# ObURWBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTv
# kpI6nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWM
# cCxBYKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls
# 5Q5SUUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBR
# a2+xq4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6
# MIIBNjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qY
# rhwPTzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8E
# BAMCAYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5k
# aWdpY2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0
# LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDig
# NoY0aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9v
# dENBLmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCg
# v0NcVec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQT
# SnovLbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh
# 65ZyoUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSw
# uKFWjuyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAO
# QGPFmCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjD
# TZ9ztwGpn1eqXijiuZQwggYaMIIEAqADAgECAhBiHW0MUgGeO5B5FSCJIRwKMA0G
# CSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExp
# bWl0ZWQxLTArBgNVBAMTJFNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBSb290
# IFI0NjAeFw0yMTAzMjIwMDAwMDBaFw0zNjAzMjEyMzU5NTlaMFQxCzAJBgNVBAYT
# AkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNlY3RpZ28g
# UHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwggGiMA0GCSqGSIb3DQEBAQUAA4IB
# jwAwggGKAoIBgQCbK51T+jU/jmAGQ2rAz/V/9shTUxjIztNsfvxYB5UXeWUzCxEe
# AEZGbEN4QMgCsJLZUKhWThj/yPqy0iSZhXkZ6Pg2A2NVDgFigOMYzB2OKhdqfWGV
# oYW3haT29PSTahYkwmMv0b/83nbeECbiMXhSOtbam+/36F09fy1tsB8je/RV0mIk
# 8XL/tfCK6cPuYHE215wzrK0h1SWHTxPbPuYkRdkP05ZwmRmTnAO5/arnY83jeNzh
# P06ShdnRqtZlV59+8yv+KIhE5ILMqgOZYAENHNX9SJDm+qxp4VqpB3MV/h53yl41
# aHU5pledi9lCBbH9JeIkNFICiVHNkRmq4TpxtwfvjsUedyz8rNyfQJy/aOs5b4s+
# ac7IH60B+Ja7TVM+EKv1WuTGwcLmoU3FpOFMbmPj8pz44MPZ1f9+YEQIQty/NQd/
# 2yGgW+ufflcZ/ZE9o1M7a5Jnqf2i2/uMSWymR8r2oQBMdlyh2n5HirY4jKnFH/9g
# Rvd+QOfdRrJZb1sCAwEAAaOCAWQwggFgMB8GA1UdIwQYMBaAFDLrkpr/NZZILyhA
# QnAgNpFcF4XmMB0GA1UdDgQWBBQPKssghyi47G9IritUpimqF6TNDDAOBgNVHQ8B
# Af8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADATBgNVHSUEDDAKBggrBgEFBQcD
# AzAbBgNVHSAEFDASMAYGBFUdIAAwCAYGZ4EMAQQBMEsGA1UdHwREMEIwQKA+oDyG
# Omh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVTaWduaW5n
# Um9vdFI0Ni5jcmwwewYIKwYBBQUHAQEEbzBtMEYGCCsGAQUFBzAChjpodHRwOi8v
# Y3J0LnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNDb2RlU2lnbmluZ1Jvb3RSNDYu
# cDdjMCMGCCsGAQUFBzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTANBgkqhkiG
# 9w0BAQwFAAOCAgEABv+C4XdjNm57oRUgmxP/BP6YdURhw1aVcdGRP4Wh60BAscjW
# 4HL9hcpkOTz5jUug2oeunbYAowbFC2AKK+cMcXIBD0ZdOaWTsyNyBBsMLHqafvIh
# rCymlaS98+QpoBCyKppP0OcxYEdU0hpsaqBBIZOtBajjcw5+w/KeFvPYfLF/ldYp
# mlG+vd0xqlqd099iChnyIMvY5HexjO2AmtsbpVn0OhNcWbWDRF/3sBp6fWXhz7Dc
# ML4iTAWS+MVXeNLj1lJziVKEoroGs9Mlizg0bUMbOalOhOfCipnx8CaLZeVme5yE
# Lg09Jlo8BMe80jO37PU8ejfkP9/uPak7VLwELKxAMcJszkyeiaerlphwoKx1uHRz
# NyE6bxuSKcutisqmKL5OTunAvtONEoteSiabkPVSZ2z76mKnzAfZxCl/3dq3dUNw
# 4rg3sTCggkHSRqTqlLMS7gjrhTqBmzu1L90Y1KWN/Y5JKdGvspbOrTfOXyXvmPL6
# E52z1NZJ6ctuMFBQZH3pwWvqURR8AgQdULUvrxjUYbHHj95Ejza63zdrEcxWLDX6
# xWls/GDnVNueKjWUH3fTv1Y8Wdho698YADR7TNx8X8z2Bev6SivBBOHY+uqiirZt
# g0y9ShQoPzmCcn63Syatatvx157YK9hlcPmVoa1oDE5/L9Uo2bC5a4CH2RwwggY+
# MIIEpqADAgECAhAHnODk0RR/hc05c892LTfrMA0GCSqGSIb3DQEBDAUAMFQxCzAJ
# BgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNl
# Y3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwHhcNMjYwMjA5MDAwMDAw
# WhcNMjkwNDIxMjM1OTU5WjBVMQswCQYDVQQGEwJVUzEUMBIGA1UECAwLQ29ubmVj
# dGljdXQxFzAVBgNVBAoMDkphc29uIEFsYmVyaW5vMRcwFQYDVQQDDA5KYXNvbiBB
# bGJlcmlubzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAPN6aN4B1yYW
# kI5b5TBj3I0VV/peETrHb6EY4BHGxt8Ap+eT+WpEpJyEtRYPxEmNJL3A38Bkg7mw
# zPE3/1NK570ZBCuBjSAn4mSDIgIuXZnvyBO9W1OQs5d67MlJLUAEufl18tOr3ST1
# DeO9gSjQSAE5Nql0QDxPnm93OZBon+Fz3CmE+z3MwAe2h4KdtRAnCqwM+/V7iBdb
# w+JOxolpx+7RVjGyProTENIG3pe/hKvPb501lf8uBAADLdjZr5ip8vIWbf857Yw1
# Bu10nVI7HW3eE8Cl5//d1ribHlzTzQLfttW+k+DaFsKZBBL56l4YAlIVRsrOiE1k
# dHYYx6IGrEA809R7+TZA9DzGqyFiv9qmJAbL4fDwetDeyIq+Oztz1LvEdy8Rcd0J
# BY+J4S0eDEFIA3X0N8VcLeAwabKb9AjulKXwUeqCJLvN79CJ90UTZb2+I+tamj0d
# n+IKMEsJ4v4Ggx72sxFr9+6XziodtTg5Luf2xd6+PhhamOxF2px9LObhBLLEMyRs
# CHZIzVZOFKu9BpHQH7ufGB+Sa80Tli0/6LEyn9+bMYWi2ttn6lLOPThXMiQaooRU
# q6q2u3+F4SaPlxVFLI7OJVMhar6nW6joBvELTJPmANSMjDSRFDfHRCdGbZsL/keE
# LJNy+jZctF6VvxQEjFM8/bazu6qYhrA7AgMBAAGjggGJMIIBhTAfBgNVHSMEGDAW
# gBQPKssghyi47G9IritUpimqF6TNDDAdBgNVHQ4EFgQU6YF0o0D5AVhKHbVocr8G
# aSIBibAwDgYDVR0PAQH/BAQDAgeAMAwGA1UdEwEB/wQCMAAwEwYDVR0lBAwwCgYI
# KwYBBQUHAwMwSgYDVR0gBEMwQTA1BgwrBgEEAbIxAQIBAwIwJTAjBggrBgEFBQcC
# ARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYGZ4EMAQQBMEkGA1UdHwRCMEAw
# PqA8oDqGOGh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVT
# aWduaW5nQ0FSMzYuY3JsMHkGCCsGAQUFBwEBBG0wazBEBggrBgEFBQcwAoY4aHR0
# cDovL2NydC5zZWN0aWdvLmNvbS9TZWN0aWdvUHVibGljQ29kZVNpZ25pbmdDQVIz
# Ni5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLnNlY3RpZ28uY29tMA0GCSqG
# SIb3DQEBDAUAA4IBgQAEIsm4xnOd/tZMVrKwi3doAXvCwOA/RYQnFJD7R/bSQRu3
# wXEK4o9SIefye18B/q4fhBkhNAJuEvTQAGfqbbpxow03J5PrDTp1WPCWbXKX8Oz9
# vGWJFyJxRGftkdzZ57JE00synEMS8XCwLO9P32MyR9Z9URrpiLPJ9rQjfHMb1BUd
# vaNayomm7aWLAnD+X7jm6o8sNT5An1cwEAob7obWDM6sX93wphwJNBJAstH9Ozs6
# LwISOX6sKS7CKm9N3Kp8hOUue0ZHAtZdFl6o5u12wy+zzieGEI50fKnN77FfNKFO
# WKlS6OJwlArcbFegB5K89LcE5iNSmaM3VMB2ADV1FEcjGSHw4lTg1Wx+WMAMdl/7
# nbvfFxJ9uu5tNiT54B0s+lZO/HztwXYQUczdsFon3pjsNrsk9ZlalBi5SHkIu+F6
# g7tWiEv3rtVApmJRnLkUr2Xq2a4nbslUCt4jKs5UX4V1nSX8OM++AXoyVGO+iTj7
# z+pl6XE9Gw/Td6WKKKswgga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0G
# CSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ
# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0
# IFRydXN0ZWQgUm9vdCBHNDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTla
# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE
# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEy
# NTYgMjAyNSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHT
# CphBcr48RsAcrHXbo0ZodLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPh
# of6pvF4uGjwjqNjfEvUi6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mA
# xAHeHYNnQxqXmRinvuNgxVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBv
# MgEdgkFiDNYiOTx4OtiFcMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps
# 0wjUjsZvkgFkriK9tUKJm/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF
# 83bRVFLeGkuAhHiGPMvSGmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXi
# UOeSLsJygoLPp66bkDX1ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOM
# CZIVNSaz7BX8VtYGqLt9MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydP
# pOjL6s36czwzsucuoKs7Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrU
# G2ZdSoQbU2rMkpLiQ6bGRinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+
# sdFUeEY0qVjPKOWug/G6X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0T
# AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYD
# VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG
# A1UdJQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY
# aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj
# ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV
# HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU
# cnVzdGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1s
# BwEwDQYJKoZIhvcNAQELBQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WI
# GjB/T8ObXAZz8OjuhUxjaaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+
# IQhQE7jU/kXjjytJgnn0hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8M
# yb9rEVKChHyfpzee5kH0F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2
# th9y1IsA0QF8dTXqvcnTmpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjaj
# V/gxdEkMx1NKU4uHQcKfZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2
# Lr3ty9qIijanrUR3anzEwlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFze
# GxcytL5TTLL4ZaoBdqbhOhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG
# 7uEBYTptMSbhdhGQDpOXgpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+N
# Jpud/v4+7RWsWCiKi9EOLLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckT
# etiSuEtQvLsNz3Qbp7wGWqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszW
# kPZPubdcMIIG7TCCBNWgAwIBAgIQCoDvGEuN8QWC0cR2p5V0aDANBgkqhkiG9w0B
# AQsFADBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/
# BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYg
# U0hBMjU2IDIwMjUgQ0ExMB4XDTI1MDYwNDAwMDAwMFoXDTM2MDkwMzIzNTk1OVow
# YzELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQD
# EzJEaWdpQ2VydCBTSEEyNTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIw
# MjUgMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANBGrC0Sxp7Q6q5g
# VrMrV7pvUf+GcAoB38o3zBlCMGMyqJnfFNZx+wvA69HFTBdwbHwBSOeLpvPnZ8ZN
# +vo8dE2/pPvOx/Vj8TchTySA2R4QKpVD7dvNZh6wW2R6kSu9RJt/4QhguSssp3qo
# me7MrxVyfQO9sMx6ZAWjFDYOzDi8SOhPUWlLnh00Cll8pjrUcCV3K3E0zz09ldQ/
# /nBZZREr4h/GI6Dxb2UoyrN0ijtUDVHRXdmncOOMA3CoB/iUSROUINDT98oksouT
# MYFOnHoRh6+86Ltc5zjPKHW5KqCvpSduSwhwUmotuQhcg9tw2YD3w6ySSSu+3qU8
# DD+nigNJFmt6LAHvH3KSuNLoZLc1Hf2JNMVL4Q1OpbybpMe46YceNA0LfNsnqcnp
# JeItK/DhKbPxTTuGoX7wJNdoRORVbPR1VVnDuSeHVZlc4seAO+6d2sC26/PQPdP5
# 1ho1zBp+xUIZkpSFA8vWdoUoHLWnqWU3dCCyFG1roSrgHjSHlq8xymLnjCbSLZ49
# kPmk8iyyizNDIXj//cOgrY7rlRyTlaCCfw7aSUROwnu7zER6EaJ+AliL7ojTdS5P
# WPsWeupWs7NpChUk555K096V1hE0yZIXe+giAwW00aHzrDchIc2bQhpp0IoKRR7Y
# ufAkprxMiXAJQ1XCmnCfgPf8+3mnAgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAA
# MB0GA1UdDgQWBBTkO/zyMe39/dfzkXFjGVBDz2GM6DAfBgNVHSMEGDAWgBTvb1NK
# 6eQGfHrK4pBW9i/USezLTjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYI
# KwYBBQUHAwgwgZUGCCsGAQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8v
# b2NzcC5kaWdpY2VydC5jb20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRp
# Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZT
# SEEyNTYyMDI1Q0ExLmNydDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5k
# aWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2
# U0hBMjU2MjAyNUNBMS5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9
# bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQBlKq3xHCcEua5gQezRCESeY0ByIfjk9iJP
# 2zWLpQq1b4URGnwWBdEZD9gBq9fNaNmFj6Eh8/YmRDfxT7C0k8FUFqNh+tshgb4O
# 6Lgjg8K8elC4+oWCqnU/ML9lFfim8/9yJmZSe2F8AQ/UdKFOtj7YMTmqPO9mzskg
# iC3QYIUP2S3HQvHG1FDu+WUqW4daIqToXFE/JQ/EABgfZXLWU0ziTN6R3ygQBHMU
# BaB5bdrPbF6MRYs03h4obEMnxYOX8VBRKe1uNnzQVTeLni2nHkX/QqvXnNb+YkDF
# kxUGtMTaiLR9wjxUxu2hECZpqyU1d0IbX6Wq8/gVutDojBIFeRlqAcuEVT0cKsb+
# zJNEsuEB7O7/cuvTQasnM9AWcIQfVjnzrvwiCZ85EE8LUkqRhoS3Y50OHgaY7T/l
# wd6UArb+BOVAkg2oOvol/DJgddJ35XTxfUlQ+8Hggt8l2Yv7roancJIFcbojBcxl
# RcGG0LIhp6GvReQGgMgYxQbV1S3CrWqZzBt1R9xJgKf47CdxVRd/ndUlQ05oxYy2
# zRWVFjF7mcr4C34Mj3ocCVccAvlKV9jEnstrniLvUxxVZE/rptb7IRE2lskKPIJg
# baP5t2nGj/ULLi49xTcBZU8atufk+EMF/cWuiC7POGT75qaL6vdCvHlshtjdNXOC
# IUjsarfNZzGCBkQwggZAAgEBMGgwVDELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1Nl
# Y3RpZ28gTGltaXRlZDErMCkGA1UEAxMiU2VjdGlnbyBQdWJsaWMgQ29kZSBTaWdu
# aW5nIENBIFIzNgIQB5zg5NEUf4XNOXPPdi036zANBglghkgBZQMEAgEFAKCBhDAY
# BgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3
# AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEi
# BCCPGHT+DVefFAL8SznE1Q1BpauokrGGZvBSL3pcj+pbyDANBgkqhkiG9w0BAQEF
# AASCAgBk41d507JSrFjU4uqTbOOcFJEGycXUj75SAFbWdMdDAGhFTCK1H10qLAzD
# QUqDZUyFrkjinIBkdNtxY5bGiKtJXYP2oC0avEG7gwwE4/TSN8hfa4mcpQaw+S0z
# mlvZ0Kyg/X57O4mwX8Q2vxBI/BWSMMSE+fpbO+VmkS9FSFYV2JrJGPRmwe9/hFYN
# 3Ka49MfaQkjqHRMtRx88/KAJ5rFtxkKdMnP878JjEg/XXS90Ipz1uXxNjkMtfKpq
# 4ibhpkX1o4HNSlqN2ebIqaOxvrgDpVRO05Wx4K/Oa279z8mVuoJPMpOTkDDCDIvf
# ichPbAVoIG2JqYxuY5wTPOWb+SLuDYpe3/PtpFTsUNSj8YmKC5xhyB0LNDS6xs4e
# 4fm6nvWvN9h0qvnD0bE/wxKhm2jvE/juLH9RoPz5Y6BbPSdUflpT2TkVglMaLXmG
# YIBOnLHa1mk55QHf9iVipELAv1/RvES5YiOxrXTy9bJAxYp7qfvypMY1rG6WFTN8
# NW0lK3xwpW95oyenfbg6eeIjfIX0lxmWukBVueO/R0o2cm4Eso9xsnVJiicsMIoD
# zeTwNhsQfkpdOkdI9tzC2xR9b3XziohfXAowb3WqOk8gzL0yFBpFTYFy16rU7GU1
# 2/cuGCV6p2jK25lO8+m05aq7O3VyTat2MSls4VMVJFLcEDpqUKGCAyYwggMiBgkq
# hkiG9w0BCQYxggMTMIIDDwIBATB9MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5E
# aWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1l
# U3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTECEAqA7xhLjfEFgtHEdqeV
# dGgwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwG
# CSqGSIb3DQEJBTEPFw0yNjA3MDcxMTU2NDVaMC8GCSqGSIb3DQEJBDEiBCCyW23N
# P9KU9LmixbVhu19htNsh9YEg/CV3qiSFyIKIlDANBgkqhkiG9w0BAQEFAASCAgBO
# 0Tz90yGADjFwOsQw/q0NH8lo9u6jp4c5m4A3AMunFfP8JOd9IyF/W8Y4dYXvXxja
# 9EauzjWAOUxYaLcw2CFPAqKRDyU8jnON3YWyZIFr/c/TM/J+0luoegOBr/ZvO56T
# YKTgz8hYrGpX4gb7+llLjaK3HhnDGTnyHLr/EYZbjiB++QdqPiOJ970bsQpQ/kIU
# Z+UR4p9C9EcvXF5kO95/nB639Jry5G65BvTfOLs/s4B3c9tyV30h3ltwPg9UvYjg
# 778LJ2s0RKzISzKMHgm+m0r/q++aM0W690LzsvLCTJ3G0YKdBBAw+LZGOslZqOV3
# AAuL56GMH+UxUf9S7ekfq+bQGkyf2exdUVoCRli5Gupi3EgklJyiIu7Ufrp017tN
# HVveAXZTlotnc0ISTdpDRyfQyiBVctRSZRRpTR0cWr9m/9XWZil5ttlUKGfkmzJc
# GhTa0+NE+rZ9B1RjxCvzJHlVKm8DGErGcqeg7Lerm3MXMdMItCoMPLRTPEJ5aLtX
# apnv2gZ+crwPx5pWTuBlGAAriRmrvGppv4vHQmhHXFDUqO/ZcMFP3wpBKJS+a4dE
# nDWNoz8mMCh+WOv55stQOqrIyjcVb4kiKwRzPgKg3VpqN50ZKKfvfQIQMtC3H9mE
# 6S9s28VoXgOvLro0BxGbipF0eqzyKgxGm8VO4kYaTQ==
# SIG # End signature block