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>
This commit is contained in:
898
tools/Scan-YealinkPhones.ps1
Normal file
898
tools/Scan-YealinkPhones.ps1
Normal file
@@ -0,0 +1,898 @@
|
||||
<#
|
||||
.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 ""
|
||||
Reference in New Issue
Block a user