Files
claudetools/tools/Scan-YealinkPhones.ps1
Mike Swanson 92f3dd696f sync: Add Yealink tools and session log for 2026-02-24/25
Session covering YMCS setup, Yealink phone scanner tool development,
and Peaceful Spirit UCG Ultra speed diagnostics (ECM crash-loop, Cox plant issue).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 07:46:44 -07:00

899 lines
34 KiB
PowerShell

<#
.SYNOPSIS
Scans a subnet for Yealink phones and extracts inventory data from their web UI.
.DESCRIPTION
Performs a fast parallel ping sweep of a given CIDR subnet, filters for Yealink
MAC address prefixes via the ARP table, authenticates to each phone's web UI
using digest auth, and extracts MAC, serial number, model, and firmware version.
Results are written to CSV and displayed in the console.
.PARAMETER Subnet
Subnet to scan in CIDR notation (e.g., 192.168.1.0/24).
.PARAMETER Username
Web UI username for the Yealink phones. Default: admin
.PARAMETER Password
Web UI password for the Yealink phones.
.PARAMETER SiteName
Client/site name included in each CSV row for multi-site inventory tracking.
.PARAMETER OutputFile
Path for the output CSV file. Default: yealink_inventory.csv
.PARAMETER Timeout
Timeout in milliseconds for network operations. Default: 1000
.EXAMPLE
.\Scan-YealinkPhones.ps1 -Subnet "192.168.1.0/24" -Password "mypass" -SiteName "ClientHQ"
.EXAMPLE
.\Scan-YealinkPhones.ps1 -Subnet "10.0.5.0/24" -Username "admin" -Password "p@ss" -SiteName "BranchOffice" -OutputFile "C:\inventory\phones.csv" -Timeout 2000
.NOTES
Compatible with PowerShell 5.1 (Windows built-in). No external module dependencies.
Uses runspaces for parallel ping sweep. Supports Yealink digest authentication.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, HelpMessage = "Subnet in CIDR notation, e.g. 192.168.1.0/24")]
[ValidatePattern('^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2}$')]
[string]$Subnet,
[Parameter(Mandatory = $false)]
[string]$Username = "admin",
[Parameter(Mandatory = $true, HelpMessage = "Web UI password for the phones")]
[string]$Password,
[Parameter(Mandatory = $true, HelpMessage = "Client/site name for the CSV output")]
[string]$SiteName,
[Parameter(Mandatory = $false)]
[string]$OutputFile = "yealink_inventory.csv",
[Parameter(Mandatory = $false)]
[ValidateRange(100, 30000)]
[int]$Timeout = 1000
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# ---------------------------------------------------------------------------
# Yealink OUI prefixes (colon-separated, uppercase for comparison)
# ---------------------------------------------------------------------------
$YealinkPrefixes = @("80:5E:C0", "80:5E:0C", "80:5A:35", "00:15:65", "28:6D:97", "24:4B:FE")
# ---------------------------------------------------------------------------
# CIDR Calculation
# ---------------------------------------------------------------------------
function Get-SubnetAddresses {
<#
.SYNOPSIS
Returns all usable host IP addresses for a given CIDR subnet.
#>
param(
[Parameter(Mandatory)]
[string]$CidrSubnet
)
$parts = $CidrSubnet -split '/'
$networkAddress = [System.Net.IPAddress]::Parse($parts[0])
$prefixLength = [int]$parts[1]
if ($prefixLength -lt 8 -or $prefixLength -gt 30) {
throw "Prefix length must be between /8 and /30. Got /$prefixLength."
}
$networkBytes = $networkAddress.GetAddressBytes()
# Convert to UInt32 (big-endian)
$networkUInt = ([uint32]$networkBytes[0] -shl 24) -bor `
([uint32]$networkBytes[1] -shl 16) -bor `
([uint32]$networkBytes[2] -shl 8) -bor `
([uint32]$networkBytes[3])
$hostBits = 32 - $prefixLength
$totalAddresses = [math]::Pow(2, $hostBits)
$subnetMask = ([uint32]::MaxValue) -shl $hostBits -band [uint32]::MaxValue
$networkStart = $networkUInt -band $subnetMask
$addresses = [System.Collections.Generic.List[string]]::new()
# Skip network address (first) and broadcast address (last)
for ($i = 1; $i -lt ($totalAddresses - 1); $i++) {
$ipUInt = $networkStart + $i
$octet1 = ($ipUInt -shr 24) -band 0xFF
$octet2 = ($ipUInt -shr 16) -band 0xFF
$octet3 = ($ipUInt -shr 8) -band 0xFF
$octet4 = $ipUInt -band 0xFF
$addresses.Add("$octet1.$octet2.$octet3.$octet4")
}
return $addresses
}
# ---------------------------------------------------------------------------
# Parallel Ping Sweep using Runspaces
# ---------------------------------------------------------------------------
function Invoke-PingSweep {
<#
.SYNOPSIS
Pings all IPs in parallel using runspaces. Returns list of responding IPs.
#>
param(
[Parameter(Mandatory)]
[System.Collections.Generic.List[string]]$IPAddresses,
[int]$TimeoutMs = 1000,
[int]$ThrottleLimit = 64
)
$runspacePool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(1, $ThrottleLimit)
$runspacePool.Open()
$scriptBlock = {
param([string]$IP, [int]$Timeout)
$pinger = New-Object System.Net.NetworkInformation.Ping
try {
$result = $pinger.Send($IP, $Timeout)
if ($result.Status -eq [System.Net.NetworkInformation.IPStatus]::Success) {
return $IP
}
}
catch {
# Host unreachable or other error — skip silently
}
finally {
$pinger.Dispose()
}
return $null
}
$jobs = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach ($ip in $IPAddresses) {
$ps = [System.Management.Automation.PowerShell]::Create()
$ps.RunspacePool = $runspacePool
[void]$ps.AddScript($scriptBlock)
[void]$ps.AddArgument($ip)
[void]$ps.AddArgument($TimeoutMs)
$handle = $ps.BeginInvoke()
$jobs.Add([PSCustomObject]@{
PowerShell = $ps
Handle = $handle
})
}
$liveHosts = [System.Collections.Generic.List[string]]::new()
$completed = 0
$total = $jobs.Count
foreach ($job in $jobs) {
try {
$result = $job.PowerShell.EndInvoke($job.Handle)
if ($result -and $result[0]) {
$liveHosts.Add($result[0])
}
}
catch {
# Silently skip failed pings
}
finally {
$job.PowerShell.Dispose()
}
$completed++
if ($completed % 50 -eq 0 -or $completed -eq $total) {
$pct = [math]::Round(($completed / $total) * 100)
Write-Host "`r Ping progress: $completed/$total ($pct%)" -NoNewline
}
}
Write-Host ""
$runspacePool.Close()
$runspacePool.Dispose()
return $liveHosts
}
# ---------------------------------------------------------------------------
# ARP Table MAC Lookup
# ---------------------------------------------------------------------------
function Get-ArpMac {
<#
.SYNOPSIS
Retrieves MAC address for an IP from the local ARP table.
Returns MAC in colon-separated uppercase format, or $null if not found.
#>
param(
[Parameter(Mandatory)]
[string]$IPAddress
)
try {
$arpOutput = & arp -a $IPAddress 2>$null
if (-not $arpOutput) { return $null }
foreach ($line in $arpOutput) {
$line = $line.Trim()
# Windows ARP format: "192.168.1.1 80-5e-c0-aa-bb-cc dynamic"
if ($line -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\s+([\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2})') {
$mac = $Matches[1].ToUpper() -replace '-', ':'
return $mac
}
}
}
catch {
# ARP lookup failed — not critical
}
return $null
}
# ---------------------------------------------------------------------------
# Yealink MAC Prefix Check
# ---------------------------------------------------------------------------
function Test-YealinkMac {
<#
.SYNOPSIS
Returns $true if the MAC address belongs to a known Yealink OUI prefix.
#>
param(
[Parameter(Mandatory)]
[string]$Mac
)
$macUpper = $Mac.ToUpper() -replace '-', ':'
$prefix = ($macUpper -split ':')[0..2] -join ':'
return ($YealinkPrefixes -contains $prefix)
}
# ---------------------------------------------------------------------------
# Digest Authentication Helper
# ---------------------------------------------------------------------------
function Set-UnsafeHeaderParsing {
<#
.SYNOPSIS
Enables useUnsafeHeaderParsing to tolerate non-standard HTTP headers
from embedded devices like Yealink phones.
#>
$netAssembly = [System.Reflection.Assembly]::GetAssembly([System.Net.Configuration.SettingsSection])
if ($netAssembly) {
$bindingFlags = [System.Reflection.BindingFlags]::Static -bor
[System.Reflection.BindingFlags]::GetProperty -bor
[System.Reflection.BindingFlags]::NonPublic
$settingsType = $netAssembly.GetType("System.Net.Configuration.SettingsSectionInternal")
if ($settingsType) {
$instance = $settingsType.InvokeMember("Section", $bindingFlags, $null, $null, @())
if ($instance) {
$useUnsafeField = $settingsType.GetField("useUnsafeHeaderParsing",
[System.Reflection.BindingFlags]::NonPublic -bor [System.Reflection.BindingFlags]::Instance)
if ($useUnsafeField) {
$useUnsafeField.SetValue($instance, $true)
}
}
}
}
}
function Invoke-DigestAuthRequest {
<#
.SYNOPSIS
Performs an HTTP GET with digest authentication.
Compatible with PowerShell 5.1.
#>
param(
[Parameter(Mandatory)]
[string]$Uri,
[Parameter(Mandatory)]
[string]$User,
[Parameter(Mandatory)]
[string]$Pass,
[int]$TimeoutMs = 5000
)
# Enable lenient header parsing for Yealink's non-standard HTTP responses
Set-UnsafeHeaderParsing
# Ignore SSL certificate errors (phones use self-signed certs)
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
# Ensure TLS 1.2 support
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 -bor [System.Net.SecurityProtocolType]::Tls11 -bor [System.Net.SecurityProtocolType]::Tls -bor [System.Net.SecurityProtocolType]::Ssl3
$credential = New-Object System.Net.NetworkCredential($User, $Pass)
$credCache = New-Object System.Net.CredentialCache
$credCache.Add([Uri]$Uri, "Digest", $credential)
# Also add Basic in case some models use it
$credCache.Add([Uri]$Uri, "Basic", $credential)
$request = [System.Net.HttpWebRequest]::Create($Uri)
$request.Credentials = $credCache
$request.PreAuthenticate = $false
$request.Timeout = $TimeoutMs
$request.ReadWriteTimeout = $TimeoutMs
$request.Method = "GET"
$request.UserAgent = "Mozilla/5.0 (YealinkScanner)"
try {
$response = $request.GetResponse()
$stream = $response.GetResponseStream()
$reader = New-Object System.IO.StreamReader($stream)
$body = $reader.ReadToEnd()
$reader.Close()
$stream.Close()
$response.Close()
return $body
}
catch [System.Net.WebException] {
$ex = $_.Exception
if ($ex.Response) {
$statusCode = [int]$ex.Response.StatusCode
if ($statusCode -eq 401) {
throw "Authentication failed (HTTP 401)"
}
throw "HTTP error $statusCode"
}
throw "Connection failed: $($ex.Message)"
}
}
# ---------------------------------------------------------------------------
# Yealink Data Extraction
# ---------------------------------------------------------------------------
function Get-YealinkPhoneData {
<#
.SYNOPSIS
Queries a Yealink phone's web UI and extracts inventory data.
Tries the structured data endpoint first, then falls back to the HTML status page.
#>
param(
[Parameter(Mandatory)]
[string]$IPAddress,
[Parameter(Mandatory)]
[string]$User,
[Parameter(Mandatory)]
[string]$Pass,
[int]$TimeoutMs = 5000
)
$phoneData = [PSCustomObject]@{
MAC = ""
Serial = ""
Model = ""
FirmwareVersion = ""
IP = $IPAddress
Success = $false
Error = ""
}
# Enable lenient header parsing and SSL bypass
Set-UnsafeHeaderParsing
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 -bor [System.Net.SecurityProtocolType]::Tls11 -bor [System.Net.SecurityProtocolType]::Tls -bor [System.Net.SecurityProtocolType]::Ssl3
$body = $null
$protocols = @("https", "http")
# Disable Expect: 100-continue globally (Yealink returns 417 otherwise)
[System.Net.ServicePointManager]::Expect100Continue = $false
foreach ($proto in $protocols) {
$baseUrl = "${proto}://$IPAddress"
# --- Method 1: Cookie-based login (T4x/T5x with newer firmware) ---
try {
$cookieContainer = New-Object System.Net.CookieContainer
# Step 1: POST login
$loginUrl = "$baseUrl/servlet?m=mod_listener&p=login&q=login&Ession=0"
$loginRequest = [System.Net.HttpWebRequest]::Create($loginUrl)
$loginRequest.Method = "POST"
$loginRequest.ContentType = "application/x-www-form-urlencoded"
$loginRequest.ServicePoint.Expect100Continue = $false
$loginRequest.CookieContainer = $cookieContainer
$loginRequest.Timeout = $TimeoutMs
$loginRequest.ReadWriteTimeout = $TimeoutMs
$loginRequest.UserAgent = "Mozilla/5.0 (YealinkScanner)"
$loginRequest.AllowAutoRedirect = $true
$loginBody = "username=$User&pwd=$Pass"
$loginBytes = [System.Text.Encoding]::UTF8.GetBytes($loginBody)
$loginRequest.ContentLength = $loginBytes.Length
$reqStream = $loginRequest.GetRequestStream()
$reqStream.Write($loginBytes, 0, $loginBytes.Length)
$reqStream.Close()
$loginResponse = $loginRequest.GetResponse()
$loginReader = New-Object System.IO.StreamReader($loginResponse.GetResponseStream())
$loginResult = $loginReader.ReadToEnd()
$loginReader.Close()
$loginResponse.Close()
# Step 2: Fetch status page with session cookie
$statusUrl = "$baseUrl/servlet?m=mod_data&p=status-status&q=load"
$statusRequest = [System.Net.HttpWebRequest]::Create($statusUrl)
$statusRequest.Method = "GET"
$statusRequest.CookieContainer = $cookieContainer
$statusRequest.Timeout = $TimeoutMs
$statusRequest.ReadWriteTimeout = $TimeoutMs
$statusRequest.UserAgent = "Mozilla/5.0 (YealinkScanner)"
$statusResponse = $statusRequest.GetResponse()
$statusReader = New-Object System.IO.StreamReader($statusResponse.GetResponseStream())
$body = $statusReader.ReadToEnd()
$statusReader.Close()
$statusResponse.Close()
# Check if we got actual data (not a login page)
if ($body -and $body.Length -gt 10 -and $body -notmatch 'authstatus.*none' -and $body -notmatch 'CheckLogin') {
break
}
# Try alternate status endpoint
$statusUrl2 = "$baseUrl/servlet?p=status-status"
$statusRequest2 = [System.Net.HttpWebRequest]::Create($statusUrl2)
$statusRequest2.Method = "GET"
$statusRequest2.CookieContainer = $cookieContainer
$statusRequest2.Timeout = $TimeoutMs
$statusRequest2.ReadWriteTimeout = $TimeoutMs
$statusRequest2.UserAgent = "Mozilla/5.0 (YealinkScanner)"
$statusResponse2 = $statusRequest2.GetResponse()
$statusReader2 = New-Object System.IO.StreamReader($statusResponse2.GetResponseStream())
$body = $statusReader2.ReadToEnd()
$statusReader2.Close()
$statusResponse2.Close()
if ($body -and $body.Length -gt 10 -and $body -notmatch 'authstatus.*none' -and $body -notmatch 'CheckLogin') {
break
}
$body = $null
}
catch {
$phoneData.Error = $_.Exception.Message
$body = $null
continue
}
# --- Method 2: Digest auth fallback (older firmware) ---
$endpoints = @(
"$baseUrl/servlet?m=mod_data&p=status-status&q=load",
"$baseUrl/servlet?p=status-status"
)
foreach ($endpoint in $endpoints) {
try {
$body = Invoke-DigestAuthRequest -Uri $endpoint -User $User -Pass $Pass -TimeoutMs $TimeoutMs
if ($body -and $body.Length -gt 10 -and $body -notmatch 'authstatus.*none' -and $body -notmatch 'CheckLogin') {
break
}
$body = $null
}
catch {
$phoneData.Error = $_.Exception.Message
$body = $null
continue
}
}
if ($body) { break }
}
if (-not $body) {
if (-not $phoneData.Error) {
$phoneData.Error = "No valid response from any status endpoint"
}
return $phoneData
}
# DEBUG: dump raw response to temp file for inspection
$debugFile = Join-Path $env:TEMP "yealink_debug_$($IPAddress -replace '\.','_').txt"
$body | Out-File -FilePath $debugFile -Encoding UTF8 -Force
Write-Host " DEBUG: Raw response saved to $debugFile" -ForegroundColor DarkGray
# --- Parse structured JSON response (mod_data endpoint) ---
try {
# Attempt JSON parse first (mod_data endpoint returns JSON on many models)
$jsonData = $body | ConvertFrom-Json -ErrorAction Stop
# Different models use different JSON field names.
# Common patterns observed across T2x/T4x/T5x series:
$macFields = @("MacAddress", "MAC", "mac", "Mac_Address", "MACAddress")
$serialFields = @("SerialNumber", "Serial", "serial", "Machine_ID", "MachineID", "SN")
$modelFields = @("ModelName", "Model", "model", "ProductName", "Product", "DeviceModel")
$fwFields = @("FirmwareVersion", "Firmware", "firmware", "FWVersion", "fw_version", "SoftwareVersion")
foreach ($field in $macFields) {
$val = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
if ($val -and $val.Value) { $phoneData.MAC = ($val.Value -replace '-', ':').ToUpper(); break }
}
foreach ($field in $serialFields) {
$val = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
if ($val -and $val.Value) { $phoneData.Serial = [string]$val.Value; break }
}
foreach ($field in $modelFields) {
$val = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
if ($val -and $val.Value) { $phoneData.Model = [string]$val.Value; break }
}
foreach ($field in $fwFields) {
$val = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
if ($val -and $val.Value) { $phoneData.FirmwareVersion = [string]$val.Value; break }
}
# Some models nest data under a "body" or "data" property
$nestedContainers = @("body", "data", "Body", "Data", "status")
foreach ($container in $nestedContainers) {
$nested = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $container } | Select-Object -First 1
if ($nested -and $nested.Value -and $nested.Value -is [PSCustomObject]) {
$obj = $nested.Value
if (-not $phoneData.MAC) {
foreach ($field in $macFields) {
$val = $obj.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
if ($val -and $val.Value) { $phoneData.MAC = ($val.Value -replace '-', ':').ToUpper(); break }
}
}
if (-not $phoneData.Serial) {
foreach ($field in $serialFields) {
$val = $obj.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
if ($val -and $val.Value) { $phoneData.Serial = [string]$val.Value; break }
}
}
if (-not $phoneData.Model) {
foreach ($field in $modelFields) {
$val = $obj.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
if ($val -and $val.Value) { $phoneData.Model = [string]$val.Value; break }
}
}
if (-not $phoneData.FirmwareVersion) {
foreach ($field in $fwFields) {
$val = $obj.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
if ($val -and $val.Value) { $phoneData.FirmwareVersion = [string]$val.Value; break }
}
}
}
}
}
catch {
# Not JSON — fall through to HTML/text parsing
}
# --- Fallback: Parse HTML/text response ---
if (-not $phoneData.MAC -or -not $phoneData.Serial -or -not $phoneData.Model -or -not $phoneData.FirmwareVersion) {
# MAC Address patterns in HTML
if (-not $phoneData.MAC) {
if ($body -match '(?i)(?:mac\s*(?:address)?|MAC)\s*[:=]\s*([0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2})') {
$phoneData.MAC = ($Matches[1] -replace '-', ':').ToUpper()
}
}
# Serial / Machine ID
if (-not $phoneData.Serial) {
if ($body -match '(?i)(?:serial\s*(?:number)?|machine\s*id|SN)\s*[:=]\s*([A-Za-z0-9]+)') {
$phoneData.Serial = $Matches[1]
}
}
# Model
if (-not $phoneData.Model) {
# Look for Yealink model patterns like T46S, T54W, SIP-T48G, VP59, CP920, etc.
if ($body -match '(?i)(?:model|product\s*name|device\s*model)\s*[:=]\s*((?:SIP-)?[A-Za-z]{1,4}[\-]?[0-9]{2,4}[A-Za-z]?)') {
$phoneData.Model = $Matches[1]
}
elseif ($body -match '(?i)(Yealink\s+(?:SIP-)?[A-Za-z]{1,4}[\-]?[0-9]{2,4}[A-Za-z]?)') {
$phoneData.Model = $Matches[1]
}
}
# Firmware Version
if (-not $phoneData.FirmwareVersion) {
if ($body -match '(?i)(?:firmware|software)\s*(?:version)?\s*[:=]\s*([0-9]+\.[0-9]+\.[0-9]+[.\-][0-9A-Za-z.]+)') {
$phoneData.FirmwareVersion = $Matches[1]
}
elseif ($body -match '(?i)(?:firmware|software)\s*(?:version)?\s*[:=]\s*(\S+)') {
$phoneData.FirmwareVersion = $Matches[1]
}
}
# Try parsing HTML table rows (common Yealink status page format):
# <tr><td>Label</td><td>Value</td></tr>
$tablePattern = '<tr[^>]*>\s*<td[^>]*>\s*(?<label>[^<]+)\s*</td>\s*<td[^>]*>\s*(?<value>[^<]+)\s*</td>\s*</tr>'
$tableMatches = [regex]::Matches($body, $tablePattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
foreach ($m in $tableMatches) {
$label = $m.Groups['label'].Value.Trim()
$value = $m.Groups['value'].Value.Trim()
if (-not $phoneData.MAC -and $label -match '(?i)mac') {
$phoneData.MAC = ($value -replace '-', ':').ToUpper()
}
if (-not $phoneData.Serial -and $label -match '(?i)(serial|machine)') {
$phoneData.Serial = $value
}
if (-not $phoneData.Model -and $label -match '(?i)(model|product)') {
$phoneData.Model = $value
}
if (-not $phoneData.FirmwareVersion -and $label -match '(?i)(firmware|software)') {
$phoneData.FirmwareVersion = $value
}
}
}
# If we got at least one meaningful field, consider it a success
if ($phoneData.MAC -or $phoneData.Serial -or $phoneData.Model -or $phoneData.FirmwareVersion) {
$phoneData.Success = $true
$phoneData.Error = ""
}
elseif (-not $phoneData.Error) {
$phoneData.Error = "Could not parse any fields from status page response"
}
return $phoneData
}
# ---------------------------------------------------------------------------
# CSV Output
# ---------------------------------------------------------------------------
function Export-PhoneInventory {
<#
.SYNOPSIS
Appends phone inventory data to a CSV file. Creates the file with headers if it does not exist.
#>
param(
[Parameter(Mandatory)]
[array]$PhoneRecords,
[Parameter(Mandatory)]
[string]$FilePath,
[Parameter(Mandatory)]
[string]$Site
)
$csvRows = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach ($phone in $PhoneRecords) {
$csvRows.Add([PSCustomObject]@{
MAC = $phone.MAC
Serial = $phone.Serial
Model = $phone.Model
FirmwareVersion = $phone.FirmwareVersion
IP = $phone.IP
SiteName = $Site
})
}
$fileExists = Test-Path -Path $FilePath -PathType Leaf
if ($fileExists) {
# Append without header
$csvRows | Export-Csv -Path $FilePath -NoTypeInformation -Append -Force
}
else {
$csvRows | Export-Csv -Path $FilePath -NoTypeInformation -Force
}
}
# ===========================================================================
# MAIN EXECUTION
# ===========================================================================
$scriptStartTime = Get-Date
Write-Host ""
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host " Yealink Phone Scanner" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host " Subnet: $Subnet"
Write-Host " Site: $SiteName"
Write-Host " Output: $OutputFile"
Write-Host " Timeout: ${Timeout}ms"
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host ""
# --- Step 1: Calculate subnet addresses ---
Write-Host "[INFO] Calculating IP addresses for $Subnet..." -ForegroundColor Yellow
try {
$ipList = Get-SubnetAddresses -CidrSubnet $Subnet
}
catch {
Write-Host "[ERROR] Invalid subnet: $_" -ForegroundColor Red
exit 1
}
Write-Host "[OK] $($ipList.Count) host addresses in range." -ForegroundColor Green
Write-Host ""
# --- Step 2: Ping sweep to populate ARP table ---
Write-Host "[INFO] Pinging $($ipList.Count) addresses to populate ARP table..." -ForegroundColor Yellow
# Use ping in batches — send async pings using .NET Ping.SendPingAsync for speed
$pingTasks = [System.Collections.Generic.List[object]]::new()
$batchSize = 100
for ($i = 0; $i -lt $ipList.Count; $i += $batchSize) {
$batch = $ipList[$i..[math]::Min($i + $batchSize - 1, $ipList.Count - 1)]
foreach ($ip in $batch) {
$pinger = New-Object System.Net.NetworkInformation.Ping
try {
$task = $pinger.SendPingAsync($ip, $Timeout)
$pingTasks.Add(@{ Task = $task; Pinger = $pinger })
}
catch {
$pinger.Dispose()
}
}
# Wait for this batch to finish
foreach ($pt in $pingTasks) {
try { [void]$pt.Task.Wait(($Timeout + 500)) } catch {}
$pt.Pinger.Dispose()
}
$pingTasks.Clear()
$done = [math]::Min($i + $batchSize, $ipList.Count)
$pct = [math]::Round(($done / $ipList.Count) * 100)
Write-Host "`r Ping progress: $done/$($ipList.Count) ($pct%)" -NoNewline
}
Write-Host ""
Write-Host "[OK] Ping sweep complete." -ForegroundColor Green
Write-Host ""
# --- Step 3: Parse ARP table for Yealink MACs within our subnet ---
Write-Host "[INFO] Scanning ARP table for Yealink MAC prefixes..." -ForegroundColor Yellow
$yealinkDevices = [System.Collections.Generic.List[PSCustomObject]]::new()
# Build a HashSet of IPs in our target subnet for fast lookup
$subnetIPs = [System.Collections.Generic.HashSet[string]]::new()
foreach ($ip in $ipList) { [void]$subnetIPs.Add($ip) }
# Parse the full ARP table
$arpLines = & arp -a 2>$null
foreach ($line in $arpLines) {
$line = $line.Trim()
if ($line -match '^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s+([\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2})') {
$ip = $Matches[1]
$mac = $Matches[2].ToUpper() -replace '-', ':'
if ($subnetIPs.Contains($ip) -and (Test-YealinkMac -Mac $mac)) {
$yealinkDevices.Add([PSCustomObject]@{
IP = $ip
MAC = $mac
})
Write-Host " Found: $ip -> $mac" -ForegroundColor Cyan
}
}
}
# Also try Get-NetNeighbor as a fallback (more reliable on Windows 10/11)
try {
$neighbors = Get-NetNeighbor -State Reachable,Stale,Delay,Probe -ErrorAction SilentlyContinue
foreach ($n in $neighbors) {
$ip = $n.IPAddress
$mac = ($n.LinkLayerAddress -replace '-', ':').ToUpper()
if ($subnetIPs.Contains($ip) -and (Test-YealinkMac -Mac $mac)) {
# Avoid duplicates
$already = $yealinkDevices | Where-Object { $_.IP -eq $ip }
if (-not $already) {
$yealinkDevices.Add([PSCustomObject]@{
IP = $ip
MAC = $mac
})
Write-Host " Found: $ip -> $mac (via NetNeighbor)" -ForegroundColor Cyan
}
}
}
}
catch {
Write-Host " [INFO] Get-NetNeighbor not available, using ARP table only." -ForegroundColor DarkGray
}
Write-Host "[OK] Found $($yealinkDevices.Count) Yealink devices." -ForegroundColor Green
Write-Host ""
if ($yealinkDevices.Count -eq 0) {
Write-Host "[WARNING] No Yealink devices detected on this subnet. Exiting." -ForegroundColor DarkYellow
exit 0
}
# Display detected devices
Write-Host " Detected Yealink devices:" -ForegroundColor Cyan
foreach ($dev in $yealinkDevices) {
Write-Host " $($dev.IP) ($($dev.MAC))"
}
Write-Host ""
# --- Step 4: Query each Yealink phone's web UI ---
Write-Host "[INFO] Querying Yealink phone web UIs for inventory data..." -ForegroundColor Yellow
Write-Host ""
$successfulScrapes = [System.Collections.Generic.List[PSCustomObject]]::new()
$failedScrapes = [System.Collections.Generic.List[PSCustomObject]]::new()
$deviceIndex = 0
foreach ($device in $yealinkDevices) {
$deviceIndex++
Write-Host " [$deviceIndex/$($yealinkDevices.Count)] Querying $($device.IP) ($($device.MAC))..." -NoNewline
$phoneData = Get-YealinkPhoneData -IPAddress $device.IP -User $Username -Pass $Password -TimeoutMs ($Timeout * 5)
# If we didn't get MAC from the web UI, use the ARP MAC
if (-not $phoneData.MAC) {
$phoneData.MAC = $device.MAC
}
if ($phoneData.Success) {
$successfulScrapes.Add($phoneData)
Write-Host " [OK] $($phoneData.Model) / $($phoneData.Serial)" -ForegroundColor Green
}
else {
$failedScrapes.Add([PSCustomObject]@{
IP = $device.IP
MAC = $device.MAC
Error = $phoneData.Error
})
Write-Host " [FAILED] $($phoneData.Error)" -ForegroundColor Red
}
}
Write-Host ""
# --- Step 5: Output results ---
if ($successfulScrapes.Count -gt 0) {
# Write to CSV
Write-Host "[INFO] Writing $($successfulScrapes.Count) records to $OutputFile..." -ForegroundColor Yellow
try {
Export-PhoneInventory -PhoneRecords $successfulScrapes -FilePath $OutputFile -Site $SiteName
Write-Host "[OK] CSV updated: $OutputFile" -ForegroundColor Green
}
catch {
Write-Host "[ERROR] Failed to write CSV: $_" -ForegroundColor Red
}
# Display results table
Write-Host ""
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host " Scan Results" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host ""
$successfulScrapes | ForEach-Object {
[PSCustomObject]@{
IP = $_.IP
MAC = $_.MAC
Model = $_.Model
Serial = $_.Serial
FirmwareVersion = $_.FirmwareVersion
}
} | Format-Table -AutoSize | Out-String | Write-Host
}
# Report failures
if ($failedScrapes.Count -gt 0) {
Write-Host " Failed devices:" -ForegroundColor Red
foreach ($fail in $failedScrapes) {
Write-Host " $($fail.IP) ($($fail.MAC)): $($fail.Error)" -ForegroundColor Red
}
Write-Host ""
}
# --- Summary ---
$elapsed = (Get-Date) - $scriptStartTime
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host " Summary" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host " Site: $SiteName"
Write-Host " Subnet: $Subnet"
Write-Host " IPs scanned: $($ipList.Count)"
Write-Host " Yealink detected: $($yealinkDevices.Count)"
Write-Host " Successfully scraped: $($successfulScrapes.Count)" -ForegroundColor Green
Write-Host " Failed: $($failedScrapes.Count)" -ForegroundColor $(if ($failedScrapes.Count -gt 0) { "Red" } else { "Green" })
Write-Host " Elapsed time: $([math]::Round($elapsed.TotalSeconds, 1))s"
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host ""