Providers/Hp.ps1

function Send-CdpCommand {
  <#
  .SYNOPSIS
    Sends a command to the Chrome DevTools Protocol (CDP) and waits for the response.
 
  .PARAMETER WebSocket
    The WebSocket connection to the Chrome DevTools Protocol.
 
  .PARAMETER Id
    The unique identifier for the CDP command.
 
  .PARAMETER Method
    The CDP method to invoke.
 
  .PARAMETER Params
    A hashtable of parameters to pass to the CDP method.
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    [System.Net.WebSockets.ClientWebSocket] $WebSocket,

    [Parameter(Mandatory)]
    [int] $Id,

    [Parameter(Mandatory)]
    [string] $Method,

    [hashtable] $Params = @{}
  )

  $cmd     = @{ id = $Id; method = $Method; params = $Params } | ConvertTo-Json -Depth 10 -Compress
  $sendBuf = [System.Text.Encoding]::UTF8.GetBytes($cmd)
  $segment = [System.ArraySegment[byte]]::new($sendBuf)
  $WebSocket.SendAsync(
    $segment,
    [System.Net.WebSockets.WebSocketMessageType]::Text,
    $true,
    [System.Threading.CancellationToken]::None
  ).GetAwaiter().GetResult()

  $recvBuf = [byte[]]::new(1MB)
  $sw      = [System.Diagnostics.Stopwatch]::StartNew()
  $found   = $null

  while ($sw.Elapsed.TotalSeconds -lt 60 -and $null -eq $found) {
    $accumulated = ""
    do {
      $seg    = [System.ArraySegment[byte]]::new($recvBuf)
      $result = $WebSocket.ReceiveAsync($seg, [System.Threading.CancellationToken]::None).GetAwaiter().GetResult()
      $accumulated += [System.Text.Encoding]::UTF8.GetString($recvBuf, 0, $result.Count)
    } while (-not $result.EndOfMessage)

    if ($accumulated -notmatch "`"id`"\s*:\s*$Id\b") { continue }

    try {
      $json = $accumulated | ConvertFrom-Json -ErrorAction Stop
    } catch {
      continue
    }

    foreach ($item in @($json)) {
      if ($null -eq $item) { continue }
      $idProp = $item.PSObject.Properties["id"]
      if ($null -ne $idProp -and [int]$idProp.Value -eq $Id) {
        $found = $item
        break
      }
    }
  }

  if ($null -eq $found) {
    throw "CDP command '$Method' (id=$Id) timed out."
  }

  return $found
}

function Get-CdpEvalValue {
  <#
  .SYNOPSIS
    Extracts the 'value' from a CDP Runtime.evaluate response, handling nested structures.
  .PARAMETER CdpResponse
    The response object returned from a Runtime.evaluate CDP command.
  #>

  param($CdpResponse)

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

  if ($CdpResponse -is [System.Array]) {
    $CdpResponse = $CdpResponse | Where-Object {
      $null -ne $_ -and $null -ne $_.PSObject.Properties["result"]
    } | Select-Object -First 1
    if ($null -eq $CdpResponse) { return $null }
  }

  $resultProp = $CdpResponse.PSObject.Properties["result"]
  if ($null -eq $resultProp) { return $null }

  $inner = $resultProp.Value
  if ($null -eq $inner) { return $null }

  $innerResultProp = $inner.PSObject.Properties["result"]
  if ($null -eq $innerResultProp) { return $null }

  $resultObj = $innerResultProp.Value
  if ($null -eq $resultObj) { return $null }

  $valueProp = $resultObj.PSObject.Properties["value"]
  if ($null -eq $valueProp) { return $null }

  return $valueProp.Value
}

function Get-SafeProp {
  <#
  .SYNOPSIS
    Safely reads a property from a PSObject (StrictMode compatible).
 
  .PARAMETER Object
    The object from which to read the property.
 
  .PARAMETER Name
    The name of the property to read.
  #>

  param($Object, [string]$Name)
  if ($null -eq $Object) { return $null }
  $prop = $Object.PSObject.Properties[$Name]
  if ($null -eq $prop) { return $null }
  return $prop.Value
}

function Get-HpWarranty {
  <#
    .SYNOPSIS
      Retrieves HP warranty information for a given serial number.
 
    .DESCRIPTION
      Uses a headless Chromium browser via CDP to:
      1. Navigate to the HP warranty page
      2. Obtain a reCAPTCHA Enterprise token
      3. Call the HP product search API (from the browser session)
      4. Call the HP warranty API (from the same browser session)
      All API calls are made via fetch() inside the browser to maintain
      session/cookie coherence with the reCAPTCHA token.
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    [string] $Serial,

    [Parameter()]
    [int] $TimeoutSeconds = 60
  )

  $browser = Get-ChromiumPath
  $proc    = $null
  $ws      = $null
  $msgId   = 0

  Write-Verbose "Launching headless browser (CDP) for HP warranty: $browser"

  $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
  $listener.Start()
  $port = $listener.LocalEndpoint.Port
  $listener.Stop()

  $browserArgs = @(
    "--headless",
    "--disable-gpu",
    "--no-first-run",
    "--no-default-browser-check",
    "--disable-extensions",
    "--remote-debugging-port=$port",
    "about:blank"
  )

  try {
    $proc = Start-Process -FilePath $browser -ArgumentList $browserArgs -PassThru -WindowStyle Hidden

    $cdpBase  = "http://localhost:$port"
    $cdpReady = $false
    $sw       = [System.Diagnostics.Stopwatch]::StartNew()
    while (-not $cdpReady -and $sw.Elapsed.TotalSeconds -lt 15) {
      Start-Sleep -Milliseconds 300
      try {
        [void](Invoke-RestMethod -Uri "$cdpBase/json/version" -ErrorAction Stop)
        $cdpReady = $true
      } catch { }
    }
    if (-not $cdpReady) { throw "CDP endpoint not available on port $port." }

    $targets    = Invoke-RestMethod -Uri "$cdpBase/json" -ErrorAction Stop
    $pageTarget = $targets | Where-Object { $_.type -eq "page" } | Select-Object -First 1
    if (-not $pageTarget) { throw "No page target found via CDP." }

    $ws = [System.Net.WebSockets.ClientWebSocket]::new()
    [void]($ws.ConnectAsync([Uri]$pageTarget.webSocketDebuggerUrl, [System.Threading.CancellationToken]::None).GetAwaiter().GetResult())

    $msgId++
    [void](Send-CdpCommand -WebSocket $ws -Id $msgId -Method "Page.enable")

    Write-Verbose "Navigating to HP warranty page..."
    $msgId++
    [void](Send-CdpCommand -WebSocket $ws -Id $msgId -Method "Page.navigate" -Params @{
      url = "https://support.hp.com/us-en/check-warranty"
    })

    Write-Verbose "Waiting for page to load..."
    Start-Sleep -Seconds 6

    # Step 1: Get product info via fetch()
    Write-Verbose "Fetching product info for serial: $Serial"
    $jsProductInfo = @"
(function() {
  return fetch('https://support.hp.com/wcc-services/searchresult/us-en?q=$Serial&context=pdp&authState=anonymous&template=WarrantyLanding')
    .then(function(r) { return r.json(); })
    .then(function(data) { return JSON.stringify(data); });
})()
"@


    $msgId++
    $piResult = Send-CdpCommand -WebSocket $ws -Id $msgId -Method "Runtime.evaluate" -Params @{
      expression    = $jsProductInfo
      returnByValue = $true
      awaitPromise  = $true
    }

    $piJson   = Get-CdpEvalValue -CdpResponse $piResult
    $piData   = $null
    $prodName = $null
    $prodNum  = ""

    if ($piJson) {
      try {
        $piParsed = $piJson | ConvertFrom-Json -ErrorAction Stop
        $vrData   = Get-SafeProp (Get-SafeProp (Get-SafeProp $piParsed "data") "verifyResponse") "data"
        if ($null -ne $vrData) {
          $piData   = $vrData
          $prodName = Get-SafeProp $vrData "productName"
          $prodNum  = Get-SafeProp $vrData "productNumber"
          if ($null -eq $prodNum) { $prodNum = "" }
        }
      } catch {
        Write-Verbose "Failed to parse product info: $_"
      }
    }

    Write-Verbose "Product: $prodName ($prodNum)"

    # Step 2: Get reCAPTCHA token
    Write-Verbose "Obtaining reCAPTCHA Enterprise token..."
    $jsRecaptcha = @"
(function() {
  return new Promise(function(resolve, reject) {
    var maxMs = 30000;
    var start = Date.now();
    var check = setInterval(function() {
      if (typeof grecaptcha !== 'undefined' &&
          typeof grecaptcha.enterprise !== 'undefined' &&
          typeof grecaptcha.enterprise.execute === 'function') {
        clearInterval(check);
        grecaptcha.enterprise.execute(
          '6LfX93IaAAAAAKlH_84kr8WSMGbZ-qDaxJxNzrnB',
          { action: 'checkWarranty' }
        ).then(resolve).catch(function(e) { reject('execute failed: ' + e); });
      } else if (Date.now() - start > maxMs) {
        clearInterval(check);
        reject('grecaptcha.enterprise not available');
      }
    }, 500);
  });
})()
"@


    $msgId++
    $captchaResult = Send-CdpCommand -WebSocket $ws -Id $msgId -Method "Runtime.evaluate" -Params @{
      expression    = $jsRecaptcha
      returnByValue = $true
      awaitPromise  = $true
    }

    $captchaToken = Get-CdpEvalValue -CdpResponse $captchaResult
    if (-not $captchaToken -or $captchaToken -isnot [string] -or $captchaToken.Length -le 20) {
      throw "Failed to obtain reCAPTCHA token."
    }
    Write-Verbose "reCAPTCHA token obtained."

    # Step 3: Call warranty API via fetch()
    $tzOff     = [System.TimeZoneInfo]::Local.GetUtcOffset([datetime]::Now)
    $utcPrefix = if ($tzOff.TotalMinutes -lt 0) { "N" } else { "P" }
    $utcOffset = "{0}{1:D2}{2:D2}" -f $utcPrefix, [math]::Abs($tzOff.Hours), [math]::Abs($tzOff.Minutes)

    $jsWarranty = @"
(function() {
  var payload = {
    cc: 'us',
    lc: 'en',
    utcOffset: '$utcOffset',
    devices: [{
      serialNumber: '$Serial',
      productNumber: '$prodNum',
      displayProductNumber: '$prodNum',
      countryOfPurchase: 'us'
    }],
    captchaToken: '$captchaToken'
  };
 
  return fetch('https://support.hp.com/wcc-services/profile/devices/warranty/specs?authState=anonymous&template=WarrantyLanding', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload)
  })
  .then(function(r) { return r.json(); })
  .then(function(data) { return JSON.stringify(data); });
})()
"@


    Write-Verbose "Calling HP warranty API..."
    $msgId++
    $warResult = Send-CdpCommand -WebSocket $ws -Id $msgId -Method "Runtime.evaluate" -Params @{
      expression    = $jsWarranty
      returnByValue = $true
      awaitPromise  = $true
    }

    $warJson = Get-CdpEvalValue -CdpResponse $warResult

    try {
      [void]($ws.CloseAsync(
        [System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure,
        "done",
        [System.Threading.CancellationToken]::None
      ).GetAwaiter().GetResult())
    } catch { }

  } finally {
    if ($ws)   { try { $ws.Dispose()   } catch { } }
    if ($proc -and -not $proc.HasExited) {
      try { $proc.Kill() } catch { }
      try { $proc.WaitForExit(5000) } catch { }
    }
    if ($proc) { try { $proc.Dispose() } catch { } }
  }

  # ==================================================================
  # Parse warranty response
  # Structure: data.devices[0].warranty.data (warranty details)
  # data.devices[0].productSpecs.data (product details)
  # ==================================================================
  $warData = $null
  if ($warJson) {
    try { $warData = $warJson | ConvertFrom-Json -ErrorAction Stop } catch { }
  }

  $apiDevice    = $null
  $warrantyData = $null
  $specsData    = $null

  if ($null -ne $warData) {
    $devicesArr = Get-SafeProp (Get-SafeProp $warData "data") "devices"
    if ($null -ne $devicesArr) {
      $devicesArr = @($devicesArr)
      if ($devicesArr.Count -gt 0) {
        $apiDevice = $devicesArr[0]
      }
    }
  }

  if ($null -ne $apiDevice) {
    $warrantyData = Get-SafeProp (Get-SafeProp $apiDevice "warranty") "data"
    $specsData    = Get-SafeProp (Get-SafeProp $apiDevice "productSpecs") "data"
  }

  # Model name (prefer productSpecs.productSeriesName, fallback to search result)
  $modelName = Get-SafeProp $specsData "productSeriesName"
  if (-not $modelName) { $modelName = Get-SafeProp $specsData "productName" }
  if (-not $modelName) { $modelName = $prodName }

  # Product number
  $productNum = Get-SafeProp $specsData "productNumber"
  if (-not $productNum) { $productNum = Get-SafeProp $warrantyData "productNumber" }
  if (-not $productNum) { $productNum = $prodNum }

  # Country
  $metaCountry = "us"
  $countries = Get-SafeProp $warrantyData "countries"
  if ($countries) {
    # HP returns comma-separated, take first
    $metaCountry = ($countries -split ",")[0].Trim().ToLower()
  }

  # Warranties (from entitlements array)
  $warrantyList  = @()
  $entitlements  = Get-SafeProp $warrantyData "entitlements"

  if ($null -ne $entitlements) {
    $entitlements = @($entitlements)
  }
  if ($null -ne $entitlements -and $entitlements.Count -gt 0) {
    foreach ($e in $entitlements) {
      $wStart  = $null
      $wEnd    = $null
      $wStatus = "unknown"

      $startRaw = Get-SafeProp $e "warrantyStartDate"
      $endRaw   = Get-SafeProp $e "warrantyEndDate"

      if ($startRaw) {
        try {
          $wStart = ([datetime]::Parse($startRaw, [Globalization.CultureInfo]::InvariantCulture)).ToString("yyyy-MM-dd")
        } catch { $wStart = $startRaw }
      }

      if ($endRaw) {
        try {
          $parsedEnd = [datetime]::Parse($endRaw, [Globalization.CultureInfo]::InvariantCulture)
          $wEnd    = $parsedEnd.ToString("yyyy-MM-dd")
          $wStatus = if ($parsedEnd.Date -ge (Get-Date).Date) { "active" } else { "expired" }
        } catch { $wEnd = $endRaw }
      }

      $wType = Get-SafeProp $e "warrantyTypeDescription"
      if (-not $wType) { $wType = Get-SafeProp $e "serviceType" }
      if (-not $wType) { $wType = "Standard" }

      $wService = Get-SafeProp $e "serviceType"
      if (-not $wService) { $wService = "" }

      $warrantyList += [pscustomobject]@{
        name   = $wType
        start  = $wStart
        end    = $wEnd
        status = $wStatus
        notes  = $wService
      }
    }
  }

  # Fallback: use top-level warranty dates if no entitlements
  if ($warrantyList.Count -eq 0 -and $null -ne $warrantyData) {
    $wStart  = $null
    $wEnd    = $null
    $wStatus = "unknown"

    $startRaw = Get-SafeProp $warrantyData "warrantyStartDate"
    $endRaw   = Get-SafeProp $warrantyData "warrantyEndDate"

    if ($startRaw) {
      try {
        $wStart = ([datetime]::Parse($startRaw, [Globalization.CultureInfo]::InvariantCulture)).ToString("yyyy-MM-dd")
      } catch { $wStart = $startRaw }
    }

    if ($endRaw) {
      try {
        $parsedEnd = [datetime]::Parse($endRaw, [Globalization.CultureInfo]::InvariantCulture)
        $wEnd    = $parsedEnd.ToString("yyyy-MM-dd")
        $wStatus = if ($parsedEnd.Date -ge (Get-Date).Date) { "active" } else { "expired" }
      } catch { $wEnd = $endRaw }
    }

    $warrantyList += [pscustomobject]@{
      name   = Get-SafeProp $warrantyData "warrantyTypeDescription"
      start  = $wStart
      end    = $wEnd
      status = $wStatus
      notes  = Get-SafeProp $warrantyData "serviceType"
    }
  }

  if ($warrantyList.Count -eq 0) {
    $warrantyList += [pscustomobject]@{
      name   = "Standard"
      start  = $null
      end    = $null
      status = "unknown"
      notes  = ""
    }
  }

  [pscustomobject]@{
    manufacturer = "HP"
    model        = $modelName
    serial       = $Serial
    product      = $productNum
    checked_at   = (Get-Date).ToUniversalTime().ToString("o")
    source       = "https://support.hp.com"
    warranties   = $warrantyList
    meta         = [pscustomobject]@{
      region  = "us-en"
      country = $metaCountry
      url     = "https://support.hp.com/wcc-services/profile/devices/warranty/specs"
      method  = "ChromiumHeadless"
    }
  }
}