feat(devices): add UDB (UniFi Device Bridge) support (#968)

Adds metrics export for UDB devices (UDB-Switch, UDB-Pro, UDB-Pro-Sector)
to all output backends. UDB-Switch is a hybrid device combining PoE switch
ports with WiFi 7 wireless bridge capability (5GHz + 6GHz radios).

- pkg/promunifi/udb.go: Prometheus metrics exporter for UDB
- pkg/influxunifi/udb.go: InfluxDB batch exporter for UDB
- pkg/datadogunifi/udb.go: Datadog batch exporter for UDB
- Wire UDB into switchExport in all three output plugins
- Add UDB to inputunifi device collection and site name override
- Update integration test expectations for InfluxDB and Datadog
- Fix addUBB() bug: was incorrectly incrementing UCI counter

Resolves #947

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cody Lee
2026-03-22 15:00:18 -05:00
committed by GitHub
parent 4248d2e304
commit 54bb3bfe8e
10 changed files with 318 additions and 3 deletions

View File

@@ -343,6 +343,8 @@ func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop
u.batchUBB(r, v)
case *unifi.UCI:
u.batchUCI(r, v)
case *unifi.UDB:
u.batchUDB(r, v)
case *unifi.Site:
u.reportSite(r, v)
case *unifi.Client:

View File

@@ -507,6 +507,51 @@ gauges:
- unifi.uci.stat_rx_packets
- unifi.uci.stat_tx_dropped
- unifi.uci.state
- unifi.udb.bytes
- unifi.udb.cpu
- unifi.udb.fan_level
- unifi.udb.general_temperature
- unifi.udb.guest_num_sta
- unifi.udb.guest_wlan_num_sta
- unifi.udb.last_seen
- unifi.udb.loadavg_1
- unifi.udb.loadavg_5
- unifi.udb.loadavg_15
- unifi.udb.mem
- unifi.udb.mem_buffer
- unifi.udb.mem_total
- unifi.udb.mem_used
- unifi.udb.memory
- unifi.udb.network
- unifi.udb.num_sta
- unifi.udb.probe
- unifi.udb.rx_bytes
- unifi.udb.satisfaction
- unifi.udb.stat_bytes
- unifi.udb.stat_rx_bytes
- unifi.udb.stat_rx_crypts
- unifi.udb.stat_rx_dropped
- unifi.udb.stat_rx_errors
- unifi.udb.stat_rx_frags
- unifi.udb.stat_rx_packets
- unifi.udb.stat_tx_bytes
- unifi.udb.stat_tx_dropped
- unifi.udb.stat_tx_errors
- unifi.udb.stat_tx_packets
- unifi.udb.stat_tx_retries
- unifi.udb.state
- unifi.udb.sys
- unifi.udb.system_uptime
- unifi.udb.total_max_power
- unifi.udb.tx_bytes
- unifi.udb.upgradeable
- unifi.udb.uplink_latency
- unifi.udb.uplink_max_speed
- unifi.udb.uplink_speed
- unifi.udb.uplink_uptime
- unifi.udb.uptime
- unifi.udb.user_num_sta
- unifi.udb.user_wlan_num_sta
counts:
- unifi.collector.num_devices
- unifi.collector.num_errors

68
pkg/datadogunifi/udb.go Normal file
View File

@@ -0,0 +1,68 @@
package datadogunifi
import "github.com/unpoller/unifi/v5"
// udbT is used as a name for printed/logged counters.
const udbT = item("UDB")
// batchUDB generates datapoints for UDB (UniFi Device Bridge) devices.
// UDB-Switch is a hybrid device combining switch ports with WiFi 7
// wireless bridge capability.
func (u *DatadogUnifi) batchUDB(r report, s *unifi.UDB) {
if !s.Adopted.Val || s.Locating.Val {
return
}
tags := cleanTags(map[string]string{
"mac": s.Mac,
"site_name": s.SiteName,
"source": s.SourceName,
"name": s.Name,
"version": s.Version,
"model": s.Model,
"serial": s.Serial,
"type": s.Type,
"ip": s.IP,
})
data := CombineFloat64(
u.batchUSWstat(s.Stat.Sw),
u.batchSysStats(s.SysStats, s.SystemStats),
map[string]float64{
"guest_num_sta": s.GuestNumSta.Val,
"bytes": s.Bytes.Val,
"fan_level": s.FanLevel.Val,
"general_temperature": s.GeneralTemperature.Val,
"last_seen": s.LastSeen.Val,
"rx_bytes": s.RxBytes.Val,
"tx_bytes": s.TxBytes.Val,
"uptime": s.Uptime.Val,
"state": s.State.Val,
"user_num_sta": s.UserNumSta.Val,
"num_sta": s.NumSta.Val,
"upgradeable": boolToFloat64(s.Upgradable.Val),
"guest_wlan_num_sta": s.GuestWlanNumSta.Val,
"user_wlan_num_sta": s.UserWlanNumSta.Val,
"satisfaction": s.Satisfaction.Val,
"total_max_power": s.TotalMaxPower.Val,
"uplink_speed": s.Uplink.Speed.Val,
"uplink_max_speed": s.Uplink.MaxSpeed.Val,
"uplink_latency": s.Uplink.Latency.Val,
"uplink_uptime": s.Uplink.Uptime.Val,
})
r.addCount(udbT)
metricName := metricNamespace("udb")
reportGaugeForFloat64Map(r, metricName, data, tags)
// Port table (reuse USW function)
u.batchPortTable(r, tags, s.PortTable)
// Radio table (reuse UAP functions)
u.processRadTable(r, tags, s.RadioTable, s.RadioTableStats)
// VAP table (reuse UAP function)
u.processVAPTable(r, tags, s.VapTable)
}

View File

@@ -454,6 +454,8 @@ func (u *InfluxUnifi) switchExport(r report, v any) { //nolint:cyclop
u.batchUBB(r, v)
case *unifi.UCI:
u.batchUCI(r, v)
case *unifi.UDB:
u.batchUDB(r, v)
case *unifi.UDM:
u.batchUDM(r, v)
case *unifi.Site:

View File

@@ -581,6 +581,64 @@ points:
tx_bytes: float
uptime: float
version: string
udb:
tags:
- mac
- model
- name
- serial
- site_name
- source
- type
- version
fields:
bytes: float
cpu: float
fan_level: float
general_temperature: float
guest-num_sta: float
guest-wlan-num_sta: float
ip: string
last_seen: float
loadavg_1: float
loadavg_5: float
loadavg_15: float
mem: float
mem_buffer: float
mem_total: float
mem_used: float
num_sta: float
rx_bytes: float
satisfaction: float
stat_bytes: float
stat_rx_bytes: float
stat_rx_crypts: float
stat_rx_dropped: float
stat_rx_errors: float
stat_rx_frags: float
stat_rx_packets: float
stat_tx_bytes: float
stat_tx_dropped: float
stat_tx_errors: float
stat_tx_packets: float
stat_tx_retries: float
state: float
system_uptime: float
temp_cpu: float
temp_memory: float
temp_network: float
temp_probe: float
temp_sys: float
total_max_power: float
tx_bytes: float
upgradeable: bool
uplink_latency: float
uplink_max_speed: float
uplink_speed: float
uplink_uptime: float
uptime: float
user-num_sta: float
user-wlan-num_sta: float
unifi_alarm:
tags:
- action

65
pkg/influxunifi/udb.go Normal file
View File

@@ -0,0 +1,65 @@
package influxunifi
import "github.com/unpoller/unifi/v5"
// udbT is used as a name for printed/logged counters.
const udbT = item("UDB")
// batchUDB generates datapoints for UDB (UniFi Device Bridge) devices.
// UDB-Switch is a hybrid device combining switch ports with WiFi 7
// wireless bridge capability.
func (u *InfluxUnifi) batchUDB(r report, s *unifi.UDB) {
if !s.Adopted.Val || s.Locating.Val {
return
}
tags := map[string]string{
"mac": s.Mac,
"site_name": s.SiteName,
"source": s.SourceName,
"name": s.Name,
"version": s.Version,
"model": s.Model,
"serial": s.Serial,
"type": s.Type,
}
fields := Combine(
u.batchUSWstat(s.Stat.Sw),
u.batchSysStats(s.SysStats, s.SystemStats),
map[string]any{
"guest-num_sta": s.GuestNumSta.Val,
"ip": s.IP,
"bytes": s.Bytes.Val,
"fan_level": s.FanLevel.Val,
"general_temperature": s.GeneralTemperature.Val,
"last_seen": s.LastSeen.Val,
"rx_bytes": s.RxBytes.Val,
"tx_bytes": s.TxBytes.Val,
"uptime": s.Uptime.Val,
"state": s.State.Val,
"user-num_sta": s.UserNumSta.Val,
"num_sta": s.NumSta.Val,
"upgradeable": s.Upgradable.Val,
"guest-wlan-num_sta": s.GuestWlanNumSta.Val,
"user-wlan-num_sta": s.UserWlanNumSta.Val,
"satisfaction": s.Satisfaction.Val,
"total_max_power": s.TotalMaxPower.Val,
"uplink_speed": s.Uplink.Speed.Val,
"uplink_max_speed": s.Uplink.MaxSpeed.Val,
"uplink_latency": s.Uplink.Latency.Val,
"uplink_uptime": s.Uplink.Uptime.Val,
})
r.addCount(udbT)
r.send(&metric{Table: "udb", Tags: tags, Fields: fields})
// Port table (reuse USW function)
u.batchPortTable(r, tags, s.PortTable)
// Radio table (reuse UAP functions)
u.processRadTable(r, tags, s.RadioTable, s.RadioTableStats)
// VAP table (reuse UAP function)
u.processVAPTable(r, tags, s.VapTable)
}

View File

@@ -174,10 +174,10 @@ func (u *InputUnifi) pollController(c *Controller) (*poller.Metrics, error) {
return nil, fmt.Errorf("unifi.GetDevices(%s): %w", c.URL, err)
}
u.LogDebugf("Found %d UBB, %d UXG, %d PDU, %d UCI, %d UAP %d USG %d USW %d UDM devices",
u.LogDebugf("Found %d UBB, %d UXG, %d PDU, %d UCI, %d UDB, %d UAP %d USG %d USW %d UDM devices",
len(m.Devices.UBBs), len(m.Devices.UXGs),
len(m.Devices.PDUs), len(m.Devices.UCIs),
len(m.Devices.UAPs), len(m.Devices.USGs),
len(m.Devices.UDBs), len(m.Devices.UAPs), len(m.Devices.USGs),
len(m.Devices.USWs), len(m.Devices.UDMs))
// Get speed test results for all WANs
@@ -486,6 +486,10 @@ func applySiteNameOverride(m *poller.Metrics, overrideName string) {
if isDefaultSiteName(d.SiteName) {
d.SiteName = overrideName
}
case *unifi.UDB:
if isDefaultSiteName(d.SiteName) {
d.SiteName = overrideName
}
case *unifi.PDU:
if isDefaultSiteName(d.SiteName) {
d.SiteName = overrideName
@@ -595,6 +599,15 @@ func extractDevices(metrics *Metrics) (*poller.Metrics, map[string]string, map[s
m.Devices = append(m.Devices, r)
}
for _, r := range metrics.Devices.UDBs {
devices[r.Mac] = r.Name
m.Devices = append(m.Devices, r)
for _, v := range r.VapTable {
bssdIDs[v.Bssid] = fmt.Sprintf("%s %s %s:", r.Name, v.Radio, v.RadioName)
}
}
for _, r := range metrics.Devices.PDUs {
devices[r.Mac] = r.Name
m.Devices = append(m.Devices, r)

View File

@@ -101,6 +101,7 @@ type Report struct {
UXG int // Total count of UXG devices.
UBB int // Total count of UBB devices.
UCI int // Total count of UCI devices.
UDB int // Total count of UDB devices.
Metrics *poller.Metrics // Metrics collected and recorded.
Elapsed time.Duration // Duration elapsed collecting and exporting.
Fetch time.Duration // Duration elapsed making controller requests.
@@ -479,6 +480,9 @@ func (u *promUnifi) switchExport(r report, v any) {
case *unifi.UCI:
r.addUCI()
u.exportUCI(r, v)
case *unifi.UDB:
r.addUDB()
u.exportUDB(r, v)
case *unifi.UDM:
r.addUDM()
u.exportUDM(r, v)

View File

@@ -23,6 +23,7 @@ type report interface {
addUXG()
addUBB()
addUCI()
addUDB()
addUSG()
addUAP()
addUSW()
@@ -111,13 +112,17 @@ func (r *Report) addUXG() {
}
func (r *Report) addUBB() {
r.UCI++
r.UBB++
}
func (r *Report) addUCI() {
r.UCI++
}
func (r *Report) addUDB() {
r.UDB++
}
// close is not part of the interface.
func (r *Report) close() {
r.wg.Wait()

53
pkg/promunifi/udb.go Normal file
View File

@@ -0,0 +1,53 @@
package promunifi
import "github.com/unpoller/unifi/v5"
// exportUDB exports metrics for UDB (UniFi Device Bridge) devices.
// The UDB range includes UDB-Switch, UDB-Pro, UDB-Pro-Sector.
// UDB-Switch is a hybrid device combining switch ports (8 PoE ports)
// with WiFi 7 wireless bridge capability (5GHz + 6GHz radios).
func (u *promUnifi) exportUDB(r report, d *unifi.UDB) {
if !d.Adopted.Val || d.Locating.Val {
return
}
baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
baseInfoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := append(baseLabels, tag)
infoLabels := append(baseInfoLabels, tag)
// Export switch stats (reuse USW functions)
u.exportUSWstats(r, labels, d.Stat.Sw)
u.exportPRTtable(r, labels, d.PortTable)
// Export wireless stats (reuse UAP functions)
u.exportVAPtable(r, labels, d.VapTable)
u.exportRADtable(r, labels, d.RadioTable, d.RadioTableStats)
// Common device stats
u.exportBYTstats(r, labels, d.TxBytes, d.RxBytes)
u.exportSYSstats(r, labels, d.SysStats, d.SystemStats)
u.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta)
r.send([]*metric{
{u.Device.Info, gauge, 1.0, append(baseLabels, infoLabels...)},
{u.Device.Uptime, gauge, d.Uptime, labels},
{u.Device.Upgradeable, gauge, d.Upgradable.Val, labels},
})
if d.HasTemperature.Val {
r.send([]*metric{{u.Device.Temperature, gauge, d.GeneralTemperature, append(labels, "general", "board")}})
}
if d.HasFan.Val {
r.send([]*metric{{u.Device.FanLevel, gauge, d.FanLevel, labels}})
}
if d.TotalMaxPower.Txt != "" {
r.send([]*metric{{u.Device.TotalMaxPower, gauge, d.TotalMaxPower, labels}})
}
})
}