Nexthink-Omnissa-Connector.psm1

# Constants definition
New-Variable -Name 'START_TIME' -Value (Get-Date) -Option ReadOnly -Scope Script -Force

# LOGGING
New-Variable -Name 'LOG_FORMAT' -Value "[%{timestamp:+yyyy-MM-dd HH:mm:ss.fffffzzz}][%{level:-7}][%{lineno:3}] %{message}" -Option ReadOnly -Scope Script -Force
New-Variable -Name 'LOG_RETENTION_DAYS' -Value 7 -Option ReadOnly -Scope Script -Force
New-Variable -Name 'LOG_LEVEL' -Value 'INFO' -Option ReadOnly -Scope Script -Force

# CONFIG
New-Variable -Name 'OMNISSA_ENV_CONFIG_NOT_FOUND' -Value 'Configuration for Omnissa environment not found' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'MISSING_FIELDS' -Value 'Missing required fields' -Option ReadOnly -Scope Script -Force

# ENRICHMENT API
New-Variable -Name 'ENRICHMENT_API_HOST' -Value 'api.eu-west-3.dev.nexthink.cloud' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'TARGET_CREDENTIALS_NAME' -Value 'nxt-ctx-credentials' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'JWT_URL' -Value 'https://{0}/api/v1/token' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'ENRICHMENT_URL' -Value 'https://{0}/api/v1/enrichment/data/fields' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'LOGS_URL' -Value 'https://{0}/vdi/vdi-log-ingestion-api/api/v1/logs/entries' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'REQUEST_BATCH_SIZE' -Value 1000 -Option ReadOnly -Scope Script -Force
New-Variable -Name 'NEXTHINK_PROXY_ADDRESS' -Value '' -Option ReadOnly -Scope Script -Force

# IN-MEMORY LOG STORAGE
New-Variable -Name 'IN_MEMORY_LOGS' -Value ([System.Collections.ArrayList]::new()) -Option ReadOnly -Scope Script -Force

# OMNISSA API
# The maximum value of size supported by the API is 1000
New-Variable -Name 'PAGINATION_SIZE' -Value 1000 -Option ReadOnly -Scope Script -Force

# EXIT CODES
New-Variable -Name 'EXIT_CODE_OK' -Value 0 -Option ReadOnly -Scope Script -Force
New-Variable -Name 'EXIT_CODE_ERROR' -Value 1 -Option ReadOnly -Scope Script -Force

# TRACE ID
New-Variable -Name 'TRACE_ID_HEADER' -Value 'x-enrichment-trace-id' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'TRACE_ID_VALUE' -Value ([Guid]::NewGuid()) -Option ReadOnly -Scope Script -Force
New-Variable -Name 'LOGS_TRACE_ID_HEADER' -Value 'x-nexthink-vdi-logs-trace-id' -Option ReadOnly -Scope Script -Force

# TIMESTAMP
New-Variable -Name 'TIMESTAMP' -Value '1970-01-01T00:00:00Z' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'EXECUTION_START_DATE' -Value (Get-Date) -Option ReadOnly -Scope Script -Force

# VIRTUALIZATION CONSTANTS
# horizon_on_prem = 5
New-Variable -Name 'DESKTOP_BROKER' -Value 5 -Option ReadOnly -Scope Script -Force

# CACHE
New-Variable -Name 'BASE_IMAGES_CACHE' -Value (@{}) -Option ReadOnly -Scope Script -Force
New-Variable -Name 'BASE_SNAPSHOTS_CACHE' -Value (@{}) -Option ReadOnly -Scope Script -Force

#
# Main function
#
function Invoke-Main {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$OmnissaEnvironment,
        [Parameter(Mandatory = $true)]
        [string]$ScriptRootPath
    )

    $exitCode = $EXIT_CODE_OK
    $scriptVersion = Get-ModuleVersion

    try {
        Write-Host "Initializing Omnissa connector $OmnissaEnvironment in $ScriptRootPath" -ForegroundColor Blue
        Initialize-EnrichmentProcess -OmnissaEnvironment $OmnissaEnvironment -ScriptRootPath $ScriptRootPath
        Write-CustomLog -Message "Starting Omnissa connector $scriptVersion for environment $OmnissaEnvironment" -Severity 'INFO'
        Start-EnrichmentProcess -OmnissaEnvironment $OmnissaEnvironment
    }
    catch {
        Write-CustomLog -Message "The execution stopped unexpectedly. Details: $($_.Exception.Message)" -Severity 'ERROR'
        $exitCode = $EXIT_CODE_ERROR
    }
    Write-CustomLog -Message "Stopping Omnissa connector $scriptVersion with exit code $exitCode`n" -Severity 'INFO'

    # Send in-memory logs to Logs API
    try {
        Send-Logs -OmnissaEnvironment $OmnissaEnvironment -Version $scriptVersion
    } catch {
        # Log error but don't fail execution if log sending fails
        Write-CustomLog -Message "[$TRACE_ID_VALUE] Failed to send logs to API: $($_.Exception.Message)" -Severity 'ERROR' -NoCache
    }

    Wait-Logging
    return $exitCode
}

Set-Alias -Name Invoke-NexthinkOmnissaConnector -Value Invoke-Main

function Initialize-EnrichmentProcess {
    param(
        [Parameter(Mandatory = $true)]
        [string]$OmnissaEnvironment,
        [Parameter(Mandatory = $true)]
        [string]$ScriptRootPath
    )

    # Clear in-memory logs from previous run
    $script:IN_MEMORY_LOGS.Clear()

    # Initialize path-dependent variables
    $storeFolder = Join-Path -Path (Join-Path -Path $ScriptRootPath -ChildPath "Storage") -ChildPath $OmnissaEnvironment
    $logsFolder = Join-Path -Path (Join-Path -Path $ScriptRootPath -ChildPath "Logs") -ChildPath $OmnissaEnvironment
    $configFileFolder = Join-Path -Path (Join-Path -Path $ScriptRootPath -ChildPath "Config") -ChildPath "config.json"
    New-Variable -Name 'SCRIPT_FOLDER' -Value $ScriptRootPath -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'LOGS_FOLDER' -Value $logsFolder -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'LOGFILE_NAME' -Value (Join-Path -Path $logsFolder -ChildPath "OmnissaConnector-%{+yyyyMMdd}.log") -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'ZIPFILE_NAME' -Value (Join-Path -Path $logsFolder -ChildPath "RotatedLogs.zip") -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'CONFIG_FILE_NAME' -Value $configFileFolder -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'STORE_DEVICES_HASH_PATH' -Value (Join-Path -Path $storeFolder -ChildPath "devices_hash_v2.json") -Option ReadOnly -Scope Script -Force


    Initialize-Folder -Path $logsFolder
    Initialize-Folder -Path $storeFolder
    Initialize-Logger
    Get-ConfigData -OmnissaEnvironment $OmnissaEnvironment -ConfigFilePath $configFileFolder
    # Needed to update log level after reading it from the configuration
    Initialize-Logger
    New-Variable -Name 'TIMESTAMP' -Value $(Get-Date -Format o) -Option ReadOnly -Scope Script -Force
}

function Start-EnrichmentProcess {
    param(
        [Parameter(Mandatory = $true)]
        [string]$OmnissaEnvironment
    )
    $pools = @(Get-DesktopPools)
    $rdsServers = @(Get-RdsServers -Pools $pools)

    $page = 1
    $machines = @()
    do {
        $machinesResponse = Get-Machines -Pools $pools -Page $page
        $machines += $machinesResponse.Machines
        $page = $page + 1
    } while($machinesResponse.HasMoreRecords)

    $allDevices = $machines + $rdsServers

    $validDevices = @(Get-ValidDevices -Devices $allDevices)

    Write-CustomLog -Message "$($validDevices.Count) device(s) retrieved in total from Omnissa API." -Severity 'INFO'
    $diff = Get-ChangedDevicesToBeSent -Devices $validDevices
    $totalDevicesToSend = $diff.devices.Count
    Write-CustomLog -Message "$totalDevicesToSend changed devices." -Severity 'INFO'

    for ($devicesOffset = 0; $devicesOffset -lt $totalDevicesToSend; $devicesOffset += $REQUEST_BATCH_SIZE) {
        $deviceLimit = [Math]::Min($totalDevicesToSend - 1, $devicesOffset + $REQUEST_BATCH_SIZE - 1)
        $fullData = $diff.devices[$devicesOffset..$deviceLimit]
        Send-EnrichmentRequest -FullData $fullData -OmnissaEnvironment $OmnissaEnvironment
    }
    # Only save to storage after calls to enrichments API are done.
    # This avoids missing/lagging updates in case of failure during the API calls.
    Save-DeviceHashStorage -StorageData $diff.storage | Out-Null
}

function Send-EnrichmentRequest ([Object[]]$FullData, [String]$OmnissaEnvironment) {
    # Time in milliseconds since the script started up to when the enrichment request is sent
    $executionDuration = [Int](((Get-Date) - $EXECUTION_START_DATE).TotalMilliseconds)
    $clientTelemetry = Get-ClientTelemetry -ExecutionDuration $executionDuration

    $jwt = Get-Jwt
    $enrichmentEndpoint = [String]::Format($ENRICHMENT_URL, $ENRICHMENT_API_HOST)

    $enrichmentBody = Get-JsonFromOmnissaData -OmnissaData $FullData -ClientTelemetry $clientTelemetry -OmnissaEnvironment $OmnissaEnvironment
    Write-CustomLog -Message "Sending Devices to Nexthink" -Severity 'DEBUG'
    $response = Invoke-SendDataToEnrichmentAPI -EndpointURL $enrichmentEndpoint -JsonPayload $enrichmentBody -Jwt $jwt
    switch ( $response.statusCode ) {
        '200' {
            Write-CustomLog -Message "[$TRACE_ID_VALUE] Batch with $($FullData.Count) devices successfully processed by Enrichment API." -Severity 'INFO'
        }
        '207' {
            Write-CustomLog -Message "[$TRACE_ID_VALUE] Batch with $($FullData.Count) devices partially processed by Enrichment API." -Severity 'INFO'
            Write-CustomLog -Message "[$TRACE_ID_VALUE] Partial success response: $($response.content)" -Severity 'INFO'
        }
        default {
            $message = "[$TRACE_ID_VALUE] Error sending request to Enrichment API with status code: $($response.statusCode)"
            Write-CustomLog -Message $message -Severity 'ERROR'
            throw $message
        }
    }
}

function Get-Jwt () {
    $jwtUrl = [String]::Format($JWT_URL, $ENRICHMENT_API_HOST)
    if (-not (Test-ValidWebUrl($jwtUrl))) {
        throw "Invalid URL to retrieve the token: $jwtUrl"
    }

    try {
        $credentials = Get-ClientCredentials -Target $TARGET_CREDENTIALS_NAME

        $basicHeader = Get-StringAsBase64 -InputString "$($credentials.clientId):$($credentials.clientSecret)"
        $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
        $headers.Add('Authorization', "Basic $basicHeader")

        $webRequestParams = @{
            Uri             = $jwtUrl
            Method          = 'POST'
            Headers         = $headers
            UseBasicParsing = $true
        }
        Add-ProxySettings -Params $webRequestParams

        # Skip logging of response to avoid leaking sensitive information
        $response = Invoke-WebRequestWithLogging @webRequestParams -SkipResponseLogging
        $parsedResponse = ConvertFrom-Json $([String]::new($response.Content))
        return $parsedResponse.access_token
    }
    catch [Net.WebException], [IO.IOException] {
        Write-CustomLog "Error sending request to get the JWT token. Details: [$($_.Exception.response.StatusCode.value__)] $($_.ErrorDetails)" -Severity "ERROR"
        throw "Unable to access token endpoint. Details: $($_.Exception.Message)"
    }
    catch {
        throw "An error occurred that could not be resolved at Get-Jwt function. Details: $($_.Exception.Message)"
    }
}

function Test-ValidWebUrl($UrlToValidate) {
    $uri = $UrlToValidate -as [System.Uri]
    $null -ne $uri.AbsoluteURI -and $uri.Scheme -match '[http|https]'
}

function Get-ClientCredentials ([String]$Target) {
    $storedCredentials = Get-StoredCredential -Target $Target
    if ($storedCredentials -and $null -ne $storedCredentials.UserName -and $null -ne $storedCredentials.Password ) {
        $userName = $storedCredentials.UserName
        $securePassword = $storedCredentials.Password
        $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword)
        $unsecurePassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)

        return @{ clientId = $userName; clientSecret = $unsecurePassword }
    }
    else {
        throw "Credentials not found or they are empty for Target: $Target"
    }
}

function Get-StringAsBase64 ([String]$InputString) {
    $Bytes = [System.Text.Encoding]::UTF8.GetBytes($InputString)
    $EncodedText = [Convert]::ToBase64String($Bytes)
    return $EncodedText
}

function Add-ProxySettings ([hashtable]$Params) {
    if (-not [string]::IsNullOrWhiteSpace($Script:NEXTHINK_PROXY_ADDRESS)) {
        Write-CustomLog -Message "Adding proxy settings: $($Script:NEXTHINK_PROXY_ADDRESS)" -Severity 'DEBUG'
        $Params.Add('Proxy', $Script:NEXTHINK_PROXY_ADDRESS)
        $Params.Add('ProxyUseDefaultCredentials', $true)
    } else {
        Write-CustomLog -Message "Proxy not configured, skipping proxy settings" -Severity 'DEBUG'
    }
}

#
# Omnissa Horizon functions
#
function Get-DesktopPools {

    Write-CustomLog -Message "Fetching desktop pools..." -Severity 'DEBUG'
    $headers = Get-RequestHeaders

    try {
        $desktopPoolsResponse = Invoke-RestMethodWithLogging -Uri "$OMNISSA_HOST/rest/inventory/v7/desktop-pools" -Headers $headers -Method Get
        $desktopPools = $desktopPoolsResponse | Where-Object { $null -ne $_.id }
        $desktopPoolsCount = $desktopPools.Count

        Write-CustomLog -Message "$desktopPoolsCount desktop pool IDs found" -Severity 'DEBUG'
        return $desktopPools
    }
    catch {
        throw "Failed to get desktop pools. Check if the Omnissa user has the required permissions to access desktop pools. Details: $($_.Exception.Message)"
    }
}

function Get-Machines {
    param (
        [Object[]]$Pools,
        [Parameter(Mandatory = $true)][int]$Page
    )

    try {
        $output = @{
            Machines = @()
            HasMoreRecords = $false
        }

        $headers = Get-RequestHeaders
        $response = Invoke-WebRequestWithLogging -Uri "$OMNISSA_HOST/rest/inventory/v5/machines?page=$Page&size=$PAGINATION_SIZE" -Headers $headers -Method Get -UseBasicParsing
        $machinesResponse = $response.Content | ConvertFrom-Json

        $output.HasMoreRecords = $response.Headers.HAS_MORE_RECORDS -eq "true"

        $machineCount = $machinesResponse.Count
        Write-CustomLog -Message "$machineCount machines found" -Severity 'DEBUG'

        if ($machineCount -eq 0) {
            # Early return if no machines were found
            return $output
        }

        $poolLookup = Get-ObjectLookup -KeySelector { param($p) $p.id } -Objects $Pools

        foreach ($machine in $machinesResponse) {
            $pool = $poolLookup[$machine.desktop_pool_id]
            $vCenterId = if ($null -ne $machine.managed_machine_data.virtual_center_id) { $machine.managed_machine_data.virtual_center_id } else { $pool.vcenter_id }
            $baseVmId = $machine.managed_machine_data.base_vm_id
            $snapshotId = $machine.managed_machine_data.base_vm_snapshot_id

            Write-CustomLog -Message "Retrieving base image for machine '$($machine.name)'..." -Severity 'DEBUG'
            $baseImage = Get-BaseImage -VCenterId $vCenterId -BaseVmId $baseVmId -SnapshotId $snapshotId

            $output.Machines += [PSCustomObject]@{
                MachineName    = $machine.name
                DesktopPool    = $pool.name
                PoolType       = $pool.type
                Hostname       = $machine.managed_machine_data.host_name
                UserAssignment = $pool.user_assignment
                DiskImage      = $baseImage
            }
        }

        return $output
    }
    catch {
        throw "Failed to get machines."
    }
}

function Get-RdsServers {
    param (
        [Object[]]$Pools
    )

    try {
        $rdsServersOutput = @()

        $headers = Get-RequestHeaders
        $rdsServersResponse = Invoke-RestMethodWithLogging -Uri "$OMNISSA_HOST/rest/inventory/v2/rds-servers" -Headers $headers -Method Get

        $rdsServersCount = $rdsServersResponse.Count
        Write-CustomLog -Message "$rdsServersCount RDS servers found" -Severity 'DEBUG'

        if ($rdsServersCount -eq 0) {
            # Early return if there are no RDS servers
            return $rdsServersOutput
        }

        $farmsResponse = @(Get-Farms)
        $farmLookup = Get-ObjectLookup -Objects $farmsResponse -KeySelector { param($p) $p.id }
        $poolLookup = Get-ObjectLookup -KeySelector { param($p) $p.farm_id } -Objects $Pools

        foreach ($rdsServer in $rdsServersResponse) {
            $pool = $poolLookup[$rdsServer.farm_id]
            $farm = $farmLookup[$rdsServer.farm_id]

            $vCenterId = $farm.automated_farm_settings.vcenter_id
            $baseVmId = $farm.automated_farm_settings.provisioning_settings.parent_vm_id
            $snapshotId = $farm.automated_farm_settings.provisioning_settings.base_snapshot_id

            Write-CustomLog -Message "Retrieving base image for RDS server '$($rdsServer.name)'..." -Severity 'DEBUG'
            $baseImage = Get-BaseImage -VCenterId $vCenterId -BaseVmId $baseVmId -SnapshotId $snapshotId

            # Extract short machine name (remove domain suffix if present)
            $machineName = $rdsServer.name -replace '\..*$', ''

            $rdsServersOutput += [PSCustomObject]@{
                MachineName    = $machineName
                DesktopPool    = $pool.name
                PoolType       = $pool.type
                Hostname       = $rdsServer.dns_name
                UserAssignment = $pool.user_assignment
                DiskImage      = $baseImage
            }
        }

        return $rdsServersOutput
    }
    catch {
        throw "Failed to get RDS servers. Check if the Omnissa user has the required permissions to access RDS servers."
    }
}

function Get-BaseImage {
    param (
        [string]$VCenterId,
        [string]$BaseVmId,
        [string]$SnapshotId
    )

    if ([string]::IsNullOrEmpty($VCenterId) -or
        [string]::IsNullOrEmpty($BaseVmId) -or
        [string]::IsNullOrEmpty($SnapshotId)) {
        Write-CustomLog -Message "Required parameters missing: $($PSBoundParameters | ConvertTo-Json -Compress). Skipping base image retrieval." -Severity 'DEBUG'

        return $null
    }

    $baseImagesCacheKey = "$VCenterId"
    $baseVMLookup = Get-FromCacheOrInvoke -Cache $BASE_IMAGES_CACHE -Key $baseImagesCacheKey -Invoke { Get-BaseVmLookup -VCenterId $VCenterId }
    $baseVmPath = $baseVMLookup[$BaseVmId].path

    $baseSnapshotsCacheKey = "$VCenterId" + "_" + "$BaseVmId"
    $snapshotResponse = Get-FromCacheOrInvoke -Cache $BASE_SNAPSHOTS_CACHE -Key $baseSnapshotsCacheKey -Invoke { Get-BaseSnapshotLookup -VCenterId $VCenterId -BaseVmId $BaseVmId }

    $snaphotPath = $snapshotResponse[$SnapshotId].path
    $baseImage = $baseVmPath + $snaphotPath


    Write-CustomLog -Message "Base image retrieved: '$baseImage'" -Severity 'DEBUG'
    return $baseImage
}

function Get-BaseVmLookup {
    param (
        [Parameter(Mandatory = $true)][string]$VCenterId
    )

    Write-CustomLog -Message "Fetching base VMs for VCenterId='$VCenterId'..." -Severity 'DEBUG'

    try {
        $headers = Get-RequestHeaders
        $baseVMsResponse = Invoke-RestMethodWithLogging -Uri "$OMNISSA_HOST/rest/external/v2/base-vms?vcenter_id=$VCenterId" -Headers $headers -Method Get
        $baseVMsCount = $baseVMsResponse.Count

        Write-CustomLog -Message "$baseVMsCount base VMs found" -Severity 'DEBUG'

        return Get-ObjectLookup -Objects $baseVMsResponse -KeySelector { param($p) $p.id }
    }
    catch {
        throw "Failed to get base VMs."
    }
}

function Get-BaseSnapshotLookup {
    param (
        [Parameter(Mandatory = $true)][string]$VCenterId,
        [Parameter(Mandatory = $true)][string]$BaseVmId
    )

    Write-CustomLog -Message "Fetching base snapshots for VCenterId='$VCenterId' and BaseVmId='$BaseVmId'..." -Severity 'DEBUG'

    try {
        $headers = Get-RequestHeaders
        $baseSnapshotsResponse = Invoke-RestMethodWithLogging -Uri "$OMNISSA_HOST/rest/external/v2/base-snapshots?vcenter_id=$VCenterId&base_vm_id=$BaseVmId" -Headers $headers -Method Get
        $baseSnapshotsCount = $baseSnapshotsResponse.Count

        Write-CustomLog -Message "$baseSnapshotsCount base snapshots found" -Severity 'DEBUG'

        return Get-ObjectLookup -Objects $baseSnapshotsResponse -KeySelector { param($p) $p.id }
    }
    catch {
        throw "Failed to get base snapshots."
    }
}

function Get-Farms {
    Write-CustomLog -Message "Fetching farms..." -Severity 'DEBUG'

    try {
        $headers = Get-RequestHeaders
        $farms = Invoke-RestMethodWithLogging -Uri "$OMNISSA_HOST/rest/inventory/v6/farms" -Headers $headers -Method Get
        $farmsCount = $farms.Count

        Write-CustomLog -Message "$farmsCount farms found" -Severity 'DEBUG'
        return $farms
    }
    catch {
        throw "Failed to get farms."
    }
}

function Get-RequestHeaders {
    $credentials = Get-ClientCredentials -Target $OMNISSA_CREDENTIALS_NAME
    $split = $credentials.clientId -split '\\', 2

    if ($split.Count -eq 2) {
        $domain = $split[0]
        $username = $split[1]
    } else {
        # Domain is required by the login endpoint, so set it to a default.
        $domain = 'default'
        $username = $credentials.clientId
    }

    $authBody = @{
        domain   = $domain
        username = $username
        password = $credentials.clientSecret
    } | ConvertTo-Json

    try {
        # Skip logging of response to avoid leaking sensitive information
        $response = Invoke-WebRequestWithLogging -Uri "$OMNISSA_HOST/rest/login" -Method POST -Body $authBody -ContentType "application/json" -UseBasicParsing -SkipResponseLogging
        $token = ($response.Content | ConvertFrom-Json).access_token

        if (-not $token) {
            throw "Authentication failed: No token returned."
        }
    }
    catch {
        throw "Get-RequestHeaders failed. Check credentials and omnissa host URL(check URL and if it starts with https://... or http://...)"
    }

    return @{
        "Authorization" = "Bearer $token"
        "Accept"        = "application/json"
        "Content-Type"  = "application/json"
    }
}

# Function to determine virtualization type based on input string
function Get-VirtualizationType {
     param(
        [string]$UserAssignment,

        [string]$PoolType
    )

    # Default to UNSPECIFIED (0) if no match is found
    $result = 0

    # Determine virtualization type based on UserAssignment and PoolType
    # https://github.com/nexthink/data-platform.protobufs/blob/5d7fc865d99b5fbebdc6cb4fee2536d02150e40d/nexthink/protobuf/data_platform/inventory/v1/model_device.proto#L661
    if ($PoolType -eq "RDS") {
        # SHARED
        $result = 1
    }
    elseif ($UserAssignment -eq "DEDICATED") {
        # PERSONAL
        $result = 2
    }
    elseif ($UserAssignment -eq "FLOATING") {
        # POOLED
        $result = 3
    }

    return $result
}

function Get-ValidDevices {
    param(
        [Parameter(Mandatory = $true)]
        [Object[]]$Devices
    )

    $validDevices = @()

    # Filter out devices without MachineName early to avoid processing invalid devices
    foreach ($device in $Devices) {
        if ($null -ne $device.MachineName) {
            $validDevices += $device
        }
        else {
            Write-CustomLog -Message "Invalid device: '$($device | ConvertTo-Json -Compress)'" -Severity 'DEBUG'
        }
    }
    return $validDevices
}

#
# Logging
#
function Write-CustomLog {
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [String]$Message,
        [Parameter(Mandatory = $false)]
        [String]$Severity = 'INFO',
        [Parameter(Mandatory = $false)]
        [Switch]$NoCache
    )
    
    if ([string]::IsNullOrWhiteSpace($Message)) {
        return
    }
    
    switch ($Severity) {

        'ERROR' {
            Write-Host $Message -ForegroundColor Red
        }
        'WARNING' {
            Write-Host $Message -ForegroundColor Yellow
        }
        'INFO' {
            Write-Host $Message -ForegroundColor Blue
        }
        default
        {
            Write-Host $Message
        }
    
    }

    # Logging after writing to stdout so we have some logs if the file logging fails for some unexpected reason
    Write-Log -Message $Message -Level $Severity

    # Store log in memory for later transmission to logs API (unless NoCache is specified)
    if (-not $NoCache) {
        $logTimestamp = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
        $logEntry = @{
            timestamp = $logTimestamp
            level = $Severity.ToUpper()
            message = $Message
        }
        [void]$script:IN_MEMORY_LOGS.Add($logEntry)
    }
}

function Initialize-Folder ([String]$Path) {
    try {
        if (-not (Test-Path -Path $Path)) {
            [Void](New-Item -Path $Path -ItemType 'Directory' -Force -ErrorAction Stop)
        }
    }
    catch {
        throw "Error creating folder at $Path."
    }
}

function Initialize-Logger {
    Add-LoggingTarget -Name File -Configuration @{
        Path              = $LOGFILE_NAME
        Encoding          = 'unicode'
        Level             = $LOG_LEVEL
        Format            = $LOG_FORMAT
        RotateAfterAmount = $LOG_RETENTION_DAYS
        RotateAmount      = 1
        CompressionPath   = $ZIPFILE_NAME
    }
    Set-LoggingCallerScope 2
}

#
# Config functions
#
function Get-ConfigData {
    param(
        [Parameter(Mandatory = $true)]
        [string]$OmnissaEnvironment,
        [Parameter(Mandatory = $true)]
        [string]$ConfigFilePath
    )

    $configData = Read-ConfigFile -ConfigFilePath $ConfigFilePath
    $omnissaEnvironmentConfigData = Get-OmnissaEnvironmentConfigData -ConfigData $configData -OmnissaEnvName $OmnissaEnvironment

    if ($null -eq $omnissaEnvironmentConfigData) {
        throw "$OMNISSA_ENV_CONFIG_NOT_FOUND"
    }

    if (-not (Test-ConfigData -ConfigData $configData -OmnissaEnvConfigData $omnissaEnvironmentConfigData)) {
        throw "$MISSING_FIELDS"
    }

    New-Variable -Name 'LOG_RETENTION_DAYS' -Value ([int]$configData.Logging.LogRetentionDays) -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'LOG_LEVEL' -Value $configData.Logging.LogLevel -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'OMNISSA_HOST' -Value $omnissaEnvironmentConfigData.Host -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'OMNISSA_CREDENTIALS_NAME' -Value $omnissaEnvironmentConfigData.WindowsCredentialEntry -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'ENRICHMENT_API_HOST' -Value $configData.NexthinkAPI.HostFQDN -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'TARGET_CREDENTIALS_NAME' -Value $configData.NexthinkAPI.WindowsCredentialEntry -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'REQUEST_BATCH_SIZE' -Value $configData.NexthinkAPI.RequestBatchSize -Option ReadOnly -Scope Script -Force

    # Optional proxy configuration for Nexthink API
    $proxyAddress = $configData.NexthinkAPI.ProxyAddress
    if (-not [string]::IsNullOrWhiteSpace($proxyAddress)) {
        New-Variable -Name 'NEXTHINK_PROXY_ADDRESS' -Value $proxyAddress -Option ReadOnly -Scope Script -Force
        Write-CustomLog -Message "Nexthink API proxy configured: $proxyAddress" -Severity 'INFO'
    }
}

function Read-ConfigFile {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ConfigFilePath
    )
    try {
        return (Get-Content "$ConfigFilePath" -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop)
    }
    catch {
        throw "Error loading config file. Details: $($_.toString())"
    }
}

function Get-OmnissaEnvironmentConfigData($ConfigData, $OmnissaEnvName) {
    return $ConfigData.OmnissaEnvironments | Where-Object { $_.Name -eq $OmnissaEnvName }
}

function Test-ConfigData($ConfigData, $OmnissaEnvConfigData) {
    return ($ConfigData.Logging.LogRetentionDays -and $ConfigData.Logging.LogLevel -and $ConfigData.NexthinkAPI.HostFQDN -and `
            $ConfigData.NexthinkAPI.WindowsCredentialEntry -and $ConfigData.NexthinkAPI.RequestBatchSize -and `
            $OmnissaEnvConfigData.Name -and $OmnissaEnvConfigData.Host -and `
            $OmnissaEnvConfigData.WindowsCredentialEntry)
}

#
# Utilities
#
function Get-ModuleVersion() {
    param (
        [Object]$Module = $MyInvocation.MyCommand.Module
    )

    try {
        return $Module.Version.ToString()
    } catch {
        Write-CustomLog -Message "Could not determine module version: $_" -Severity 'WARNING'
        return '-'
    }
}

function Get-ObjectLookup {
    param(
        [Object[]]$Objects,

        [Parameter(Mandatory = $true)]
        [ScriptBlock]$KeySelector
    )

    $lookup = @{}

    foreach ($obj in $Objects) {
        $key = & $KeySelector $obj

        if ([string]::IsNullOrEmpty($key)) {
            continue
        }

        if (-not $lookup.ContainsKey($key)) {
            $lookup.Add($key, $obj)
        }
    }

    return $lookup
}

function Get-FromCacheOrInvoke {
    param (
        [hashtable]$Cache,
        [string]$Key,
        [ScriptBlock]$Invoke
    )

    if ($Cache.ContainsKey($Key)) {
        return $Cache[$Key]
    }

    $result = & $Invoke
    $Cache[$Key] = $result
    return $result
}

function Invoke-RestMethodWithLogging {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Uri,
        
        [Parameter(Mandatory = $false)]
        [Microsoft.PowerShell.Commands.WebRequestMethod]$Method = 'Get',
        
        [Parameter(Mandatory = $false)]
        [hashtable]$Headers,
        
        [Parameter(Mandatory = $false)]
        [object]$Body,
        
        [Parameter(Mandatory = $false)]
        [string]$ContentType,

        [Parameter(Mandatory = $false)]
        [switch]$SkipResponseLogging
    )
    
    Write-CustomLog -Message "Invoking REST API: $Method $Uri" -Severity 'DEBUG'
    
    try {
        $params = @{
            Uri    = $Uri
            Method = $Method
        }
        
        if ($Headers) { $params.Add('Headers', $Headers) }
        if ($Body) { $params.Add('Body', $Body) }
        if ($ContentType) { $params.Add('ContentType', $ContentType) }
        
        $response = Invoke-RestMethod @params
        Write-CustomLog -Message "Successfully completed request to: $Uri" -Severity 'DEBUG'

        if($VerbosePreference -eq 'Continue' -and -not $SkipResponseLogging) {
            Write-CustomLog -Message "Response: $($response | ConvertTo-Json -Compress)" -Severity 'DEBUG'
        }

        return $response
    }
    catch {
        $errorMessage = "Failed to execute REST API call to $Uri. Error: $($_.Exception.Message)"
        Write-CustomLog -Message $errorMessage -Severity 'ERROR'
        
        if ($_.Exception.Response) {
            $statusCode = $_.Exception.Response.StatusCode.value__
            Write-CustomLog -Message "HTTP Status Code: $statusCode" -Severity 'ERROR'
        }
        
        throw $errorMessage
    }
}

function Invoke-WebRequestWithLogging {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Uri,
        
        [Parameter(Mandatory = $false)]
        [Microsoft.PowerShell.Commands.WebRequestMethod]$Method = 'Get',
        
        [Parameter(Mandatory = $false)]
        [hashtable]$Headers,
        
        [Parameter(Mandatory = $false)]
        [object]$Body,
        
        [Parameter(Mandatory = $false)]
        [string]$ContentType,
        
        [Parameter(Mandatory = $false)]
        [switch]$UseBasicParsing,
        
        [Parameter(Mandatory = $false)]
        [string]$Proxy,
        
        [Parameter(Mandatory = $false)]
        [switch]$ProxyUseDefaultCredentials,

        [Parameter(Mandatory = $false)]
        [switch]$SkipResponseLogging
    )
    
    Write-CustomLog -Message "Invoking Web Request: $Method $Uri" -Severity 'DEBUG'
    
    try {
        $params = @{
            Uri    = $Uri
            Method = $Method
        }
        
        if ($Headers) { $params.Add('Headers', $Headers) }
        if ($Body) { $params.Add('Body', $Body) }
        if ($ContentType) { $params.Add('ContentType', $ContentType) }
        if ($UseBasicParsing) { $params.Add('UseBasicParsing', $true) }
        if (-not [string]::IsNullOrWhiteSpace($Proxy)) {
            $params.Add('Proxy', $Proxy)
            if ($ProxyUseDefaultCredentials) { $params.Add('ProxyUseDefaultCredentials', $true) }
        }
        
        $response = Invoke-WebRequest @params
        Write-CustomLog -Message "Successfully completed request to: $Uri (Status: $($response.StatusCode))" -Severity 'DEBUG'

        if($VerbosePreference -eq 'Continue' -and -not $SkipResponseLogging) {
            Write-CustomLog -Message "Response: $($response.Content)" -Severity 'DEBUG'
        }

        return $response
    }
    catch {
        $errorMessage = "Failed to execute web request to $Uri. Error: $($_.Exception.Message)"
        Write-CustomLog -Message $errorMessage -Severity 'ERROR'
        
        if ($_.Exception.Response) {
            $statusCode = $_.Exception.Response.StatusCode.value__
            Write-CustomLog -Message "HTTP Status Code: $statusCode `n Response: $($_.ToString())" -Severity 'ERROR'
            
        }
        
        throw $errorMessage
    }
}

#
# Enrichment API
#
function Get-JsonFromOmnissaData ([Object[]]$OmnissaData, [PSCustomObject]$ClientTelemetry, [String]$OmnissaEnvironment) {
    $jsonResult = '{"enrichments": ['
    foreach ($device in $OmnissaData) {
        try {
            $virtualizationType = Get-VirtualizationType -UserAssignment $device.UserAssignment -PoolType $device.PoolType
            $currentRow = '{"identification":[{"name":"device/device/name","value":"' + $device.MachineName + '"}],'
            $currentRow = $currentRow + '"fields":[{"name":"device/device/virtualization/desktop_pool","value":"' + $device.DesktopPool + '"}'
            $currentRow = $currentRow + ',{"name":"device/device/virtualization/type","value": ' + $virtualizationType + '}'
            $currentRow = $currentRow + ',{"name":"device/device/virtualization/hostname","value":"' + $device.Hostname + '"}'
            $currentRow = $currentRow + ',{"name":"device/device/virtualization/environment_name","value":"' + $OmnissaEnvironment + '"}'
            $currentRow = $currentRow + ',{"name":"device/device/virtualization/desktop_broker","value": ' + $DESKTOP_BROKER + '}'
            if ($null -ne $device.DiskImage) {
                $currentRow = $currentRow + ',{"name":"device/device/virtualization/disk_image","value":"' + $device.DiskImage + '"}'
            }
            $currentRow = $currentRow + ',{"name":"device/device/virtualization/last_update","value":"' + $TIMESTAMP + '"}'
            $currentRow = $currentRow + ']},'
            $jsonResult = $jsonResult + $currentRow
        }
        catch {
            Write-CustomLog -Message "Error processing device '$($device.MachineName)'. Details: $($_.Exception.Message)" -Severity 'ERROR'
        }
    }
    if ($jsonResult.EndsWith(',')) {
        $jsonResult = $jsonResult.Substring(0, $jsonResult.Length - 1)
    }

    $clientTelemetryJson = $clientTelemetry | ConvertTo-Json -Compress

    return $jsonResult + '], "domain":"omnissa", "clientTelemetry":' + $clientTelemetryJson + '}'
}

function Get-ClientTelemetry([Int]$ExecutionDuration) {
    $scriptVersion = Get-ModuleVersion

    return [PSCustomObject]@{
        version               = $scriptVersion
        executionDurationInMs = $ExecutionDuration
        fullScan              = "true"
    }
}

function Invoke-SendDataToEnrichmentAPI ([String]$EndpointUrl, [String]$JsonPayload, [String]$Jwt) {
    try {
        $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
        $headers.Add('Content-Type', 'application/json')
        $headers.Add('Authorization', "Bearer $Jwt")
        $headers.Add($TRACE_ID_HEADER, $TRACE_ID_VALUE)

        $webRequestParams = @{
            Uri             = $EndpointUrl
            Method          = 'POST'
            Headers         = $headers
            Body            = $JsonPayload
            UseBasicParsing = $true
        }
        Add-ProxySettings -Params $webRequestParams

        $response = Invoke-WebRequestWithLogging @webRequestParams
        $statusCode = $response.StatusCode
        $content = $response.Content
    }
    catch {
        Write-CustomLog -Message "[$TRACE_ID_VALUE] Error sending request to Enrichment API. Details: $($_.Exception.Message)" -Severity 'ERROR'
        Write-CustomLog -Message "[$TRACE_ID_VALUE] Error message: $_" -Severity 'ERROR'
        $statusCode = $_.Exception.response.StatusCode.value__
    }
    return @{ statusCode = $statusCode; content = $content }
}

function Send-Logs {
    param(
        [Parameter(Mandatory = $true)]
        [String]$OmnissaEnvironment,
        [Parameter(Mandatory = $true)]
        [String]$Version
    )

    if ($script:IN_MEMORY_LOGS.Count -eq 0) {
        return
    }

    try {
        # Get JWT token
        $jwt = Get-Jwt

        # Build logs endpoint URL
        $logsEndpoint = [String]::Format($LOGS_URL, $ENRICHMENT_API_HOST)

        # Build logs payload
        $logsPayload = @{
            source = "omnissa-horizon-connector"
            customerEnvironment = $OmnissaEnvironment
            version = $Version
            logs = $script:IN_MEMORY_LOGS
        }

        $logsJsonPayload = $logsPayload | ConvertTo-Json -Depth 10 -Compress

        # Send logs to API
        $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
        $headers.Add('Content-Type', 'application/json')
        $headers.Add('Authorization', "Bearer $jwt")
        $headers.Add($LOGS_TRACE_ID_HEADER, $TRACE_ID_VALUE)

        $response = Invoke-WebRequest -Uri $logsEndpoint -Method 'POST' -Headers $headers -Body $logsJsonPayload -UseBasicParsing
        Write-CustomLog -Message "[$TRACE_ID_VALUE] Successfully sent $($script:IN_MEMORY_LOGS.Count) log entries to Logs API. Response code: $($response.StatusCode)" -Severity 'INFO' -NoCache
    } catch {
        # Use NoCache to avoid adding to IN_MEMORY_LOGS and creating infinite recursion
        Write-CustomLog -Message "[$TRACE_ID_VALUE] Error sending logs to Logs API. Details: $($_.Exception.Message)" -Severity 'ERROR' -NoCache
        # Don't throw - we don't want log sending failures to break the main execution
    }
}

#
# Device change tracking
#
function Get-DeviceHashStorage {
    $storageData = @{}
    # Read existing storage file if it exists
    if (Test-Path -Path $STORE_DEVICES_HASH_PATH) {
        try {
            $storageContent = Get-Content -Path $STORE_DEVICES_HASH_PATH -Raw | ConvertFrom-Json -ErrorAction Stop
            $storageData = @{}
            $storageContent.PSObject.Properties | ForEach-Object {
                $deviceData = @{
                    hash = $_.Value.hash
                    lastUpdated = $_.Value.lastUpdated
                }
                $storageData[$_.Name] = $deviceData
            }

            Write-CustomLog -Message "Loaded existing device hash data from $STORE_DEVICES_HASH_PATH" -Severity 'DEBUG'
        }
        catch {
            Write-CustomLog -Message "Error reading storage file: $($_.Exception.Message)" -Severity 'WARNING'
        }
    }
    else {
        Write-CustomLog -Message "Storage file not found" -Severity 'DEBUG'
    }
    return $storageData
}

function Save-DeviceHashStorage {
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$StorageData
    )
    try {
        # Ensure directory exists
        $storageDir = Split-Path -Path $STORE_DEVICES_HASH_PATH -Parent
        if (-not (Test-Path -Path $storageDir)) {
            New-Item -Path $storageDir -ItemType Directory -Force | Out-Null
        }
        $StorageData | ConvertTo-Json -Compress | Set-Content -Path $STORE_DEVICES_HASH_PATH -Force
        Write-CustomLog -Message "Updated device hash storage file" -Severity 'DEBUG'
        return $true
    }
    catch {
        Write-CustomLog -Message "Error updating storage file: $($_.Exception.Message)" -Severity 'ERROR'
        return $false
    }
}

function Get-DeviceHash {
    param(
        [Parameter(Mandatory = $true)]
        [object]$Device
    )

    # Create a deterministic string representation by sorting properties
    # This ensures consistent hashing regardless of property order in JSON
    $sortedProperties = $Device.PSObject.Properties | Sort-Object Name
    $hashInput = ""
    foreach ($prop in $sortedProperties) {
        $hashInput += "$($prop.Name):$($prop.Value);"
    }

    $hasher = [System.Security.Cryptography.SHA256]::Create()
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($hashInput)
    $hashBytes = $hasher.ComputeHash($bytes)
    $hash = [BitConverter]::ToString($hashBytes) -replace '-', ''
    return $hash
}

function Test-DeviceChanged {
    param(
        [Parameter(Mandatory = $true)]
        [object]$Device,
        [Parameter(Mandatory = $true)]
        [hashtable]$StorageData,
        [Parameter(Mandatory = $true)]
        [datetime]$Date
    )

    $deviceId = $Device.MachineName

    if (-not $deviceId) {
        return $false
    }

    if ($StorageData.ContainsKey($deviceId)) {
        $storedDeviceData = $StorageData[$deviceId]

        # Validate that required properties are not empty/null
        if ([string]::IsNullOrEmpty($storedDeviceData.lastUpdated) -or [string]::IsNullOrEmpty($storedDeviceData.hash)) {
            Write-CustomLog -Message "Device '$deviceId' found in storage but required properties are missing or empty. Treating as new device." -Severity 'DEBUG'
            return $true
        }

        $deviceLastUpdate = [datetime]::Parse($storedDeviceData.lastUpdated)
        $deviceMaxAge = $Date.AddDays(-1)

        if ($deviceLastUpdate -lt $deviceMaxAge) {
            Write-CustomLog -Message "Update required: Device '$deviceId' last update was too long ago." -Severity 'DEBUG'
            return $true
        }

        $deviceHash = Get-DeviceHash -Device $Device

        if ($storedDeviceData.hash -ne $deviceHash) {
            Write-CustomLog -Message "Device '$deviceId' has changed" -Severity 'DEBUG'
            return $true
        }
        else {
            Write-CustomLog -Message "Device '$deviceId' unchanged" -Severity 'DEBUG'
            return $false
        }


    }
    else {
        Write-CustomLog -Message "New device found: '$deviceId'" -Severity 'DEBUG'
        return $true
    }
}

function Get-ChangedDevicesToBeSent {
    param(
        [Parameter(Mandatory = $true)]
        [Object[]]$Devices
    )

    Write-CustomLog -Message "Checking for changed devices..." -Severity 'DEBUG'
    $changedDevices = @()
    $nowDate = Get-Date
    # Get the current date and time in UTC, formatted as an ISO 8601 string (for consistent timestamping)
    $formattedDate = $nowDate.ToUniversalTime().ToString("o")

    $storageData = Get-DeviceHashStorage

    foreach ($device in $Devices) {
        try {
            $hasChanged = Test-DeviceChanged -Device $device -StorageData $storageData -Date $nowDate
            if ($hasChanged) {
                $changedDevices += $device
                $deviceHash = Get-DeviceHash -Device $device
                $storageData[$device.MachineName] = @{hash = $deviceHash; lastUpdated = $formattedDate}
            }
        }
        catch {
            $deviceInfo = if ($device.MachineName) { $device.MachineName } elseif ($device.Id) { "ID: $($device.Id)" } else { "Unknown device" }
            Write-CustomLog -Message "Error processing device $deviceInfo`: $($_.Exception.Message)" -Severity 'ERROR'
        }
    }

    Write-CustomLog -Message "Found $($changedDevices.Count) changed/new devices of a total of $($Devices.Count) devices" -Severity 'INFO'
    return @{ devices = $changedDevices; storage = $storageData }
}




#
# Module exports - Only export when running as a module
#
try {
    Export-ModuleMember -Function *  -Alias Invoke-NexthinkOmnissaConnector
}
catch [System.InvalidOperationException] {
    # Expected when dot-sourced as script during testing
}
catch {
    # Silently ignore any other export errors
}

# SIG # Begin signature block
# MIIohQYJKoZIhvcNAQcCoIIodjCCKHICAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCD1JL53XYoAaoku
# rcwjnQpBbfhEoBkDT4xasxBbGf37vaCCIZ0wggWNMIIEdaADAgECAhAOmxiO+dAt
# 5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV
# BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAwMDBa
# Fw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy
# dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lD
# ZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
# ggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3E
# MB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKy
# unWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsF
# xl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU1
# 5zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJB
# MtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObUR
# WBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6
# nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxB
# YKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5S
# UUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+x
# q4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6MIIB
# NjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwP
# TzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8EBAMC
# AYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp
# Y2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNv
# bS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDigNoY0
# aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENB
# LmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCgv0Nc
# Vec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQTSnov
# Lbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh65Zy
# oUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSwuKFW
# juyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAOQGPF
# mCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjDTZ9z
# twGpn1eqXijiuZQwggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0GCSqG
# SIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMx
# GTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRy
# dXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTlaMGkx
# CzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4
# RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEzODQg
# MjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C0Cit
# eLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce2vnS
# 1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0daE6ZM
# swEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6TSXBC
# Mo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoAFdE3
# /hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7OhD26j
# q22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM1bL5
# OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z8ujo
# 7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05huzU
# tw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNYmtwm
# KwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP/2NP
# TLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0TAQH/
# BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYDVR0j
# BBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1Ud
# JQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0
# cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0
# cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8E
# PDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVz
# dGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATANBgkq
# hkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95RysQDK
# r2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HLIvda
# qpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5BtfQ/g+
# lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnhOE7a
# brs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIhdXNS
# y0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV9zeK
# iwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/jwVYb
# KyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYHKi8Q
# xAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmCXBVm
# zGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l/aCn
# HwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZWeE4w
# gga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0GCSqGSIb3DQEBCwUAMGIx
# CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3
# dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH
# NDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTlaMGkxCzAJBgNVBAYTAlVT
# MRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1
# c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTEwggIi
# MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHTCphBcr48RsAcrHXbo0Zo
# dLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPhof6pvF4uGjwjqNjfEvUi
# 6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mAxAHeHYNnQxqXmRinvuNg
# xVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBvMgEdgkFiDNYiOTx4OtiF
# cMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps0wjUjsZvkgFkriK9tUKJ
# m/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF83bRVFLeGkuAhHiGPMvS
# GmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXiUOeSLsJygoLPp66bkDX1
# ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOMCZIVNSaz7BX8VtYGqLt9
# MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydPpOjL6s36czwzsucuoKs7
# Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrUG2ZdSoQbU2rMkpLiQ6bG
# RinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+sdFUeEY0qVjPKOWug/G6
# X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0TAQH/BAgwBgEB/wIBADAd
# BgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYDVR0jBBgwFoAU7NfjgtJx
# XWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUF
# BwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGln
# aWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5j
# b20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8EPDA6MDigNqA0hjJo
# dHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNy
# bDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQEL
# BQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WIGjB/T8ObXAZz8OjuhUxj
# aaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+IQhQE7jU/kXjjytJgnn0
# hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8Myb9rEVKChHyfpzee5kH0
# F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2th9y1IsA0QF8dTXqvcnT
# mpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjajV/gxdEkMx1NKU4uHQcKf
# ZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2Lr3ty9qIijanrUR3anzE
# wlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFzeGxcytL5TTLL4ZaoBdqbh
# OhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG7uEBYTptMSbhdhGQDpOX
# gpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+NJpud/v4+7RWsWCiKi9EO
# LLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckTetiSuEtQvLsNz3Qbp7wG
# WqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszWkPZPubdcMIIG7TCCBNWg
# AwIBAgIQCoDvGEuN8QWC0cR2p5V0aDANBgkqhkiG9w0BAQsFADBpMQswCQYDVQQG
# EwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0
# IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hBMjU2IDIwMjUgQ0Ex
# MB4XDTI1MDYwNDAwMDAwMFoXDTM2MDkwMzIzNTk1OVowYzELMAkGA1UEBhMCVVMx
# FzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBTSEEy
# NTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIwMjUgMTCCAiIwDQYJKoZI
# hvcNAQEBBQADggIPADCCAgoCggIBANBGrC0Sxp7Q6q5gVrMrV7pvUf+GcAoB38o3
# zBlCMGMyqJnfFNZx+wvA69HFTBdwbHwBSOeLpvPnZ8ZN+vo8dE2/pPvOx/Vj8Tch
# TySA2R4QKpVD7dvNZh6wW2R6kSu9RJt/4QhguSssp3qome7MrxVyfQO9sMx6ZAWj
# FDYOzDi8SOhPUWlLnh00Cll8pjrUcCV3K3E0zz09ldQ//nBZZREr4h/GI6Dxb2Uo
# yrN0ijtUDVHRXdmncOOMA3CoB/iUSROUINDT98oksouTMYFOnHoRh6+86Ltc5zjP
# KHW5KqCvpSduSwhwUmotuQhcg9tw2YD3w6ySSSu+3qU8DD+nigNJFmt6LAHvH3KS
# uNLoZLc1Hf2JNMVL4Q1OpbybpMe46YceNA0LfNsnqcnpJeItK/DhKbPxTTuGoX7w
# JNdoRORVbPR1VVnDuSeHVZlc4seAO+6d2sC26/PQPdP51ho1zBp+xUIZkpSFA8vW
# doUoHLWnqWU3dCCyFG1roSrgHjSHlq8xymLnjCbSLZ49kPmk8iyyizNDIXj//cOg
# rY7rlRyTlaCCfw7aSUROwnu7zER6EaJ+AliL7ojTdS5PWPsWeupWs7NpChUk555K
# 096V1hE0yZIXe+giAwW00aHzrDchIc2bQhpp0IoKRR7YufAkprxMiXAJQ1XCmnCf
# gPf8+3mnAgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTkO/zy
# Me39/dfzkXFjGVBDz2GM6DAfBgNVHSMEGDAWgBTvb1NK6eQGfHrK4pBW9i/USezL
# TjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwgZUGCCsG
# AQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5j
# b20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdp
# Q2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZTSEEyNTYyMDI1Q0ExLmNy
# dDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGln
# aUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2U0hBMjU2MjAyNUNBMS5j
# cmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9bAcBMA0GCSqGSIb3DQEB
# CwUAA4ICAQBlKq3xHCcEua5gQezRCESeY0ByIfjk9iJP2zWLpQq1b4URGnwWBdEZ
# D9gBq9fNaNmFj6Eh8/YmRDfxT7C0k8FUFqNh+tshgb4O6Lgjg8K8elC4+oWCqnU/
# ML9lFfim8/9yJmZSe2F8AQ/UdKFOtj7YMTmqPO9mzskgiC3QYIUP2S3HQvHG1FDu
# +WUqW4daIqToXFE/JQ/EABgfZXLWU0ziTN6R3ygQBHMUBaB5bdrPbF6MRYs03h4o
# bEMnxYOX8VBRKe1uNnzQVTeLni2nHkX/QqvXnNb+YkDFkxUGtMTaiLR9wjxUxu2h
# ECZpqyU1d0IbX6Wq8/gVutDojBIFeRlqAcuEVT0cKsb+zJNEsuEB7O7/cuvTQasn
# M9AWcIQfVjnzrvwiCZ85EE8LUkqRhoS3Y50OHgaY7T/lwd6UArb+BOVAkg2oOvol
# /DJgddJ35XTxfUlQ+8Hggt8l2Yv7roancJIFcbojBcxlRcGG0LIhp6GvReQGgMgY
# xQbV1S3CrWqZzBt1R9xJgKf47CdxVRd/ndUlQ05oxYy2zRWVFjF7mcr4C34Mj3oc
# CVccAvlKV9jEnstrniLvUxxVZE/rptb7IRE2lskKPIJgbaP5t2nGj/ULLi49xTcB
# ZU8atufk+EMF/cWuiC7POGT75qaL6vdCvHlshtjdNXOCIUjsarfNZzCCB6swggWT
# oAMCAQICEA20PGu9jXp4I/Dy4yL8Si8wDQYJKoZIhvcNAQELBQAwaTELMAkGA1UE
# BhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2Vy
# dCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNIQTM4NCAyMDIxIENB
# MTAeFw0yNTExMTMwMDAwMDBaFw0yODExMTQyMzU5NTlaMIGzMRMwEQYLKwYBBAGC
# NzwCAQMTAkNIMRUwEwYLKwYBBAGCNzwCAQITBFZhdWQxHTAbBgNVBA8MFFByaXZh
# dGUgT3JnYW5pemF0aW9uMRgwFgYDVQQFEw9DSEUtMTEyLjAwMC41NzkxCzAJBgNV
# BAYTAkNIMQ8wDQYDVQQHEwZQcmlsbHkxFjAUBgNVBAoTDU5FWFRoaW5rIFMuQS4x
# FjAUBgNVBAMTDU5FWFRoaW5rIFMuQS4wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw
# ggIKAoICAQDIbNJpFq6aDbvQP7zFPymmt3WRPkvbmnRL4g0imzFNbaj+U/RIGPL0
# 3l9TtYxzraPbwDdidmT5P50mK1JqjF5FzcZEC4kZfMMojY4J/gOylo8jOdLjBscL
# SYwsY+85ZRs2bvxibPXIu4AcZttxBx5XdbVyt2Bd1vtuJGj+wfU1vJu7NB3GHjPy
# 4QE1BFrgidqQTL+1HASQaWwNMetW8B1U1POZjr6uWp6Ga3DF/MAKxpbvU3iVFI8b
# YFpKiVIQ9Namj3DQ7Xj26+fSEP959+bukG8LyObHN5DDkRdpnCSVVMT6qxZ2QGri
# DFkyjOVaGkjJTI6mbKY1JPUJhYVo+7Jqhu+2EFGRy6wSj9wZBubnbwfuvgr/XlH9
# /xQQuX9i/EJvxJRQQyz9IzLtB/DBSoEQvtyCh8Po87k9x9CGYn7k1HxUg1F6oBKa
# kemv3G474RWKZwSEIbwUlygFydEpTDkRTqBDZKOQf3VaUvfUgGmBmEfq1m+cljHj
# fLDBtkDIGaZ7SvyO72q9Uj0HrIWvJDHHIYK84gqhe58vKgaQ0uH9V5BCUT5cLvau
# +1w93iq5AMCTtG96SEyjI/T9Jzh+2sM+PoUwlLNLqYFUvMI+l19X+5UtQnjU5k1c
# ooxqCebatsS642gd3X1VoeNyfJ5FB67b6KGNfrDOXBt5FXgKZAUOHwIDAQABo4IC
# AjCCAf4wHwYDVR0jBBgwFoAUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHQYDVR0OBBYE
# FICGOh06CZAfMG3M4l2zy9590p0OMD0GA1UdIAQ2MDQwMgYFZ4EMAQMwKTAnBggr
# BgEFBQcCARYbaHR0cDovL3d3dy5kaWdpY2VydC5jb20vQ1BTMA4GA1UdDwEB/wQE
# AwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzCBtQYDVR0fBIGtMIGqMFOgUaBPhk1o
# dHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2ln
# bmluZ1JTQTQwOTZTSEEzODQyMDIxQ0ExLmNybDBToFGgT4ZNaHR0cDovL2NybDQu
# ZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2
# U0hBMzg0MjAyMUNBMS5jcmwwgZQGCCsGAQUFBwEBBIGHMIGEMCQGCCsGAQUFBzAB
# hhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wXAYIKwYBBQUHMAKGUGh0dHA6Ly9j
# YWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5n
# UlNBNDA5NlNIQTM4NDIwMjFDQTEuY3J0MAkGA1UdEwQCMAAwDQYJKoZIhvcNAQEL
# BQADggIBALSLCU45H6xjr2Ce9mmr6WtqdnykOcc2s1iqd/UN88f4GlmzVmtA9Dts
# 9j8X77hxHYIAJwWM3NqKi8m55CB5HQBuOC3hOI8bWFdQLBsrAbBAApFh3cjRruGn
# r0mu7wlZo9HLgpM1PEy+hf90+cANYRBITrRkfVT4o2rTc8mmJyYzZM8WVwxGx+wm
# PAuvyjYKsjZNTeJ+miYqwJ/zHjc8LQP+/hYPNmP9ubYpMMkgC49vP9Uir0E4YI2C
# VTbUuhxao4Pmy2MnqhK20lDu50ZCNDNFurYhzXWBhK1cel6Ku4yuT6TlsjNye0AY
# v92FDyAaYH9VZuub7qZBNv/RwmcLUDqqh5rnEdeGMH7KFkUyN63RMEr7hLXV/Tav
# 7K0kWlXKPLIFenfqJnPYceni6PdJS/7WlxnBE0UwgoSoe4HawnEF0YvYBU3uyK+v
# TJWVau5Jptjp94uEQmW6PidWwUWNzVl4LQ7eEZe4ykZ0JLErbGnOeoUoZCeVzpx4
# uUEFiZv1KLeLEkB/8yFIrQp03sVMItqhNJ5mbDOoWW4y3Z8/jduhxVPwJMHhgglK
# 3z3sZr5xrpY3n1Qcqn1xFsI6U7rOrkzyQBw+d/yF82MW3LldZ7tPyU8LTEO89oOC
# EsGFwOfEb/wB7UJ1NulZ1R2nf5erYEnVsOsrGV0X1fYh2OoRQyxWMYIGPjCCBjoC
# AQEwfTBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/
# BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBTaWduaW5nIFJTQTQwOTYg
# U0hBMzg0IDIwMjEgQ0ExAhANtDxrvY16eCPw8uMi/EovMA0GCWCGSAFlAwQCAQUA
# oGowGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEOMAwG
# CisGAQQBgjcCARUwLwYJKoZIhvcNAQkEMSIEICfrAdW9AoLH2+d4Rd4bASLyHQ/o
# dumC79SNiAGe62WvMA0GCSqGSIb3DQEBAQUABIICABo2YKsduikoxWPPSvSCi/jL
# Yphq6x1Fo8zkpVtBquzktHlIXHAHjCaUcRL9E46rZRY+8wEFiplirWGw8oB+aBrd
# cCnKI1H+X2x/GSWDUaujyGtzKspBDteGVya9HC+ZIb2dd4U3KGGGxTC+hr7noYQ0
# Vc8CilfiWSPolFg9g8uX/+q+WSIK8S2lvaUWjlksHuea/A8NyQqnaRMzOKSlCTzY
# G+GD3YMsvoi8MG2Xdu3LApB6+eaoP5vlfp9dajnE8s1I6Sun2JOOLhgNMMtEh9My
# c9k9NJIWIufKBwkeNuwJlmCEEz5mXqfFK8RF9iSPUbCHXSPsbBWUer2IIWUyQ9FI
# c7I+MfKdv/0Cxr6zYxVdguWABT8l3TMrPgakWKXPlFFKNYIwwGLat6oL+dCPp+NM
# IEoil9hCWyXUd2bA0eHWVIiqHgfx5sW4dxwh2B/4AkeGoxra4T5bjYrLQ0BP5jlc
# dobGenotnaAvoYubCcnQStuGgwBW8TJ/O/5xXY2mlwUXbCguCFWPgm7zHP4Qk1d0
# 4hJarN8/ZndeIn0CB+MpmusjQZZist1dMapnZwczZfqnigYCM1j0kEv9JR8/9w9o
# u7AvWB5C9DjX95e/3832qEYL7pq80bZ+iCdfsAv1W1fuSGJwlqiP483UvwuQQNJt
# 0s3TqapYs27eFYKGw1aAoYIDJjCCAyIGCSqGSIb3DQEJBjGCAxMwggMPAgEBMH0w
# aTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQD
# EzhEaWdpQ2VydCBUcnVzdGVkIEc0IFRpbWVTdGFtcGluZyBSU0E0MDk2IFNIQTI1
# NiAyMDI1IENBMQIQCoDvGEuN8QWC0cR2p5V0aDANBglghkgBZQMEAgEFAKBpMBgG
# CSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTI2MDMwOTE1
# NDQwN1owLwYJKoZIhvcNAQkEMSIEILt+jI2zoC373iFFOa8z3h/XO27OiAN4OfwU
# NkeE6Zb4MA0GCSqGSIb3DQEBAQUABIICAAX0WAOMRxVzh9/K3ZaHA3qElEwrQCRm
# s59gRWKZ4h5M+H33TFxoU5uUByZJVsw/KcXhURWOcmBe5cmA9CPMUQxChP2IrIRp
# aCMusorC9kpIzCbc7a7Fy1OjOyEu9dyA4w9HXhusKKAjmbXUpho+N/17w0MjLpH4
# 5zjnMjc2/YLUFyUrCxstbmmiVqo9lkHzN+EZZVm5DLzrytAebPXH5HPMvjWs8bFj
# rUSJ8948NjFK9qIJ4/qnPIJ5f97EKPpmbjlO+7EPixDQnIEi9cpvcRv+YPew3k/q
# /Kzr9geo3OHrrXuhl38DFgobr0IAkYk/SctoFBnP/n/TllhTQK5SWWGZ3QBdZ5V0
# LHZ6lNXApFzyFhRNdd5mDVyAiZcfLM64hw9ZTAQ0FIMu8aDM22T93GJVETw2a4JR
# XGGwYIz1shxxCVc17XJGg6hVwWyW20BsjgzIuYQlI2wfZxpblEVYilv4z9XcYDqx
# TTZPvVhuGlGqQRcC+DNLjRYh0Cl0WYH7NLJC7Fh/+shwNF+xLen5okFKiTp9WNoV
# dQ7OQtkCFe/b7HyMrtpNSEgbGL2ZPJgqz4WWI3GM4LTWIWdN6IK6j7cJOYrXLUs7
# HTPr4VRt1MTjRLtsTD6FhHYMH946AJxl2czExlDZBWLz4+YOJnk9UqMuONHOCMeY
# pFSgi6Cjz14z
# SIG # End signature block