At Slingshot, we’ve moved our Managed clients over to Unifi networking systems, and I wanted to use our RMM to directly monitor the Unifi Controller. The API isn’t officially documented, but I did find other resources: the community’s API documentation, CyberDrain’s examples, and the Unifi API browser. Add tons of other research and trial/error, and I finally accomplished a Powershell RMM script that monitors Device connections, resources/ports, and Alerts, and reports on a ton more.
Enough with the intros. Here’s an instance currently alerting in Solarwinds RMM:

…and More Info gives the full report (looks better in console — I wish SWRMM preserved whitespace):

Before I forget, some quick notes:
- Needs a local account (limited admin/readonly) on controller
- For UDMPs with firmware 1.6 or greater, use port 443; For older controllers, use port 8443
- It defaults the Controller IP to the detected Gateway IP (we do a lot of UDMPs).
- Several of the Device Status codes are documented nowhere, so this script might have the only public record of them (for posterity, I’ve figured out 2=pending adoption, 9=inform error, and 11=isolated).
And finally the Powershell — (self-consciously) still in progress with plenty of debug stubs, BUT with lots of useful production miles already under its belt:
<# checkHealth-UnifiController.ps1 Purpose: for all sites on a controller, checks device connections, resources, ports, alerts Author: Slingshot Solutions, www.slingfive.com Params: $IP (or hostname) of controller (default: detected gateway), [int]$PortNum (default:443), Username, PASSWORD Notes: * needs a local account (limited admin/readonly) on controller * For UDMPs with firmware 1.6 or greater, use port 443; For older controllers, use port 8443 #> # PREP SCRIPT: param( $IP = (Get-wmiObject Win32_networkAdapterConfiguration |where-object{$_.IPEnabled -and $_.DefaultIPGateway}).DefaultIPGateway[0], $PortNum = 443, # or 8443 depending on controller version $Username, $Password ) # tweaks: trap { $_; exit 1 } # force RMM to treat unhandled runtime errors as ERROR! #widen host buffer for better output on old PS versions: $pswindow=(Get-Host).UI.RawUI $newsize=$pswindow.BufferSize; if($newsize.width -lt 100){$newsize.width=100}; $pswindow.BufferSize=$newsize $newsize=$pswindow.WindowSize; if($newsize.width -lt 100){$newsize.width=100;$newsize.height=20}; $pswindow.WindowSize=$newsize function short([string]$str,[int]$size){if($str.length -gt $size){return ($str.substring(0,$size-3)+"...")}else{ return $str}} # vars: $exitcode = 0 $report = "" $FullRpt = "" $StopWatch = [system.diagnostics.stopwatch]::startNew() if(!$Password){ write-host "PARAMETERS:" foreach ( $key in ((Get-Command -Name $MyInvocation.InvocationName).Parameters).Keys ) { $val = ((Get-Variable $key -ea SilentlyContinue).Value) -join ", " Write-Host " $($key): $val" } write-error "Hey dummy, set your params!" exit 1000 } $Credential = @{ username="$Username"; password="$Password"; remember=$True; strict=$True; } |ConvertTo-Json if($PortNum -eq 443){ # UnifiOS 1.6 or newer: $BaseURI = "https://$($IP):$($PortNum)/proxy/network" $LoginURI = "https://$($IP):$($PortNum)/api/auth/login" }elseif($PortNum -eq 8443){ # EdgeOS 1.5 firmware or older: $BaseURI = "https://$($IP):$($PortNum)" $LoginURI = "https://$($IP):$($PortNum)/api/login" } # CONNECT TO CONTROLLER [Net.ServicePointManager]::SecurityProtocol = "tls12, tls11, tls" [Net.ServicePointManager]::ServerCertificateValidationCallback = { $True } #$LoginURI Try { $login = Invoke-RestMethod -uri $LoginURI -Method POST -Body $Credential -ContentType "application/json" -SessionVariable UniFiSession | out-null }catch{ #wrong username/password = "The remote server returned an error: (400) Bad Request" write-error "* ERROR - Api Connection Error: $($_.Exception.Message)" $exitcode = 1010 } if($exitcode -eq 0){ #write-host "CONNECTED`r`n" } # GET ALL SITES if($exitcode -eq 0){ try { $urlSites = "$($BaseURI)/api/stat/sites" $sites = Invoke-Restmethod -Uri $urlSites -Method GET -WebSession $UniFiSession }catch{ #$report += "* ERROR - Sites Query Failed: $($_.Exception.Message)`r`n" write-error "* ERROR - Sites Query Failed: $($_.Exception.Message)`r`n" $exitcode = 1011 } } # LOOP THROUGH SITES if($exitcode -eq 0){ # DIRTY HACK: UDMPs lose their auth on the second call (Something ServerCertificateValidationCallback related), so just pound it back every time: Invoke-RestMethod -uri $LoginURI -Method POST -Body $Credential -ContentType "application/json" -SessionVariable UniFiSession | out-null $rptDevices = "" $rptAlarms = "" Foreach ($site in $sites.data){ # SITE try { $urlDevices = "$($BaseURI)/api/s/$($site.name)/stat/device/" $devices = Invoke-Restmethod -Uri $urlDevices -Method GET -ContentType "application/json" -Headers @{"Accept"="application/json"} -WebSession $UniFiSession }catch{ write-error "* ERROR - Device Query Failed: $($_.Exception.Message)`r`n" } $shortsitename = short $site.desc 7 $FullRpt += "`r`nSITE '$($site.desc)' - $urlDevices :`r`n" $DeviceStateNames = @{0="disconnected"; 1="connected"; 2="pending adoption"; 3="3(?)"; 4="upgrading"; 5="provisioning"; 6="heartbeat missed"; 9="inform error"; 11="isolated" } #https://ubntwiki.com/products/software/unifi-controller/api: $DeviceModelNames = @{"BZ2"="UniFi AP"; "BZ2LR"="UniFi AP-LR"; "U2HSR"="UniFi AP-Outdoor+"; "U2IW"="UniFi AP-In Wall"; "U2L48"="UniFi AP-LR"; "U2Lv2"="UniFi AP-LR v2"; "U2M"="UniFi AP-Mini"; "U2O"="UniFi AP-Outdoor"; "U2S48"="UniFi AP"; "U2Sv2"="UniFi AP v2"; "U5O"="UniFi AP-Outdoor 5G"; "U7E"="UniFi AP-AC"; "U7EDU"="UniFi AP-AC-EDU"; "U7Ev2"="UniFi AP-AC v2"; "U7HD"="UniFi AP-HD"; "U7SHD"="UniFi AP-SHD"; "U7NHD"="UniFi AP-nanoHD"; "UFLHD"="UniFi AP-Flex-HD"; "UHDIW"="UniFi AP-HD-In Wall"; "UCXG"="UniFi AP-XG"; "UXSDM"="UniFi AP-BaseStationXG"; "UCMSH"="UniFi AP-MeshXG"; "U7IW"="UniFi AP-AC-In Wall"; "U7IWP"="UniFi AP-AC-In Wall Pro"; "U7MP"="UniFi AP-AC-Mesh-Pro"; "U7LR"="UniFi AP-AC-LR"; "U7LT"="UniFi AP-AC-Lite"; "U7O"="UniFi AP-AC Outdoor"; "U7P"="UniFi AP-Pro"; "U7MSH"="UniFi AP-AC-Mesh"; "U7PG2"="UniFi AP-AC-Pro"; "p2N"="PicoStation M2"; "US48PRO"="UniFi Switch Pro 48"; "US8"="UniFi Switch 8"; "US8P60"="UniFi Switch 8 POE-60W"; "US8P150"="UniFi Switch 8 POE-150W"; "S28150"="UniFi Switch 8 AT-150W"; "USC8"="UniFi Switch 8"; "US16P150"="UniFi Switch 16 POE-150W"; "S216150"="UniFi Switch 16 AT-150W"; "US24"="UniFi Switch 24"; "US24P250"="UniFi Switch 24 POE-250W"; "US24PL2"="UniFi Switch 24 L2 POE"; "US24P500"="UniFi Switch 24 POE-500W"; "S224250"="UniFi Switch 24 AT-250W"; "S224500"="UniFi Switch 24 AT-500W"; "US48"="UniFi Switch 48"; "US48P500"="UniFi Switch 48 POE-500W"; "US48PL2"="UniFi Switch 48 L2 POE"; "US48P750"="UniFi Switch 48 POE-750W"; "S248500"="UniFi Switch 48 AT-500W"; "S248750"="UniFi Switch 48 AT-750W"; "US6XG150"="UniFi Switch 6XG POE-150W"; "USXG"="UniFi Switch 16XG"; "UGW3"="UniFi Security Gateway 3P"; "UGW4"="UniFi Security Gateway 4P"; "UGWHD4"="UniFi Security Gateway HD"; "UGWXG"="UniFi Security Gateway XG-8"; "UP4"="UniFi Phone-X"; "UP5"="UniFi Phone"; "UP5t"="UniFi Phone-Pro"; "UP7"="UniFi Phone-Executive"; "UP5c"="UniFi Phone"; "UP5tc"="UniFi Phone-Pro"; "UP7c"="UniFi Phone-Executive"; "UDMPRO"="UniFi Dream Machine Pro"} # LOOP THROUGH DEVICES FOR SITE Foreach ($device in ($devices.data)){ $DeviceModelName = $DeviceModelNames[$($device.model)] $DeviceStateName = $DeviceStateNames[$device.state] if( $device.default -eq $True){ # PENDING ADOPTION: $vwireEnabled = ($device.vwireEnabled -eq $True) $discovered_via = $device.discovered_via #scan or l2 $FullRpt += "* $($device.type) $DeviceModelName, ip:$($device.ip), mac:$($device.mac), state:$DeviceStateName $($device.state), discovered_via:$discovered_via, wireless:$vwireEnabled`r`n" $rptDevices += "* PENDING ADOPTION$(if($vwireEnabled){" (WIRELESS)"}): $shortsitename > ($DeviceModelName)`r`n" $exitcode = 1010 }elseif ($device.adopted -eq $True){ if($device.state -eq 0 -or $device.state -eq 9 -or $device.state -eq 11){ # DISCONNECTED, INFORM ERROR, ISOLATED: $FullRpt += "* '$($device.NAME)' - $($device.type) $DeviceModelName, ip:$($device.ip), mac:$($device.mac),`r`n state:$($device.state) $DeviceStateName, adopted:$($device.adopted), disabled:$($device.disabled -eq $True)`r`n" if($device.disabled -ne $True){ # don't care if it's disabled $rptDevices += "* $($DeviceStateName.toUpper()): $shortsitename > '$($device.name)'`r`n" $exitcode = 1011 } }else{ # OTHERWISE: $uptime = new-TimeSpan -Seconds (0+ $device.'system-stats'.uptime) $FullRpt += "* '$($device.NAME)' - $($device.type) $DeviceModelName, ip:$($device.ip), mac:$($device.mac),`r`n state:$($device.state) $DeviceStateName, uptime:$uptime, cpu:$([int]$device.'system-stats'.cpu)%, mem:$([int]$device.'system-stats'.mem)%, current:$(!$device.upgradable) (FW v$($device.version))`r`n" if( [math]::Round($device.'system-stats'.uptime) -lt "300") { $rptDevices += "* $shortsitename > $($device.name) : Disconnected `r`n" $exitcode = 1010 } if( [math]::Round($device.'system-stats'.cpu) -gt "90.0") { $rptDevices += "* $shortsitename > $($device.name) : CPU usage of $($device.'system-stats'.cpu)% `r`n" $exitcode = 1012 } if( [math]::Round($device.'system-stats'.mem) -gt "90.0") { $rptDevices += "* $shortsitename > $($device.name) : Memory usage of $($device.'system-stats'.mem)% `r`n" $exitcode = 1013 } if( $device.upgradable -eq $true ) { # $rptDevices += "* $shortsitename > $($device.name) : Firmware upgrade available <-- IGNORING til QA recovers`r`n" # $exitcode = 1014 } #PORTS: #if($device.port_table.count -gt 0){ #don't need to see AP Pros' secondary ports if($device.type -eq 'usw'){ $FullRpt += " "+ ($device.port_table |ft port_idx,name,enable,up,is_uplink,port_poe,autoneg,speed,full_duplex,network_name -AutoSize |out-string).trim().replace("`n", "`n ") +"`r`n" } Foreach ($port in $device.port_table.data){ if($port.stp_state -eq "discard"){ $rptDevices += "* $shortsitename > $($device.desc) > PORT $($device.name) : blocked due to STP issues `r`n" $exitcode = 1017 } } } } } Foreach ($device in $devices.data.wan1 |where-object {$_.name}){ #weirdly duplicates with blanks otherwise $FullRpt += "* FW WAN1 '$($device.name)' - is_uplink:$($device.is_uplink), up:$($device.up), ip:$($device.ip), netmask:$($device.netmask), gateway:$($device.gateway) `r`n" if($device.is_uplink -and $device.up -ne $True) { $rptDevices += "* $shortsitename > WAN1 $($device.name) : link down `r`n" $exitcode = 1015 } } Foreach ($device in $devices.data.wan2 |where-object {$_.name}){#weirdly duplicates with blanks otherwise $FullRpt += "* FW WAN2 '$($device.name)' - is_uplink:$($device.is_uplink), up:$($device.up), ip:$($device.ip), netmask:$($device.netmask), gateway:$($device.gateway) `r`n" if($device.is_uplink -and $device.up -ne $True) { $rptDevices += "* $shortsitename > WAN2 $($device.name) : link down`r`n" $exitcode = 1016 } } # DIRTY HACK: UDMPs lose their auth on the second call (Something ServerCertificateValidationCallback related), so just pound it back every time: Invoke-RestMethod -uri $LoginURI -Method POST -Body $Credential -ContentType "application/json" -SessionVariable UniFiSession | out-null try { $urlAlarms = "$($BaseURI)/api/s/$($site.name)/stat/alarm/" $alarms = Invoke-Restmethod -Uri $urlAlarms -Method GET -ContentType "application/json" -Headers @{"Accept"="application/json"} -WebSession $UniFiSession }catch{ $report += "* ERROR - Alarm Query Failed: $($_.Exception.Message)" write-error "* ERROR - Alarm Query Failed: $($_.Exception.Message)" } Foreach ($alarm in ($alarms.data)){ if(! $($alarm.handled_time)) { # ??? $rptAlarms += "* ALERT: $shortsitename > '$($alarm.ap_name)' : $($alarm.msg) @$([datetime]$alarm.datetime -f "yyyy-MM-dd-Hmmss") `r`n" $exitcode = 1018 } } } } if($rptDevices){ $report += "$rptDevices `r`n" } if($rptAlarms){ $report += "$rptAlarms `r`n" } # REPORT PASS/FAIL if($exitcode -gt 0){ write-host "FAIL - problems found:" }else{ write-host "OK - no problems found!" } $report # REPORT REST: write-host "CHECKED DATA:" write-host " "($FullRpt.replace("`n", "`n ")).trim() #format write-host write-host "PARAMETERS:" foreach ( $key in ((Get-Command -Name $MyInvocation.InvocationName).Parameters).Keys ) { $val = ((Get-Variable $key -ea SilentlyContinue).Value) -join ", " Write-Host " $($key): $val" } write-host write-host "CONTEXT:" write-host " Script Path:" (get-item $MyInvocation.InvocationName) write-host " Script Last Updated:" (get-item $MyInvocation.InvocationName).LastWriteTime " (try -5hrs [SW saves as UTC])" write-host " Execution Time: Total $($elapsed_Total)sec" # WRAP UP: write-host write-host "exitcode: $exitcode" exit $exitcode
NOTE: updates since publishing this have so far added:
* proper detection of the “pending adoption” state
* awareness of the undocumented “adopting” state
* awareness of the undocumented UBB, USP and USW-Flex-Mini models
If anyone’s dying to see the latest version, let me know in the comments.