Worker: pings every tracked device on a presence sweep (hourly in business hours plus midnight) and records last online; inventory reports whether a device was found in AD and SCCM; monitors from WmiMonitorID; snapshots of the last reachable state replace "Cached"; newly tracked devices are swept and published first; totals count source devices only; per-type timing in Worker-State. Front end: details dialog shows presence, reasons instead of N/A, "Waiting for worker...", monitors and snapshot times, and refreshes in place; Custom View gains Days Offline, Last Online, Found In and monitor columns; manual pair changes show immediately; sync dialog shows timing by type. Custom View gets a large scrollbar; "Exactly ticked" filter.
509 lines
22 KiB
PowerShell
509 lines
22 KiB
PowerShell
# =========================================================================================
|
|
# SCCM / ConfigMgr provider
|
|
#
|
|
# Talks to real infrastructure: the ConfigMgr PowerShell module (computer associations),
|
|
# the site database over SQL (inventory + installed software), Active Directory (OU and
|
|
# group membership), DNS/ICMP (online checks) and the devices' admin shares over SMB
|
|
# (free disk space and USMT progress logs).
|
|
#
|
|
# Requirements: Windows PowerShell 5.1, the ConfigMgr console (ConfigurationManager
|
|
# module), read access to the site database, and admin-share access to the devices.
|
|
#
|
|
# Every provider implements the same functions:
|
|
# Initialize-Provider, Open-ProviderCycle, Close-ProviderCycle, pingDevice,
|
|
# Resolve-DeviceIP, Get-DiskSpace, Get-LogInfo, Get-DeviceAssociations,
|
|
# Get-DeviceInventory, Get-InstalledSoftware
|
|
# and optionally Get-DeviceLastSeen.
|
|
# =========================================================================================
|
|
|
|
$script:SccmCfg = $config.SCCM
|
|
|
|
function Initialize-Provider {
|
|
$ModulePath = Join-Path (Split-Path $env:SMS_ADMIN_UI_PATH) "ConfigurationManager.psd1"
|
|
Import-Module $ModulePath -ErrorAction Stop
|
|
}
|
|
|
|
function Open-ProviderCycle {
|
|
# Flush DNS cache at the start of every cycle so stale A records and cached
|
|
# negative (NXDOMAIN) results don't cause false timeouts -- especially for
|
|
# devices freshly plugged into the network that were looked up before they registered.
|
|
if ($config.Worker.FlushDnsEachCycle) {
|
|
try {
|
|
$null = ipconfig /flushdns
|
|
if ($v) { Write-Host "DNS cache flushed." -ForegroundColor DarkGray }
|
|
} catch { Write-Host "DNS flush failed: $($_.Exception.Message)" -ForegroundColor DarkYellow }
|
|
}
|
|
|
|
$SiteCode = $script:SccmCfg.SiteCode
|
|
$session = @{ Sql = $null; PreviousLocation = Get-Location }
|
|
|
|
$session.Sql = New-Object System.Data.SqlClient.SqlConnection("Server=$($script:SccmCfg.SqlServer);Database=$($script:SccmCfg.Database);Integrated Security=True;")
|
|
try { $session.Sql.Open() } catch { Write-Host "Warning: Could not open Master SQL Connection" -ForegroundColor Red }
|
|
|
|
if (-not (Get-PSDrive -Name $SiteCode -PSProvider CMSite -ErrorAction SilentlyContinue)) {
|
|
if ($v) { Write-Host "Connecting to SCCM Drive ($SiteCode)..." -ForegroundColor DarkGray }
|
|
New-PSDrive -Name $SiteCode -PSProvider CMSite -Root $script:SccmCfg.ProviderMachineName -Scope Global | Out-Null
|
|
}
|
|
Set-Location "$($SiteCode):"
|
|
return $session
|
|
}
|
|
|
|
function Close-ProviderCycle {
|
|
param($Session)
|
|
if ($Session.Sql) {
|
|
if ($Session.Sql.State -eq 'Open') { $Session.Sql.Close() }
|
|
$Session.Sql.Dispose()
|
|
}
|
|
Set-Location $Session.PreviousLocation
|
|
}
|
|
|
|
function pingDevice {
|
|
param ([string]$computer)
|
|
if ([string]::IsNullOrWhiteSpace($computer) -or $computer -eq "N/A") { return "Not Found" }
|
|
|
|
if ($v) { Write-Host " > Fast Ping: $computer... " -ForegroundColor DarkGray -NoNewline }
|
|
|
|
try {
|
|
# Use Resolve-DnsName instead of GetHostAddressesAsync: the latter reads the
|
|
# Windows DNS client cache, which can hold STALE A records (wrong IP) long after
|
|
# DNS itself has the current record. Resolve-DnsName queries more directly and
|
|
# returns the live record. -DnsOnly skips the local cache; we still take all A records.
|
|
$allIps = @()
|
|
try {
|
|
$dnsRecs = Resolve-DnsName -Name $computer -Type A -DnsOnly -ErrorAction Stop | Where-Object { $_.Type -eq 'A' }
|
|
$allIps = @($dnsRecs | ForEach-Object { $_.IPAddress } | Select-Object -Unique)
|
|
} catch {
|
|
# Resolve-DnsName failed (no record / DNS error) -- fall back to .NET resolver.
|
|
try {
|
|
$task = [System.Net.Dns]::GetHostAddressesAsync($computer)
|
|
if ($task.Wait(1000)) {
|
|
$allIps = @($task.Result | Where-Object { $_.AddressFamily -eq 'InterNetwork' } | ForEach-Object { $_.IPAddressToString } | Select-Object -Unique)
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
if ($allIps.Count -eq 0) {
|
|
if ($v) { Write-Host "Not Found (No IP)." -ForegroundColor Red }
|
|
return "Not Found"
|
|
}
|
|
|
|
# --- Ping every resolved IP; online if ANY responds ---
|
|
# Handles duplicate/stale DNS records by testing all candidates.
|
|
$p = $null
|
|
$liveIp = $null
|
|
try {
|
|
$p = [System.Net.NetworkInformation.Ping]::new()
|
|
foreach ($ip in $allIps) {
|
|
foreach ($attempt in 1..2) {
|
|
$res = $p.Send($ip, 1000)
|
|
if ($res.Status -eq 'Success') { $liveIp = $ip; break }
|
|
}
|
|
if ($liveIp) { break }
|
|
}
|
|
} finally {
|
|
if ($null -ne $p) { $p.Dispose() }
|
|
}
|
|
|
|
if (-not $liveIp) {
|
|
if ($v) { Write-Host "Timed Out." -ForegroundColor DarkYellow }
|
|
return "Timed Out"
|
|
}
|
|
|
|
# --- Reverse DNS validation on the IP that actually responded ---
|
|
# Only flag DNS Issue if reverse DNS SUCCEEDS but names a DIFFERENT machine.
|
|
# A missing PTR record is common and is NOT an issue -- treat as online.
|
|
try {
|
|
$reverseHost = [System.Net.Dns]::GetHostEntry($liveIp).HostName
|
|
$reverseShort = ($reverseHost -split '\.')[0].ToUpper()
|
|
if ($reverseShort -ne $computer.ToUpper()) {
|
|
if ($v) { Write-Host "DNS Issue ($liveIp resolves back to $reverseShort)." -ForegroundColor Red }
|
|
return "DNS Issue"
|
|
}
|
|
} catch {
|
|
# No PTR record -- not a conflict.
|
|
}
|
|
# --------------------------------
|
|
|
|
if ($v) { Write-Host "Online" -ForegroundColor Green }
|
|
return "Online"
|
|
} catch {
|
|
if ($v) { Write-Host "Not Found (DNS)." -ForegroundColor Red }
|
|
return "Not Found"
|
|
}
|
|
}
|
|
|
|
function Resolve-DeviceIP {
|
|
param([string]$computerName)
|
|
try {
|
|
$ipRecord = [System.Net.Dns]::GetHostAddresses($computerName) | Where-Object { $_.AddressFamily -eq 'InterNetwork' } | Select-Object -First 1
|
|
if ($ipRecord) { return $ipRecord.IPAddressToString }
|
|
} catch {}
|
|
return $null
|
|
}
|
|
|
|
function Get-DiskSpace {
|
|
param([string]$computer)
|
|
if ($v) { Write-Host " > Checking C: Drive Storage (SMB Method)... " -ForegroundColor DarkGray -NoNewline }
|
|
|
|
try {
|
|
# Using SMB (Port 445) completely bypasses WMI/DCOM hangs
|
|
$fso = New-Object -ComObject Scripting.FileSystemObject
|
|
$uncPath = "\\$computer\$($script:SccmCfg.AdminShare)"
|
|
|
|
# Check if the path exists to avoid a long COM hang
|
|
if ([System.IO.Directory]::Exists($uncPath)) {
|
|
$drive = $fso.GetDrive($uncPath)
|
|
$total = [math]::Round($drive.TotalSize / 1GB, 1)
|
|
$free = [math]::Round($drive.FreeSpace / 1GB, 1)
|
|
|
|
if ($v) { Write-Host "${free}GB Free out of ${total}GB" -ForegroundColor Green }
|
|
return @{ Total = "$total GB"; Free = "$free GB" }
|
|
} else {
|
|
if ($v) { Write-Host "$($script:SccmCfg.AdminShare) Share Unreachable" -ForegroundColor Red }
|
|
}
|
|
} catch {
|
|
if ($v) { Write-Host "SMB Access Error" -ForegroundColor Red }
|
|
}
|
|
|
|
return @{ Total = "N/A"; Free = "N/A" }
|
|
}
|
|
|
|
function Get-LogInfo {
|
|
param ([string]$computer, [string]$logName)
|
|
|
|
$basePaths = @($script:SccmCfg.LogFolders | ForEach-Object { "\\$computer\$($script:SccmCfg.AdminShare)\$_" })
|
|
$currentDrive = Get-Location
|
|
Set-Location "C:\"
|
|
$result = New-LogInfoResult
|
|
|
|
if ($v) { Write-Host " > Scraping logs for '$logName'..." -ForegroundColor DarkGray }
|
|
|
|
foreach ($folder in $basePaths) {
|
|
if ($v) { Write-Host " Checking $folder... " -ForegroundColor DarkGray -NoNewline }
|
|
try {
|
|
if ([System.IO.Directory]::Exists($folder)) {
|
|
$files = [System.IO.Directory]::GetFiles($folder, "$logName*")
|
|
if ($files.Count -gt 0) {
|
|
$realPath = $files[0]
|
|
if ($v) { Write-Host "Found!" -ForegroundColor Green }
|
|
|
|
$result.LastModified = (Get-Item $realPath).LastWriteTime
|
|
$content = Get-Content -Path $realPath -Tail 1 -ErrorAction SilentlyContinue
|
|
if (-not $content) { $content = Get-Content -Path $realPath -Tail 1 -Encoding Unicode -ErrorAction SilentlyContinue }
|
|
|
|
if ($content) {
|
|
Set-LogInfoLine $result $content.Trim()
|
|
$result.Found = $true; $result.Path = $realPath
|
|
}
|
|
break
|
|
} else { if ($v) { Write-Host "Empty/Not there." -ForegroundColor DarkGray } }
|
|
} else { if ($v) { Write-Host "Path doesn't exist." -ForegroundColor DarkGray } }
|
|
} catch {
|
|
if ($v) { Write-Host "Access Denied / Error." -ForegroundColor Red }
|
|
$result.Line = "Access Denied"
|
|
}
|
|
}
|
|
Set-Location $currentDrive
|
|
return $result
|
|
}
|
|
|
|
function Get-DeviceAssociations {
|
|
param([string]$SourceComputer)
|
|
return Get-CMComputerAssociation -SourceComputer $SourceComputer -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------------------
|
|
# Inventory: AD (OU + groups) and the site database (hardware, users, asset-info class)
|
|
# Returns only the fields it could find; the worker fills the rest with "N/A".
|
|
# -----------------------------------------------------------------------------------------
|
|
function Test-SqlName([string]$name) { return ($name -match '^[A-Za-z0-9_]+$') }
|
|
|
|
function Read-AssetInventoryRow {
|
|
# Applies the configured column map + room-type flags from a data reader to $res
|
|
param($reader, $res)
|
|
$ai = $script:SccmCfg.AssetInventory
|
|
foreach ($field in $ai.Columns.Keys) {
|
|
$val = $reader["AI_$field"]
|
|
if ($val -isnot [DBNull] -and "$val") { $res[$field] = "$val" }
|
|
}
|
|
$rTypes = @()
|
|
for ($i = 0; $i -lt $ai.RoomTypes.Count; $i++) {
|
|
if ("$($reader["RT_$i"])" -eq "1") { $rTypes += $ai.RoomTypes[$i].Label }
|
|
}
|
|
return $rTypes
|
|
}
|
|
|
|
function Get-AssetInventorySelect {
|
|
param([string]$alias)
|
|
$ai = $script:SccmCfg.AssetInventory
|
|
$parts = @()
|
|
foreach ($field in $ai.Columns.Keys) {
|
|
$col = $ai.Columns[$field]
|
|
if ((Test-SqlName $col) -and (Test-SqlName $field)) { $parts += "$alias.[$col] AS [AI_$field]" }
|
|
}
|
|
for ($i = 0; $i -lt $ai.RoomTypes.Count; $i++) {
|
|
$col = $ai.RoomTypes[$i].Column
|
|
if (Test-SqlName $col) { $parts += "$alias.[$col] AS [RT_$i]" }
|
|
}
|
|
return ($parts -join ", ")
|
|
}
|
|
|
|
function Get-DeviceInventory {
|
|
param([string]$computerName, $Session)
|
|
$res = @{}
|
|
$SqlConn = $Session.Sql
|
|
|
|
$searcher = $null
|
|
try {
|
|
# Only letters, digits and - _ . can reach the LDAP filter
|
|
if ($computerName -notmatch '^[A-Za-z0-9._-]+$') { throw "invalid name" }
|
|
$searcher = [adsisearcher]"(&(objectCategory=computer)(name=$computerName))"
|
|
$searcher.PropertiesToLoad.Add("distinguishedname") | Out-Null
|
|
$searcher.PropertiesToLoad.Add("memberof") | Out-Null
|
|
$adResult = $searcher.FindOne()
|
|
$res.InAD = [bool]$adResult # the lookup worked, so "no result" really means "not in AD"
|
|
|
|
if ($adResult) {
|
|
$dn = $adResult.Properties["distinguishedname"][0]
|
|
if ($dn) {
|
|
$ouParts = @($dn -split ',' | Where-Object { $_ -match '^OU=' } | ForEach-Object { $_.Substring(3) })
|
|
[array]::Reverse($ouParts)
|
|
$res.OU = if ($ouParts.Count -gt 0) { $ouParts -join '/' } else { $dn }
|
|
}
|
|
|
|
$groupDNs = $adResult.Properties["memberof"]
|
|
if ($groupDNs) {
|
|
$groups = $groupDNs | ForEach-Object { ($_ -split ',')[0].Substring(3) }
|
|
$res.Groups = $groups -join "<br>"
|
|
}
|
|
}
|
|
} catch {} finally {
|
|
if ($null -ne $searcher) { $searcher.Dispose() }
|
|
}
|
|
|
|
$ai = $script:SccmCfg.AssetInventory
|
|
$useAsset = $ai.Enabled -and (Test-SqlName $ai.View)
|
|
|
|
try {
|
|
if ($SqlConn -and $SqlConn.State -eq 'Open') {
|
|
$cmd = $SqlConn.CreateCommand()
|
|
|
|
$assetSelect = ""
|
|
$assetJoin = ""
|
|
if ($useAsset) {
|
|
$assetSelect = ", ai.ResourceID AS AI_ResourceID, " + (Get-AssetInventorySelect "ai")
|
|
$assetJoin = "LEFT JOIN $($ai.View) ai ON sys.ResourceID = ai.ResourceID"
|
|
}
|
|
|
|
$cmd.CommandText = @"
|
|
SELECT TOP 1
|
|
sys.User_Name0, sys.Client0, sys.Active0, cs.Model0, mem.TotalPhysicalMemory0, mac.MAC_Addresses0, ip.IP_Addresses0
|
|
$assetSelect
|
|
FROM v_R_System sys
|
|
$assetJoin
|
|
LEFT JOIN v_GS_COMPUTER_SYSTEM cs ON sys.ResourceID = cs.ResourceID
|
|
LEFT JOIN v_GS_X86_PC_MEMORY mem ON sys.ResourceID = mem.ResourceID
|
|
LEFT JOIN (SELECT ResourceID, MAX(MAC_Addresses0) as MAC_Addresses0 FROM v_RA_System_MACAddresses GROUP BY ResourceID) mac ON sys.ResourceID = mac.ResourceID
|
|
LEFT JOIN (SELECT ResourceID, MAX(IP_Addresses0) as IP_Addresses0 FROM v_RA_System_IPAddresses GROUP BY ResourceID) ip ON sys.ResourceID = ip.ResourceID
|
|
WHERE sys.Name0 = @CompName
|
|
"@
|
|
|
|
$cmd.Parameters.AddWithValue("@CompName", $computerName) | Out-Null
|
|
$reader = $cmd.ExecuteReader()
|
|
$assetExists = $false
|
|
|
|
$res.InSCCM = $false
|
|
if ($reader.Read()) {
|
|
$res.InSCCM = $true
|
|
if ($reader["MAC_Addresses0"] -isnot [DBNull] -and $reader["MAC_Addresses0"]) { $res.MAC = $reader["MAC_Addresses0"] }
|
|
if ($reader["Model0"] -isnot [DBNull] -and $reader["Model0"]) { $res.Model = $reader["Model0"] }
|
|
if ($reader["TotalPhysicalMemory0"] -isnot [DBNull] -and $reader["TotalPhysicalMemory0"]) {
|
|
$res.RAM = "$([math]::Round([long]$reader["TotalPhysicalMemory0"] / 1048576, 1)) GB"
|
|
}
|
|
if ($reader["IP_Addresses0"] -isnot [DBNull] -and $reader["IP_Addresses0"]) {
|
|
$res.InventoryIP = $reader["IP_Addresses0"]
|
|
}
|
|
if ($reader["User_Name0"] -isnot [DBNull] -and $reader["User_Name0"]) { $res.LastUser = $reader["User_Name0"] }
|
|
|
|
$cStat = if ("$($reader["Client0"])" -eq "1") { "Installed" } else { "Not Installed" }
|
|
$aStat = if ("$($reader["Active0"])" -eq "1") { "Active" } else { "Inactive" }
|
|
$res.ClientStatus = "$cStat / $aStat"
|
|
|
|
if ($useAsset) {
|
|
# Check if the asset-info record actually exists
|
|
if ($reader["AI_ResourceID"] -isnot [DBNull]) { $assetExists = $true }
|
|
$rTypes = Read-AssetInventoryRow $reader $res
|
|
$res.RoomType = if ($rTypes.Count -gt 0) { $rTypes -join ", " } else { "Standard" }
|
|
}
|
|
}
|
|
$reader.Dispose()
|
|
|
|
# --- Fallback asset view (Only if the primary row was completely missing) ---
|
|
if ($useAsset -and -not $assetExists -and (Test-SqlName $ai.FallbackView)) {
|
|
if ($v) { Write-Host " -> Not in $($ai.View), trying $($ai.FallbackView) fallback..." -ForegroundColor DarkYellow }
|
|
|
|
$fallbackCmd = $SqlConn.CreateCommand()
|
|
$fallbackCmd.CommandText = @"
|
|
SELECT TOP 1 $(Get-AssetInventorySelect "fb")
|
|
FROM v_R_System sys
|
|
INNER JOIN $($ai.FallbackView) fb ON sys.ResourceID = fb.ResourceID
|
|
WHERE sys.Name0 = @CompName
|
|
"@
|
|
$fallbackCmd.Parameters.AddWithValue("@CompName", $computerName) | Out-Null
|
|
$fbReader = $fallbackCmd.ExecuteReader()
|
|
|
|
if ($fbReader.Read()) {
|
|
$rTypes = Read-AssetInventoryRow $fbReader $res
|
|
if ($rTypes.Count -gt 0) { $res.RoomType = $rTypes -join ", " }
|
|
}
|
|
$fbReader.Dispose()
|
|
$fallbackCmd.Dispose()
|
|
}
|
|
# ------------------------------------------
|
|
|
|
$cmd.CommandText = "SELECT UniqueUserName FROM v_UserMachineRelationship WHERE MachineResourceName = @CompName2"
|
|
$cmd.Parameters.AddWithValue("@CompName2", $computerName) | Out-Null
|
|
$reader2 = $cmd.ExecuteReader()
|
|
$uda = @()
|
|
while ($reader2.Read()) {
|
|
$uName = $reader2["UniqueUserName"].ToString()
|
|
if ($uName -match '^[^\\]+\\(.*)') { $uName = $Matches[1] } # strip DOMAIN\
|
|
$uda += $uName
|
|
}
|
|
if ($uda.Count -gt 0) { $res.SCCMPrimaryUsers = $uda -join "<br>" }
|
|
$reader2.Dispose()
|
|
$cmd.Dispose()
|
|
}
|
|
} catch { }
|
|
|
|
# --- Physical Disk Query ---
|
|
try {
|
|
if ($SqlConn -and $SqlConn.State -eq 'Open') {
|
|
$diskCmd = $SqlConn.CreateCommand()
|
|
$diskCmd.CommandText = @"
|
|
SELECT TOP 1 pd.Model0, pd.SerialNumber0, pd.Size0
|
|
FROM v_R_System sys
|
|
LEFT JOIN v_GS_PHYSICAL_DISK pd ON sys.ResourceID = pd.ResourceID
|
|
WHERE sys.Name0 = @DiskCompName AND pd.MediaType0 = 4
|
|
ORDER BY pd.Size0 DESC
|
|
"@
|
|
$diskCmd.Parameters.AddWithValue("@DiskCompName", $computerName) | Out-Null
|
|
$diskReader = $diskCmd.ExecuteReader()
|
|
if ($diskReader.Read()) {
|
|
$diskModel = if ($diskReader["Model0"] -isnot [DBNull]) { $diskReader["Model0"] } else { "N/A" }
|
|
$diskSerial = if ($diskReader["SerialNumber0"] -isnot [DBNull]) { $diskReader["SerialNumber0"] } else { "N/A" }
|
|
$diskSizeGB = if ($diskReader["Size0"] -isnot [DBNull] -and $diskReader["Size0"]) { "$([math]::Round([long]$diskReader["Size0"] / 1GB)) GB" } else { "N/A" }
|
|
$res.Disk = "Size: $diskSizeGB<br>Model: $diskModel<br>S/N: $diskSerial"
|
|
}
|
|
$diskReader.Dispose()
|
|
$diskCmd.Dispose()
|
|
}
|
|
} catch {}
|
|
# ---------------------------
|
|
|
|
# --- Monitors (WmiMonitorID hardware inventory) ---
|
|
$mon = $script:SccmCfg.Monitors
|
|
if ($mon -and $mon.Enabled -and (Test-SqlName $mon.View) -and (Test-SqlName $mon.NameColumn) -and (Test-SqlName $mon.ManufacturerColumn)) {
|
|
try {
|
|
if ($SqlConn -and $SqlConn.State -eq 'Open') {
|
|
$monCmd = $SqlConn.CreateCommand()
|
|
$monCmd.CommandText = @"
|
|
SELECT m.[$($mon.NameColumn)] AS MonName, m.[$($mon.ManufacturerColumn)] AS MonMfr
|
|
FROM v_R_System sys
|
|
INNER JOIN $($mon.View) m ON sys.ResourceID = m.ResourceID
|
|
WHERE sys.Name0 = @MonCompName
|
|
"@
|
|
$monCmd.Parameters.AddWithValue("@MonCompName", $computerName) | Out-Null
|
|
$monReader = $monCmd.ExecuteReader()
|
|
$names = @()
|
|
while ($monReader.Read()) {
|
|
$name = ConvertFrom-WmiCharCodes $monReader["MonName"]
|
|
$mfr = ConvertFrom-WmiCharCodes $monReader["MonMfr"]
|
|
if ($mon.InternalPattern -and $mfr -match $mon.InternalPattern -and -not $name) { continue } # laptop panel
|
|
$names += $(if ($name) { $name } elseif ($mfr) { "$mfr (unnamed)" } else { "Unknown monitor" })
|
|
}
|
|
$monReader.Dispose(); $monCmd.Dispose()
|
|
$res.MonitorCount = "$($names.Count)"
|
|
$res.Monitors = if ($names.Count -gt 0) { $names -join "<br>" } else { "None" }
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
return $res
|
|
}
|
|
|
|
# WmiMonitorID strings are uint16 arrays; SCCM inventories them as "72,80,32,69,50,52,0,0"
|
|
function ConvertFrom-WmiCharCodes($value) {
|
|
if ($null -eq $value -or $value -is [DBNull]) { return "" }
|
|
$text = "$value".Trim()
|
|
if ($text -match '^\d+(\s*,\s*\d+)*$') {
|
|
$text = -join ($text -split '\s*,\s*' | Where-Object { [int]$_ -gt 0 } | ForEach-Object { [char][int]$_ })
|
|
}
|
|
return $text.Trim()
|
|
}
|
|
|
|
# When a device is tracked for the first time and doesn't answer a ping, "last online" is
|
|
# backfilled from the client's last activity (heartbeat / policy / status messages).
|
|
function Get-DeviceLastSeen {
|
|
param([string]$computerName, $Session)
|
|
$SqlConn = $Session.Sql
|
|
if (-not $SqlConn -or $SqlConn.State -ne 'Open') { return $null }
|
|
$cmd = $SqlConn.CreateCommand()
|
|
try {
|
|
$cmd.CommandText = "SELECT TOP 1 ch.LastActiveTime FROM v_R_System sys INNER JOIN v_CH_ClientSummary ch ON sys.ResourceID = ch.ResourceID WHERE sys.Name0 = @SeenName"
|
|
$cmd.Parameters.AddWithValue("@SeenName", $computerName) | Out-Null
|
|
$val = $cmd.ExecuteScalar()
|
|
if ($val -and $val -isnot [DBNull]) { return ([datetime]$val).ToLocalTime() }
|
|
} catch {} finally { $cmd.Dispose() }
|
|
return $null
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------------------
|
|
# Installed software (used by Get-Software.ps1)
|
|
# -----------------------------------------------------------------------------------------
|
|
function Get-InstalledSoftware {
|
|
param([string]$ComputerName)
|
|
|
|
$sql = @"
|
|
SELECT DISTINCT
|
|
ARPDisplayName0 AS Name,
|
|
ProductVersion0 AS Version,
|
|
Publisher0 AS Publisher,
|
|
InstallSource0 AS Source,
|
|
InstallDate0 AS InstallDate
|
|
FROM v_GS_INSTALLED_SOFTWARE SW
|
|
INNER JOIN v_R_System SYS ON SW.ResourceID = SYS.ResourceID
|
|
WHERE SYS.Name0 = @CompName
|
|
AND ARPDisplayName0 IS NOT NULL
|
|
ORDER BY ARPDisplayName0
|
|
"@
|
|
|
|
$rows = @()
|
|
$conn = New-Object System.Data.SqlClient.SqlConnection
|
|
$conn.ConnectionString = "Server=$($script:SccmCfg.SqlServer);Database=$($script:SccmCfg.Database);Integrated Security=True;"
|
|
$conn.Open()
|
|
try {
|
|
$cmd = $conn.CreateCommand()
|
|
$cmd.CommandText = $sql
|
|
$cmd.CommandTimeout = 30
|
|
$cmd.Parameters.AddWithValue("@CompName", $ComputerName) | Out-Null
|
|
|
|
$adapter = New-Object System.Data.SqlClient.SqlDataAdapter $cmd
|
|
$dataset = New-Object System.Data.DataSet
|
|
$null = $adapter.Fill($dataset)
|
|
|
|
if ($dataset.Tables.Count -gt 0) {
|
|
foreach ($row in $dataset.Tables[0].Rows) {
|
|
$rows += [PSCustomObject]@{
|
|
Name = "$($row.Name)"
|
|
Version = $row.Version
|
|
Publisher = $row.Publisher
|
|
Source = $row.Source
|
|
InstallDate = if ($row.InstallDate -isnot [DBNull]) { $row.InstallDate } else { "" }
|
|
}
|
|
}
|
|
}
|
|
} finally { $conn.Close() }
|
|
return $rows
|
|
}
|