Files
2026-08-02 21:41:19 -04:00

545 lines
42 KiB
PowerShell

#region Get-UnifiInventory
Function Get-UnifiInventory
{
<#
.SYNOPSIS
Collects the layer two inventory from a UniFi Network controller and returns the same normalized model that the Omada
collector returns.
.DESCRIPTION
The returned model is deliberately identical to the Omada one, so that everything downstream treats a UniFi estate and an
Omada estate the same way and no vendor specific branching leaks into the desired state graph.
Available from both transports.
Sites, device inventory, and client sessions.
Available only from the network interface.
Per port media, speed, and power over Ethernet state.
Per port LLDP neighbour detail.
Uplink relationships carrying the remote port, which is what allows an inter switch cable to be drawn with both ends known.
Network definitions with their subnets, which become VLANs and subnets.
The real wireless configuration, rather than SSID names inferred from whichever clients happen to be associated.
Enumerations relied upon here.
Device state 0 disconnected, 1 connected, 2 pending adoption, 4 upgrading, 5 provisioning, and anything else unknown.
Device type usw is a switch, uap is an access point, and ugw, udm, or uxg is a gateway.
Port media GE and FE are copper, SFP and SFP+ are a fibre or direct attach cage.
.PARAMETER Context
The connection context produced by Connect-UnifiController.
.PARAMETER SiteName
Optionally restricts collection to one or more sites, matched against the site description as shown in the user interface.
.EXAMPLE
$UnifiInventory = Get-UnifiInventory -Context $UnifiContext
.NOTES
Author: Grace Solutions
Version: 2026.08.02.0000
#>
[CmdletBinding()]
Param
(
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[PSObject]$Context,
[Parameter(Mandatory=$False)]
[AllowNull()]
[String[]]$SiteName
)
Try
{
#region Define the device status translation
[ScriptBlock]$ConvertToDeviceStatus = {
Param
(
[System.Object]$StateValue
)
$DeviceStatus = 'unknown'
Switch ("$($StateValue)")
{
{($_ -ieq '0')}
{
$DeviceStatus = 'offline'
}
{($_ -ieq '1')}
{
$DeviceStatus = 'online'
}
{($_ -iin @('2', '4', '5', '6'))}
{
$DeviceStatus = 'warning'
}
}
Write-Output -InputObject ("$($DeviceStatus)")
}
#endregion
#region Define the port media translation
[ScriptBlock]$ConvertToPortKind = {
Param
(
[String]$MediaValue,
[System.Object]$SpeedValue
)
$PortKind = 'rj45'
Switch ("$($MediaValue)")
{
{($_ -imatch '^(?i)SFP\+$')}
{
$PortKind = 'sfp_plus'
}
{($_ -imatch '^(?i)SFP$')}
{
$PortKind = 'sfp'
}
}
#region A cage reporting ten gigabit is a plus cage regardless of how it labelled itself
Switch (("$($PortKind)" -ieq 'sfp') -and ([Int]"0$($SpeedValue)" -ge 10000))
{
{($_ -eq $True)}
{
$PortKind = 'sfp_plus'
}
}
#endregion
Write-Output -InputObject ("$($PortKind)")
}
#endregion
#region Define the link speed translation, which UniFi reports in megabits
[ScriptBlock]$ConvertToLinkSpeed = {
Param
(
[System.Object]$SpeedValue
)
$SpeedName = ''
Switch ([Int]"0$($SpeedValue)")
{
{($_ -ge 10000)}
{
$SpeedName = '10G'
}
{($_ -ge 2500) -and ($_ -lt 10000)}
{
$SpeedName = '2.5G'
}
{($_ -ge 1000) -and ($_ -lt 2500)}
{
$SpeedName = '1G'
}
{($_ -ge 100) -and ($_ -lt 1000)}
{
$SpeedName = '100M'
}
{($_ -gt 0) -and ($_ -lt 100)}
{
$SpeedName = '10M'
}
}
Write-Output -InputObject ("$($SpeedName)")
}
#endregion
#region Enumerate sites
$SiteObjectList = New-Object -TypeName 'System.Collections.Generic.List[PSObject]'
$SiteResult = Invoke-UnifiRequest -Context ($Context) -NetworkPath 'api/self/sites' -IntegrationPath 'v1/sites'
$DiscoveredSiteList = New-Object -TypeName 'System.Collections.Generic.List[PSObject]'
ForEach ($DiscoveredSite In @($SiteResult.Result))
{
Switch ($Null -ine $DiscoveredSite)
{
{($_ -eq $True)}
{
$DiscoveredSiteList.Add($DiscoveredSite)
}
}
}
#region A controller that will not enumerate sites still has the default site, which is enough to proceed
Switch ($DiscoveredSiteList.Count -eq 0)
{
{($_ -eq $True)}
{
$DefaultSiteProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$DefaultSiteProperties.name = 'default'
$DefaultSiteProperties.desc = 'Default'
$DiscoveredSiteList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($DefaultSiteProperties)))
Write-Verbose -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [WARNING] - The UniFi site list could not be retrieved, so the default site will be collected on its own. [$($SiteResult.ErrorMessage)]"
}
}
#endregion
Write-Verbose -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [INFO] - $($DiscoveredSiteList.Count) UniFi site(s) were discovered."
For ($DiscoveredSiteIndex = 0; $DiscoveredSiteIndex -lt $DiscoveredSiteList.Count; $DiscoveredSiteIndex++)
{
$DiscoveredSite = $DiscoveredSiteList[$DiscoveredSiteIndex]
#region The network interface addresses a site by its short name and the integration API by its identifier
$SiteKey = "$($DiscoveredSite.name)"
$SiteDisplayName = "$($DiscoveredSite.desc)"
Switch ([String]::IsNullOrWhiteSpace($SiteDisplayName))
{
{($_ -eq $True)}
{
$SiteDisplayName = "$($DiscoveredSite.name)"
}
}
Switch ([String]::IsNullOrWhiteSpace($SiteKey))
{
{($_ -eq $True)}
{
$SiteKey = "$($DiscoveredSite.id)"
}
}
#endregion
$SiteIsRequested = $True
Switch (($Null -ine $SiteName) -and ($SiteName.Count -gt 0))
{
{($_ -eq $True)}
{
$SiteIsRequested = ("$($SiteDisplayName)" -iin $SiteName) -or ("$($SiteKey)" -iin $SiteName)
}
}
Switch ($SiteIsRequested)
{
{($_ -eq $True)}
{
$WriteProgressParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$WriteProgressParameters.Activity = 'Collecting the UniFi inventory'
$WriteProgressParameters.Status = "Processing site `"$($SiteDisplayName)`" ($($DiscoveredSiteIndex + 1) of $($DiscoveredSiteList.Count))"
$WriteProgressParameters.PercentComplete = [System.Math]::Round((($DiscoveredSiteIndex + 1) / $DiscoveredSiteList.Count) * 100, 2)
$WriteProgressParameters.Id = 23
$Null = Write-Progress @WriteProgressParameters
#region Network definitions, which become VLANs and their subnets
$VLANObjectList = New-Object -TypeName 'System.Collections.Generic.List[PSObject]'
$NetworkIdentifierTable = New-Object -TypeName 'System.Collections.Generic.Dictionary[[System.String], [System.Int32]]'
$NetworkResult = Invoke-UnifiRequest -Context ($Context) -NetworkPath "api/s/$($SiteKey)/rest/networkconf"
ForEach ($DiscoveredNetwork In @($NetworkResult.Result))
{
$NetworkVLANID = [Int]"0$($DiscoveredNetwork.vlan)"
Switch (($NetworkVLANID -gt 0) -and ("$($DiscoveredNetwork.purpose)" -inotmatch '^(?i)wan'))
{
{($_ -eq $True)}
{
$NetworkIdentifierTable["$($DiscoveredNetwork._id)"] = $NetworkVLANID
$VLANProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$VLANProperties.VLANID = $NetworkVLANID
$VLANProperties.Name = "$($DiscoveredNetwork.name)"
$VLANProperties.Purpose = "$($DiscoveredNetwork.purpose)"
$VLANProperties.GatewaySubnet = "$($DiscoveredNetwork.ip_subnet)"
$VLANProperties.NetworkID = "$($DiscoveredNetwork._id)"
$VLANObjectList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($VLANProperties)))
}
}
}
#endregion
#region Wireless configuration
$SSIDObjectList = New-Object -TypeName 'System.Collections.Generic.List[PSObject]'
$SecurityTable = New-Object -TypeName 'System.Collections.Generic.Dictionary[[System.String], [System.String]]'
$SecurityTable.Add('open', 'Open')
$SecurityTable.Add('wep', 'WEP')
$SecurityTable.Add('wpapsk', 'WPA Personal')
$SecurityTable.Add('wpaeap', 'WPA Enterprise')
$WirelessResult = Invoke-UnifiRequest -Context ($Context) -NetworkPath "api/s/$($SiteKey)/rest/wlanconf"
ForEach ($DiscoveredSSID In @($WirelessResult.Result))
{
Switch ([String]::IsNullOrWhiteSpace("$($DiscoveredSSID.name)") -eq $False)
{
{($_ -eq $True)}
{
#region The security mode is refined by the protected access generation when the controller reports one
$SecurityName = Switch ($SecurityTable.ContainsKey("$($DiscoveredSSID.security)".ToLowerInvariant())) {{($_ -eq $True)} {"$($SecurityTable["$($DiscoveredSSID.security)".ToLowerInvariant()])"} Default {"$($DiscoveredSSID.security)"}}
Switch ("$($DiscoveredSSID.wpa3_support)" -ieq 'True')
{
{($_ -eq $True)}
{
$SecurityName = Switch ("$($DiscoveredSSID.wpa3_transition)" -ieq 'True') {{($_ -eq $True)} {'WPA2/WPA3 Personal'} Default {'WPA3 Personal'}}
}
}
#endregion
$SSIDVLANID = ''
Switch ($NetworkIdentifierTable.ContainsKey("$($DiscoveredSSID.networkconf_id)"))
{
{($_ -eq $True)}
{
$SSIDVLANID = "$($NetworkIdentifierTable["$($DiscoveredSSID.networkconf_id)"])"
}
}
$SSIDProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$SSIDProperties.Name = "$($DiscoveredSSID.name)"
$SSIDProperties.Security = "$($SecurityName)"
$SSIDProperties.IsHidden = ("$($DiscoveredSSID.hide_ssid)" -ieq 'True')
$SSIDProperties.IsGuest = ("$($DiscoveredSSID.is_guest)" -ieq 'True')
$SSIDProperties.VLANID = "$($SSIDVLANID)"
$SSIDProperties.WLANGroupName = "$($DiscoveredSSID.wlangroup_id)"
$SSIDObjectList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($SSIDProperties)))
}
}
}
#endregion
#region Devices
$DeviceObjectList = New-Object -TypeName 'System.Collections.Generic.List[PSObject]'
$DeviceResult = Invoke-UnifiRequest -Context ($Context) -NetworkPath "api/s/$($SiteKey)/stat/device" -IntegrationPath "v1/sites/$($DiscoveredSite.id)/devices"
ForEach ($DiscoveredDevice In @($DeviceResult.Result))
{
$DeviceMACAddress = "$($DiscoveredDevice.mac)".ToUpperInvariant()
$DeviceTypeName = Switch ("$($DiscoveredDevice.type)")
{
{($_ -ieq 'usw')}
{
'switch'
}
{($_ -ieq 'uap')}
{
'ap'
}
{($_ -iin @('ugw', 'udm', 'uxg'))}
{
'gateway'
}
Default
{
'other'
}
}
#region Ports
$DevicePortList = New-Object -TypeName 'System.Collections.Generic.List[PSObject]'
ForEach ($DiscoveredPort In @($DiscoveredDevice.port_table))
{
Switch ($Null -ine $DiscoveredPort)
{
{($_ -eq $True)}
{
$PortVLANID = ''
Switch ($NetworkIdentifierTable.ContainsKey("$($DiscoveredPort.native_networkconf_id)"))
{
{($_ -eq $True)}
{
$PortVLANID = "$($NetworkIdentifierTable["$($DiscoveredPort.native_networkconf_id)"])"
}
}
$PortProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$PortProperties.Number = [Int]"0$($DiscoveredPort.port_idx)"
$PortProperties.Name = Switch ([String]::IsNullOrWhiteSpace("$($DiscoveredPort.name)")) {{($_ -eq $False)} {"$($DiscoveredPort.name)"} Default {"Port $($DiscoveredPort.port_idx)"}}
$PortProperties.ProfileName = "$($DiscoveredPort.portconf_id)"
$PortProperties.Kind = $ConvertToPortKind.InvokeReturnAsIs("$($DiscoveredPort.media)", $DiscoveredPort.speed)
$PortProperties.Speed = $ConvertToLinkSpeed.InvokeReturnAsIs($DiscoveredPort.speed)
$PortProperties.Mode = Switch ("$($DiscoveredPort.op_mode)" -ieq 'switch') {{($_ -eq $True)} {'trunk'} Default {'access'}}
$PortProperties.VLANID = "$($PortVLANID)"
$PortProperties.IsDisabled = ("$($DiscoveredPort.enable)" -ieq 'False')
$PortProperties.IsPoweredOverEthernet = ("$($DiscoveredPort.poe_enable)" -ieq 'True')
$PortProperties.IsLinkAggregationMember = ("$($DiscoveredPort.aggregated_by)" -ieq 'True')
$PortProperties.LinkState = Switch ("$($DiscoveredPort.up)" -ieq 'True') {{($_ -eq $True)} {'up'} Default {'down'}}
$PortProperties.NeighbourChassisID = "$($DiscoveredPort.lldp_table.lldp_chassis_id)".ToUpperInvariant()
$PortProperties.NeighbourPortID = "$($DiscoveredPort.lldp_table.lldp_port_id)"
$DevicePortList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($PortProperties)))
}
}
}
#endregion
#region Uplink, which carries the remote port and therefore describes both ends
$UplinkObject = $Null
Switch (($Null -ine $DiscoveredDevice.uplink) -and ([String]::IsNullOrWhiteSpace("$($DiscoveredDevice.uplink.uplink_mac)") -eq $False))
{
{($_ -eq $True)}
{
$UplinkProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$UplinkProperties.LocalPortNumber = "$($DiscoveredDevice.uplink.port_idx)"
$UplinkProperties.UpstreamMACAddress = "$($DiscoveredDevice.uplink.uplink_mac)".ToUpperInvariant()
$UplinkProperties.UpstreamDeviceName = "$($DiscoveredDevice.uplink.uplink_device_name)"
$UplinkProperties.UpstreamPortNumber = "$($DiscoveredDevice.uplink.uplink_remote_port)"
$UplinkProperties.Speed = $ConvertToLinkSpeed.InvokeReturnAsIs($DiscoveredDevice.uplink.speed)
$UplinkObject = New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($UplinkProperties)
}
}
#endregion
$DeviceProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$DeviceProperties.MACAddress = "$($DeviceMACAddress)"
$DeviceProperties.Name = Switch ([String]::IsNullOrWhiteSpace("$($DiscoveredDevice.name)")) {{($_ -eq $False)} {"$($DiscoveredDevice.name)"} Default {"$($DeviceMACAddress)"}}
$DeviceProperties.Type = "$($DeviceTypeName)"
$DeviceProperties.Model = "$($DiscoveredDevice.model)"
$DeviceProperties.ModelName = Switch ([String]::IsNullOrWhiteSpace("$($DiscoveredDevice.model_in_lts)")) {{($_ -eq $False)} {"$($DiscoveredDevice.model)"} Default {"$($DiscoveredDevice.model)"}}
$DeviceProperties.IPAddress = "$($DiscoveredDevice.ip)"
$DeviceProperties.SerialNumber = "$($DiscoveredDevice.serial)"
$DeviceProperties.FirmwareVersion = "$($DiscoveredDevice.version)"
$DeviceProperties.Status = $ConvertToDeviceStatus.InvokeReturnAsIs($DiscoveredDevice.state)
$DeviceProperties.Uptime = "$($DiscoveredDevice.uptime)"
$DeviceProperties.Location = ''
$DeviceProperties.TagList = New-Object -TypeName 'System.Collections.Generic.List[String]'
$DeviceProperties.LastSeen = Try {[System.DateTimeOffset]::FromUnixTimeSeconds([Int64]"$($DiscoveredDevice.last_seen)").UtcDateTime} Catch {$Null}
$DeviceProperties.PortList = $DevicePortList
$DeviceProperties.Uplink = $UplinkObject
$DeviceProperties.DownlinkList = New-Object -TypeName 'System.Collections.Generic.List[PSObject]'
$DeviceProperties.SiteID = "$($SiteKey)"
$DeviceProperties.SiteName = "$($SiteDisplayName)"
$DeviceObjectList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($DeviceProperties)))
}
#endregion
#region Clients
$ClientObjectList = New-Object -TypeName 'System.Collections.Generic.List[PSObject]'
$ClientResult = Invoke-UnifiRequest -Context ($Context) -NetworkPath "api/s/$($SiteKey)/stat/sta" -IntegrationPath "v1/sites/$($DiscoveredSite.id)/clients"
ForEach ($DiscoveredClient In @($ClientResult.Result))
{
$ClientIsWired = ("$($DiscoveredClient.is_wired)" -ieq 'True')
$ClientProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$ClientProperties.MACAddress = "$($DiscoveredClient.mac)".ToUpperInvariant()
$ClientProperties.Name = Switch ([String]::IsNullOrWhiteSpace("$($DiscoveredClient.name)")) {{($_ -eq $False)} {"$($DiscoveredClient.name)"} Default {"$($DiscoveredClient.hostname)"}}
$ClientProperties.IPAddress = "$($DiscoveredClient.ip)"
$ClientProperties.Vendor = "$($DiscoveredClient.oui)"
$ClientProperties.DeviceCategory = ''
$ClientProperties.IsWireless = ($ClientIsWired -eq $False)
$ClientProperties.VLANID = "$($DiscoveredClient.vlan)"
$ClientProperties.NetworkName = "$($DiscoveredClient.network)"
$ClientProperties.SwitchMACAddress = "$($DiscoveredClient.sw_mac)".ToUpperInvariant()
$ClientProperties.SwitchName = ''
$ClientProperties.SwitchPort = "$($DiscoveredClient.sw_port)"
$ClientProperties.SSID = "$($DiscoveredClient.essid)"
$ClientProperties.AccessPointMACAddress = "$($DiscoveredClient.ap_mac)".ToUpperInvariant()
$ClientProperties.AccessPointName = ''
$ClientProperties.RadioID = Switch ("$($DiscoveredClient.radio)") {{($_ -ieq 'ng')} {'0'} {($_ -ieq 'na')} {'1'} {($_ -ieq '6e')} {'2'} Default {''}}
$ClientProperties.Channel = "$($DiscoveredClient.channel)"
$ClientProperties.SignalDbm = Switch ($Null -ine $DiscoveredClient.signal) {{($_ -eq $True)} {[Int]"$($DiscoveredClient.signal)"} Default {$Null}}
$ClientProperties.LastSeen = Try {[System.DateTimeOffset]::FromUnixTimeSeconds([Int64]"$($DiscoveredClient.last_seen)").UtcDateTime} Catch {$Null}
$ClientProperties.SiteID = "$($SiteKey)"
$ClientProperties.SiteName = "$($SiteDisplayName)"
$ClientObjectList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($ClientProperties)))
}
#region The client record names its switch and access point by address only, so the names are filled in from the device inventory
ForEach ($ClientObject In $ClientObjectList)
{
ForEach ($DeviceObject In $DeviceObjectList)
{
Switch ("$($DeviceObject.MACAddress)" -ieq "$($ClientObject.SwitchMACAddress)")
{
{($_ -eq $True)}
{
$ClientObject.SwitchName = "$($DeviceObject.Name)"
}
}
Switch ("$($DeviceObject.MACAddress)" -ieq "$($ClientObject.AccessPointMACAddress)")
{
{($_ -eq $True)}
{
$ClientObject.AccessPointName = "$($DeviceObject.Name)"
}
}
}
}
#endregion
#endregion
$SiteProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$SiteProperties.SiteID = "$($SiteKey)"
$SiteProperties.Name = "$($SiteDisplayName)"
$SiteProperties.Region = ''
$SiteProperties.TimeZone = ''
$SiteProperties.DeviceList = $DeviceObjectList
$SiteProperties.ClientList = $ClientObjectList
$SiteProperties.VLANList = $VLANObjectList
$SiteProperties.SSIDList = $SSIDObjectList
$SiteObjectList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($SiteProperties)))
Write-Verbose -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [INFO] - Collected $($DeviceObjectList.Count) device(s), $($ClientObjectList.Count) client(s), $($VLANObjectList.Count) VLAN(s), and $($SSIDObjectList.Count) SSID(s) from UniFi site `"$($SiteDisplayName)`"."
}
}
}
$Null = Write-Progress -Activity 'Collecting the UniFi inventory' -Id 23 -Completed
#endregion
$InventoryProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$InventoryProperties.SourceType = 'UniFi'
$InventoryProperties.Vendor = 'Ubiquiti'
$InventoryProperties.ControllerName = 'UniFi Network Controller'
$InventoryProperties.BaseURL = [System.URI]"$($Context.BaseAddress)"
$InventoryProperties.ControllerVersion = "$($Context.ControllerVersion)"
$InventoryProperties.CollectionMode = "$($Context.Mode)"
$InventoryProperties.RetrievedAt = [System.DateTime]::UtcNow
$InventoryProperties.SiteList = $SiteObjectList
Write-Output -InputObject (New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($InventoryProperties))
}
Catch
{
Throw
}
}
#endregion