From 00b6f3380a5fa5c05a669d4273aa65f192d3eb30 Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Thu, 1 Oct 2026 10:48:24 +0100 Subject: [PATCH 1/3] feat: export UMBB (U5G Max) cellular modem metrics Mobile broadband devices such as the U5G Max report type "umbb", which the unifi library skipped, so they never reached any output and there was no signal history for them. Pass UMBB devices through the input plugin (device list, site name override, alarm device names) and export them from Prometheus, InfluxDB, Datadog and OTel: - radio: RSRP, RSRQ, SNR, signal bars/percent, coverage, roaming, registration state, carrier aggregation count and total bandwidth, plus state/mode/RAT/band/operator - per SIM: active, display state and time in it, last carrier network reject (type, text, age), cumulative rx/tx bytes - the usual device uptime, system stats and uplink go.mod temporarily replaces github.com/unpoller/unifi/v6 with a fork carrying the UMBB type (unpoller/unifi feat/umbb); swap it for a version bump once that is released. --- go.mod | 2 + go.sum | 4 +- pkg/datadogunifi/datadog.go | 2 + .../integration_test_expectations.yaml | 40 +++++ pkg/datadogunifi/umbb.go | 120 ++++++++++++++ pkg/influxunifi/influxdb.go | 2 + .../integration_test_expectations.yaml | 73 +++++++++ pkg/influxunifi/umbb.go | 121 ++++++++++++++ pkg/inputunifi/collectevents.go | 6 + pkg/inputunifi/collector.go | 13 +- pkg/otelunifi/report.go | 5 + pkg/otelunifi/umbb.go | 128 +++++++++++++++ pkg/promunifi/collector.go | 6 + pkg/promunifi/report.go | 5 + pkg/promunifi/umbb.go | 150 ++++++++++++++++++ pkg/promunifi/umbb_test.go | 118 ++++++++++++++ 16 files changed, 791 insertions(+), 4 deletions(-) create mode 100644 pkg/datadogunifi/umbb.go create mode 100644 pkg/influxunifi/umbb.go create mode 100644 pkg/otelunifi/umbb.go create mode 100644 pkg/promunifi/umbb.go create mode 100644 pkg/promunifi/umbb_test.go diff --git a/go.mod b/go.mod index f478aebe..646a43d0 100644 --- a/go.mod +++ b/go.mod @@ -71,3 +71,5 @@ require ( golang.org/x/sys v0.48.0 // indirect google.golang.org/protobuf v1.36.12 // indirect ) + +replace github.com/unpoller/unifi/v6 => github.com/tomwilkie/unifi/v6 v6.2.1-0.20261001091512-6281bfb42116 diff --git a/go.sum b/go.sum index 8a67f019..ae4885ff 100644 --- a/go.sum +++ b/go.sum @@ -121,8 +121,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/unpoller/unifi/v6 v6.2.0 h1:5P3C1KkbVRhX11/aQIcgKex/aotroUEXtXLQLLfanv4= -github.com/unpoller/unifi/v6 v6.2.0/go.mod h1:fJ1lU/mpKrQ8Jv3b2yq399ATlrUuiOZAMbKWcw/1GaU= +github.com/tomwilkie/unifi/v6 v6.2.1-0.20261001091512-6281bfb42116 h1:Bpq6WiKwFwgfb8T3JMCWK/VooyLIp8oI1zmmI99Riks= +github.com/tomwilkie/unifi/v6 v6.2.1-0.20261001091512-6281bfb42116/go.mod h1:fJ1lU/mpKrQ8Jv3b2yq399ATlrUuiOZAMbKWcw/1GaU= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= diff --git a/pkg/datadogunifi/datadog.go b/pkg/datadogunifi/datadog.go index f750266a..dfb436df 100644 --- a/pkg/datadogunifi/datadog.go +++ b/pkg/datadogunifi/datadog.go @@ -453,6 +453,8 @@ func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop u.batchUCI(r, v) case *unifi.UDB: u.batchUDB(r, v) + case *unifi.UMBB: + u.batchUMBB(r, v) case *unifi.Site: u.reportSite(r, v) case *unifi.Client: diff --git a/pkg/datadogunifi/integration_test_expectations.yaml b/pkg/datadogunifi/integration_test_expectations.yaml index 1a1aecc1..c65aeda5 100644 --- a/pkg/datadogunifi/integration_test_expectations.yaml +++ b/pkg/datadogunifi/integration_test_expectations.yaml @@ -566,6 +566,46 @@ gauges: - unifi.udb.uptime - unifi.udb.user_num_sta - unifi.udb.user_wlan_num_sta + - unifi.umbb_sim.active + - unifi.umbb_sim.display_state_elapsed + - unifi.umbb_sim.network_reject_age + - unifi.umbb_sim.rx_bytes + - unifi.umbb_sim.tx_bytes + - unifi.umbb.cpu + - unifi.umbb.current_slot + - unifi.umbb.dl_bandwidth_mhz + - unifi.umbb.has_coverage + - unifi.umbb.last_seen + - unifi.umbb.loadavg_1 + - unifi.umbb.loadavg_15 + - unifi.umbb.loadavg_5 + - unifi.umbb.lte_carriers + - unifi.umbb.mem + - unifi.umbb.mem_buffer + - unifi.umbb.mem_total + - unifi.umbb.mem_used + - unifi.umbb.memory + - unifi.umbb.network + - unifi.umbb.nr_carriers + - unifi.umbb.probe + - unifi.umbb.registration_state + - unifi.umbb.roaming + - unifi.umbb.rsrp + - unifi.umbb.rsrq + - unifi.umbb.sa_mode + - unifi.umbb.signal + - unifi.umbb.signal_percent + - unifi.umbb.snr + - unifi.umbb.state + - unifi.umbb.sys + - unifi.umbb.system_uptime + - unifi.umbb.ul_bandwidth_mhz + - unifi.umbb.upgradeable + - unifi.umbb.uplink_latency + - unifi.umbb.uplink_max_speed + - unifi.umbb.uplink_speed + - unifi.umbb.uplink_uptime + - unifi.umbb.uptime counts: - unifi.collector.num_devices - unifi.collector.num_errors diff --git a/pkg/datadogunifi/umbb.go b/pkg/datadogunifi/umbb.go new file mode 100644 index 00000000..14c638e4 --- /dev/null +++ b/pkg/datadogunifi/umbb.go @@ -0,0 +1,120 @@ +package datadogunifi + +import ( + "math" + "strconv" + + "github.com/unpoller/unifi/v6" +) + +// umbbT is used as a name for printed/logged counters. +const umbbT = item("UMBB") + +// batchUMBB generates datapoints for UMBB (mobile broadband) devices, such as the U5G Max. +// The device itself goes to "umbb" with its cellular radio state; each SIM slot goes to "umbb_sim". +func (u *DatadogUnifi) batchUMBB(r report, s *unifi.UMBB) { + if !s.Adopted.Val { + return + } + + rad := s.Mbb.Radio + dl, ul := mbbBandwidth(rad) + + 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, + "mbb_state": s.Mbb.State, + "mbb_mode": s.Mbb.Mode, + "rat": rad.Rat, + "band": rad.Band, + "operator": rad.NetworkOperator, + }) + + data := CombineFloat64( + u.batchSysStats(s.SysStats, s.SystemStats), + map[string]float64{ + "last_seen": s.LastSeen.Val, + "uptime": s.Uptime.Val, + "state": s.State.Val, + "upgradeable": boolToFloat64(s.Upgradable.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, + "sa_mode": rad.SA5GMode.Float64(), + "current_slot": rad.CurrentSlot.Val, + "rsrp": rad.Rsrp.Val, + "rsrq": rad.Rsrq.Val, + "snr": rad.Snr.Val, + "signal": rad.Signal.Val, + "signal_percent": rad.SignalPercent.Val, + "has_coverage": rad.HasCoverage.Float64(), + "roaming": rad.Roaming.Float64(), + "registration_state": rad.RegistrationState.Val, + "nr_carriers": float64(len(rad.CaNr)), + "lte_carriers": float64(len(rad.CaLte)), + "dl_bandwidth_mhz": dl, + "ul_bandwidth_mhz": ul, + }) + + r.addCount(umbbT) + reportGaugeForFloat64Map(r, metricNamespace("umbb"), data, tags) + + for _, sim := range s.Mbb.Sim { + simTags := cleanTags(map[string]string{ + "mac": s.Mac, + "site_name": s.SiteName, + "source": s.SourceName, + "name": s.Name, + "model": s.Model, + "type": s.Type, + "slot": sim.Slot.Txt, + "esim": strconv.FormatBool(sim.Esim.Val), + "spn": sim.Spn, + "display_state": sim.DisplayState, + "network_reject_type": sim.NetworkRejectType, + "network_reject_text": sim.NetworkRejectText, + }) + simData := map[string]float64{ + "active": sim.Active.Float64(), + "display_state_elapsed": sim.DisplayStateElapsed.Val, + "network_reject_age": sim.NetworkRejectAge.Val, + "rx_bytes": sim.RxBytes.Val, + "tx_bytes": sim.TxBytes.Val, + } + + reportGaugeForFloat64Map(r, metricNamespace("umbb_sim"), simData, simTags) + } +} + +// mbbBandwidth sums the downlink and uplink bandwidth of every aggregated carrier, NR and LTE. +// A carrier with no uplink reports a null bandwidth, which decodes as zero. +// A sum that overflows is reported as zero: InfluxDB rejects a whole point with an infinite field. +func mbbBandwidth(rad unifi.MBBRadio) (dl, ul float64) { + for _, c := range rad.CaNr { + dl += c.DlBwMhz.Val + ul += c.UlBwMhz.Val + } + + for _, c := range rad.CaLte { + dl += c.DlBwMhz.Val + ul += c.UlBwMhz.Val + } + + return finiteOrZero(dl), finiteOrZero(ul) +} + +func finiteOrZero(v float64) float64 { + if math.IsInf(v, 0) || math.IsNaN(v) { + return 0 + } + + return v +} diff --git a/pkg/influxunifi/influxdb.go b/pkg/influxunifi/influxdb.go index 6017e1a8..30a03a67 100644 --- a/pkg/influxunifi/influxdb.go +++ b/pkg/influxunifi/influxdb.go @@ -718,6 +718,8 @@ func (u *InfluxUnifi) switchExport(r report, v any) { //nolint:cyclop u.batchUCI(r, v) case *unifi.UDB: u.batchUDB(r, v) + case *unifi.UMBB: + u.batchUMBB(r, v) case *unifi.UDM: u.batchUDM(r, v) case *unifi.Site: diff --git a/pkg/influxunifi/integration_test_expectations.yaml b/pkg/influxunifi/integration_test_expectations.yaml index 2c64cb30..2275345e 100644 --- a/pkg/influxunifi/integration_test_expectations.yaml +++ b/pkg/influxunifi/integration_test_expectations.yaml @@ -671,6 +671,79 @@ points: uptime: float user-num_sta: float user-wlan-num_sta: float + umbb: + tags: + - mac + - model + - name + - serial + - site_name + - source + - type + - version + fields: + band: string + cpu: float + current_slot: float + dl_bandwidth_mhz: float + has_coverage: bool + ip: string + last_seen: float + loadavg_1: float + loadavg_5: float + loadavg_15: float + lte_carriers: float + mbb_mode: string + mbb_state: string + mem: float + mem_buffer: float + mem_total: float + mem_used: float + nr_carriers: float + operator: string + rat: string + registration_state: float + roaming: bool + rsrp: float + rsrq: float + sa_mode: bool + signal: float + signal_percent: float + snr: float + state: float + system_uptime: float + temp_cpu: float + temp_memory: float + temp_network: float + temp_probe: float + temp_sys: float + ul_bandwidth_mhz: float + upgradeable: bool + uplink_latency: float + uplink_max_speed: float + uplink_speed: float + uplink_uptime: float + uptime: float + umbb_sim: + tags: + - esim + - mac + - model + - name + - site_name + - slot + - source + - spn + - type + fields: + active: bool + display_state: string + display_state_elapsed: float + network_reject_age: float + network_reject_text: string + network_reject_type: string + rx_bytes: float + tx_bytes: float unifi_alarm: tags: - action diff --git a/pkg/influxunifi/umbb.go b/pkg/influxunifi/umbb.go new file mode 100644 index 00000000..5ee0fcc3 --- /dev/null +++ b/pkg/influxunifi/umbb.go @@ -0,0 +1,121 @@ +package influxunifi + +import ( + "math" + "strconv" + + "github.com/unpoller/unifi/v6" +) + +// umbbT is used as a name for printed/logged counters. +const umbbT = item("UMBB") + +// batchUMBB generates datapoints for UMBB (mobile broadband) devices, such as the U5G Max. +// The device itself goes to "umbb" with its cellular radio state; each SIM slot goes to "umbb_sim". +func (u *InfluxUnifi) batchUMBB(r report, s *unifi.UMBB) { + if !s.Adopted.Val { + return + } + + rad := s.Mbb.Radio + dl, ul := mbbBandwidth(rad) + + 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.batchSysStats(s.SysStats, s.SystemStats), + map[string]any{ + "ip": s.IP, + "last_seen": s.LastSeen.Val, + "uptime": s.Uptime.Val, + "state": s.State.Val, + "upgradeable": s.Upgradable.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, + "mbb_state": s.Mbb.State, + "mbb_mode": s.Mbb.Mode, + "rat": rad.Rat, + "band": rad.Band, + "operator": rad.NetworkOperator, + "sa_mode": rad.SA5GMode.Val, + "current_slot": rad.CurrentSlot.Val, + "rsrp": rad.Rsrp.Val, + "rsrq": rad.Rsrq.Val, + "snr": rad.Snr.Val, + "signal": rad.Signal.Val, + "signal_percent": rad.SignalPercent.Val, + "has_coverage": rad.HasCoverage.Val, + "roaming": rad.Roaming.Val, + "registration_state": rad.RegistrationState.Val, + "nr_carriers": float64(len(rad.CaNr)), + "lte_carriers": float64(len(rad.CaLte)), + "dl_bandwidth_mhz": dl, + "ul_bandwidth_mhz": ul, + }) + + r.addCount(umbbT) + r.send(&metric{Table: "umbb", Tags: tags, Fields: fields}) + + for _, sim := range s.Mbb.Sim { + r.send(&metric{ + Table: "umbb_sim", + Tags: map[string]string{ + "mac": s.Mac, + "site_name": s.SiteName, + "source": s.SourceName, + "name": s.Name, + "model": s.Model, + "type": s.Type, + "slot": sim.Slot.Txt, + "esim": strconv.FormatBool(sim.Esim.Val), + "spn": sim.Spn, + }, + Fields: map[string]any{ + "active": sim.Active.Val, + "display_state": sim.DisplayState, + "display_state_elapsed": sim.DisplayStateElapsed.Val, + "network_reject_type": sim.NetworkRejectType, + "network_reject_text": sim.NetworkRejectText, + "network_reject_age": sim.NetworkRejectAge.Val, + "rx_bytes": sim.RxBytes.Val, + "tx_bytes": sim.TxBytes.Val, + }, + }) + } +} + +// mbbBandwidth sums the downlink and uplink bandwidth of every aggregated carrier, NR and LTE. +// A carrier with no uplink reports a null bandwidth, which decodes as zero. +// A sum that overflows is reported as zero: InfluxDB rejects a whole point with an infinite field. +func mbbBandwidth(rad unifi.MBBRadio) (dl, ul float64) { + for _, c := range rad.CaNr { + dl += c.DlBwMhz.Val + ul += c.UlBwMhz.Val + } + + for _, c := range rad.CaLte { + dl += c.DlBwMhz.Val + ul += c.UlBwMhz.Val + } + + return finiteOrZero(dl), finiteOrZero(ul) +} + +func finiteOrZero(v float64) float64 { + if math.IsInf(v, 0) || math.IsNaN(v) { + return 0 + } + + return v +} diff --git a/pkg/inputunifi/collectevents.go b/pkg/inputunifi/collectevents.go index fd654283..d9368993 100644 --- a/pkg/inputunifi/collectevents.go +++ b/pkg/inputunifi/collectevents.go @@ -149,6 +149,12 @@ func (u *InputUnifi) collectAlarms(logs []any, sites []*unifi.Site, c *Controlle } } + for _, d := range devices.UMBBs { + if d.Mac != "" && d.Name != "" { + macToName[strings.ToLower(d.Mac)] = d.Name + } + } + for _, s := range sites { events, err := c.Unifi.GetAlarmsSite(s) if errors.Is(err, unifi.ErrEndpointNotFound) { diff --git a/pkg/inputunifi/collector.go b/pkg/inputunifi/collector.go index 216d2f2b..7682ae5f 100644 --- a/pkg/inputunifi/collector.go +++ b/pkg/inputunifi/collector.go @@ -224,10 +224,10 @@ func (u *InputUnifi) pollNetwork(c *Controller, sites []*unifi.Site, m *Metrics) return fmt.Errorf("unifi.GetDevices(%s): %w", c.URL, err) } - u.LogDebugf("Found %d UBB, %d UXG, %d PDU, %d UCI, %d UDB, %d UAP %d USG %d USW %d UDM devices", + u.LogDebugf("Found %d UBB, %d UXG, %d PDU, %d UCI, %d UDB, %d UMBB, %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.UDBs), len(m.Devices.UAPs), len(m.Devices.USGs), + len(m.Devices.UDBs), len(m.Devices.UMBBs), len(m.Devices.UAPs), len(m.Devices.USGs), len(m.Devices.USWs), len(m.Devices.UDMs)) // Get speed test results for all WANs @@ -1107,6 +1107,10 @@ func applySiteNameOverride(m *poller.Metrics, overrideName string) { if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName } + case *unifi.UMBB: + if isDefaultSiteName(d.SiteName) { + d.SiteName = overrideName + } case *unifi.PDU: if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName @@ -1362,6 +1366,11 @@ func extractDevices(metrics *Metrics) (*poller.Metrics, map[string]string, map[s } } + for _, r := range metrics.Devices.UMBBs { + devices[r.Mac] = r.Name + m.Devices = append(m.Devices, r) + } + for _, r := range metrics.Devices.PDUs { devices[r.Mac] = r.Name m.Devices = append(m.Devices, r) diff --git a/pkg/otelunifi/report.go b/pkg/otelunifi/report.go index 009c4e31..be25c649 100644 --- a/pkg/otelunifi/report.go +++ b/pkg/otelunifi/report.go @@ -24,6 +24,7 @@ type Report struct { USG int // Total count of USG devices exported. UDM int // Total count of UDM devices exported. UXG int // Total count of UXG devices exported. + UMBB int // Total count of UMBB devices exported. Elapsed time.Duration // Duration elapsed collecting and exporting. } @@ -197,6 +198,10 @@ func (u *OtelOutput) exportDevices(ctx context.Context, meter metric.Meter, m *p r.UXG++ u.exportUXG(ctx, meter, r, d) + case *unifi.UMBB: + r.UMBB++ + u.exportUMBB(ctx, meter, r, d) + default: if u.Collector.Poller().LogUnknownTypes { u.LogDebugf("otel: unknown device type: %T", item) diff --git a/pkg/otelunifi/umbb.go b/pkg/otelunifi/umbb.go new file mode 100644 index 00000000..0c331c01 --- /dev/null +++ b/pkg/otelunifi/umbb.go @@ -0,0 +1,128 @@ +package otelunifi + +import ( + "context" + "math" + "strconv" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/unpoller/unifi/v6" +) + +// exportUMBB emits metrics for a mobile broadband (cellular) device, such as the U5G Max. +func (u *OtelOutput) exportUMBB(ctx context.Context, meter metric.Meter, r *Report, s *unifi.UMBB) { + if !s.Adopted.Val { + return + } + + rad := s.Mbb.Radio + attrs := attribute.NewSet( + attribute.String("mac", s.Mac), + attribute.String("site_name", s.SiteName), + attribute.String("source", s.SourceName), + attribute.String("name", s.Name), + attribute.String("model", s.Model), + attribute.String("version", s.Version), + attribute.String("type", s.Type), + attribute.String("ip", s.IP), + attribute.String("mbb_state", s.Mbb.State), + attribute.String("mbb_mode", s.Mbb.Mode), + attribute.String("rat", rad.Rat), + attribute.String("band", rad.Band), + attribute.String("operator", rad.NetworkOperator), + ) + + up := 0.0 + if s.State.Val == 1 { + up = 1.0 + } + + u.recordGauge(ctx, meter, r, "unifi_device_umbb_up", + "Whether UMBB is up (1) or down (0)", up, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_uptime_seconds", + "UMBB uptime in seconds", s.Uptime.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_cpu_utilization", + "UMBB CPU utilization percentage", s.SystemStats.CPU.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_mem_utilization", + "UMBB memory utilization percentage", s.SystemStats.Mem.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_rsrp_dbm", + "Cellular RSRP in dBm", rad.Rsrp.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_rsrq_db", + "Cellular RSRQ in dB", rad.Rsrq.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_snr_db", + "Cellular signal-to-noise ratio in dB", rad.Snr.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_signal_bars", + "Cellular signal strength in bars (0-5)", rad.Signal.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_signal_percent", + "Cellular signal strength percentage", rad.SignalPercent.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_coverage", + "Modem has cellular coverage (1) or not (0)", rad.HasCoverage.Float64(), attrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_roaming", + "Modem is roaming (1) or on its home network (0)", rad.Roaming.Float64(), attrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_registration_state", + "Cellular network registration state code", rad.RegistrationState.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_nr_carriers", + "Aggregated 5G NR component carriers in use", float64(len(rad.CaNr)), attrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_lte_carriers", + "Aggregated LTE component carriers in use", float64(len(rad.CaLte)), attrs) + + dl, ul := mbbBandwidth(rad) + + u.recordGauge(ctx, meter, r, "unifi_device_umbb_dl_bandwidth_mhz", + "Total aggregated downlink carrier bandwidth in MHz", dl, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_ul_bandwidth_mhz", + "Total aggregated uplink carrier bandwidth in MHz", ul, attrs) + + for _, sim := range s.Mbb.Sim { + simAttrs := attribute.NewSet( + attribute.String("mac", s.Mac), + attribute.String("site_name", s.SiteName), + attribute.String("source", s.SourceName), + attribute.String("name", s.Name), + attribute.String("slot", sim.Slot.Txt), + attribute.String("esim", strconv.FormatBool(sim.Esim.Val)), + attribute.String("spn", sim.Spn), + attribute.String("display_state", sim.DisplayState), + attribute.String("network_reject_type", sim.NetworkRejectType), + attribute.String("network_reject_text", sim.NetworkRejectText), + ) + + u.recordGauge(ctx, meter, r, "unifi_device_umbb_sim_active", + "SIM slot is the active data SIM (1) or not (0)", sim.Active.Float64(), simAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_sim_state_seconds", + "Seconds the SIM has been in its current display state", sim.DisplayStateElapsed.Val, simAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_sim_network_reject_age", + "Age of the last carrier network reject, as reported by the controller", sim.NetworkRejectAge.Val, simAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_sim_receive_bytes_total", + "Bytes received over this SIM", sim.RxBytes.Val, simAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_sim_transmit_bytes_total", + "Bytes transmitted over this SIM", sim.TxBytes.Val, simAttrs) + } +} + +// mbbBandwidth sums the downlink and uplink bandwidth of every aggregated carrier, NR and LTE. +// A carrier with no uplink reports a null bandwidth, which decodes as zero. +// A sum that overflows is reported as zero: InfluxDB rejects a whole point with an infinite field. +func mbbBandwidth(rad unifi.MBBRadio) (dl, ul float64) { + for _, c := range rad.CaNr { + dl += c.DlBwMhz.Val + ul += c.UlBwMhz.Val + } + + for _, c := range rad.CaLte { + dl += c.DlBwMhz.Val + ul += c.UlBwMhz.Val + } + + return finiteOrZero(dl), finiteOrZero(ul) +} + +func finiteOrZero(v float64) float64 { + if math.IsInf(v, 0) || math.IsNaN(v) { + return 0 + } + + return v +} diff --git a/pkg/promunifi/collector.go b/pkg/promunifi/collector.go index a663e48e..07db1457 100644 --- a/pkg/promunifi/collector.go +++ b/pkg/promunifi/collector.go @@ -53,6 +53,7 @@ type promUnifi struct { USG *usg USW *usw PDU *pdu + UMBB *umbb Site *site RogueAP *rogueap SpeedTest *speedtest @@ -198,6 +199,7 @@ type Report struct { UBB int // Total count of UBB devices. UCI int // Total count of UCI devices. UDB int // Total count of UDB devices. + UMBB int // Total count of UMBB devices. Metrics *poller.Metrics // Metrics collected and recorded. Elapsed time.Duration // Duration elapsed collecting and exporting. Fetch time.Duration // Duration elapsed making controller requests. @@ -306,6 +308,7 @@ func (u *promUnifi) Run(c poller.Collect) error { u.USG = descUSG(u.Namespace + "_device_") u.USW = descUSW(u.Namespace + "_device_") u.PDU = descPDU(u.Namespace + "_device_") + u.UMBB = descUMBB(u.Namespace + "_device_") u.Site = descSite(u.Namespace + "_site_") u.RogueAP = descRogueAP(u.Namespace + "_rogueap_") u.SpeedTest = descSpeedTest(u.Namespace + "_speedtest_") @@ -1025,6 +1028,9 @@ func (u *promUnifi) switchExport(r report, v any) { case *unifi.UDB: r.addUDB() u.exportUDB(r, v) + case *unifi.UMBB: + r.addUMBB() + u.exportUMBB(r, v) case *unifi.UDM: r.addUDM() u.exportUDM(r, v) diff --git a/pkg/promunifi/report.go b/pkg/promunifi/report.go index eaefab35..da9520d4 100644 --- a/pkg/promunifi/report.go +++ b/pkg/promunifi/report.go @@ -24,6 +24,7 @@ type report interface { addUBB() addUCI() addUDB() + addUMBB() addUSG() addUAP() addUSW() @@ -124,6 +125,10 @@ func (r *Report) addUDB() { r.UDB++ } +func (r *Report) addUMBB() { + r.UMBB++ +} + // close is not part of the interface. func (r *Report) close() { r.wg.Wait() diff --git a/pkg/promunifi/umbb.go b/pkg/promunifi/umbb.go new file mode 100644 index 00000000..eed12e2e --- /dev/null +++ b/pkg/promunifi/umbb.go @@ -0,0 +1,150 @@ +package promunifi + +import ( + "math" + "strconv" + + "github.com/prometheus/client_golang/prometheus" + "github.com/unpoller/unifi/v6" +) + +// umbb holds the cellular modem descriptors for UMBB (mobile broadband) devices, such as the U5G Max. +type umbb struct { + Rsrp *prometheus.Desc + Rsrq *prometheus.Desc + Snr *prometheus.Desc + SignalBars *prometheus.Desc + SignalPercent *prometheus.Desc + Coverage *prometheus.Desc + Roaming *prometheus.Desc + RegistrationState *prometheus.Desc + Carriers *prometheus.Desc + BandwidthMhz *prometheus.Desc + Info *prometheus.Desc + SimActive *prometheus.Desc + SimRxBytes *prometheus.Desc + SimTxBytes *prometheus.Desc + SimStateSeconds *prometheus.Desc + SimRejectAge *prometheus.Desc + SimInfo *prometheus.Desc +} + +func descUMBB(ns string) *umbb { + labels := []string{"type", "site_name", "name", "source", "tag"} + labelS := append(append([]string{}, labels...), "slot", "esim", "spn") + nd := prometheus.NewDesc + + return &umbb{ + Rsrp: nd(ns+"mbb_rsrp_dbm", "Cellular RSRP in dBm.", labels, nil), + Rsrq: nd(ns+"mbb_rsrq_db", "Cellular RSRQ in dB.", labels, nil), + Snr: nd(ns+"mbb_snr_db", "Cellular signal-to-noise ratio in dB.", labels, nil), + SignalBars: nd(ns+"mbb_signal_bars", "Cellular signal strength in bars (0-5).", labels, nil), + SignalPercent: nd(ns+"mbb_signal_percent", "Cellular signal strength percentage.", labels, nil), + Coverage: nd(ns+"mbb_coverage", "Modem has cellular coverage (1) or not (0).", labels, nil), + Roaming: nd(ns+"mbb_roaming", "Modem is roaming (1) or on its home network (0).", labels, nil), + RegistrationState: nd(ns+"mbb_registration_state", "Cellular network registration state code.", labels, nil), + Carriers: nd(ns+"mbb_carriers", "Aggregated component carriers in use.", append(labels, "carrier_type"), nil), + BandwidthMhz: nd(ns+"mbb_bandwidth_mhz", "Total aggregated carrier bandwidth in MHz.", append(labels, "direction"), nil), + Info: nd(ns+"mbb_info", "Cellular modem state. Always 1; state and network details are labels.", + append(labels, "mbb_state", "mbb_mode", "rat", "band", "operator", "sa_mode", "current_slot"), nil), + SimActive: nd(ns+"mbb_sim_active", "SIM slot is the active data SIM (1) or not (0).", labelS, nil), + SimRxBytes: nd(ns+"mbb_sim_receive_bytes_total", "Bytes received over this SIM.", labelS, nil), + SimTxBytes: nd(ns+"mbb_sim_transmit_bytes_total", "Bytes transmitted over this SIM.", labelS, nil), + SimStateSeconds: nd(ns+"mbb_sim_state_seconds", "Seconds the SIM has been in its current display state.", labelS, nil), + SimRejectAge: nd(ns+"mbb_sim_network_reject_age", + "Age of the last carrier network reject, as reported by the controller.", labelS, nil), + SimInfo: nd(ns+"mbb_sim_info", "SIM state. Always 1; display state and last network reject are labels.", + append(labelS, "display_state", "network_reject_type", "network_reject_text"), nil), + } +} + +// exportUMBB exports metrics for UMBB (mobile broadband) devices, such as the U5G Max. +func (u *promUnifi) exportUMBB(r report, d *unifi.UMBB) { + if !d.Adopted.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(append([]string{}, baseLabels...), tag) + infoLabels := append(append([]string{}, baseInfoLabels...), tag) + + u.exportSYSstats(r, labels, d.SysStats, d.SystemStats) + u.exportDeviceUplink(r, labels, d.Uplink) + u.exportMBB(r, labels, d.Mbb) + + r.send([]*metric{ + {u.Device.Info, gauge, 1.0, append(append([]string{}, baseLabels...), infoLabels...)}, + {u.Device.Uptime, gauge, d.Uptime, labels}, + {u.Device.Upgradeable, gauge, d.Upgradable.Val, labels}, + }) + }) +} + +func (u *promUnifi) exportMBB(r report, labels []string, m unifi.MBB) { + rad := m.Radio + dl, ul := mbbBandwidth(rad) + info := append(append([]string{}, labels...), + m.State, m.Mode, rad.Rat, rad.Band, rad.NetworkOperator, + strconv.FormatBool(rad.SA5GMode.Val), rad.CurrentSlot.Txt) + + r.send([]*metric{ + {u.UMBB.Rsrp, gauge, rad.Rsrp, labels}, + {u.UMBB.Rsrq, gauge, rad.Rsrq, labels}, + {u.UMBB.Snr, gauge, rad.Snr, labels}, + {u.UMBB.SignalBars, gauge, rad.Signal, labels}, + {u.UMBB.SignalPercent, gauge, rad.SignalPercent, labels}, + {u.UMBB.Coverage, gauge, rad.HasCoverage.Val, labels}, + {u.UMBB.Roaming, gauge, rad.Roaming.Val, labels}, + {u.UMBB.RegistrationState, gauge, rad.RegistrationState, labels}, + {u.UMBB.Carriers, gauge, float64(len(rad.CaNr)), append(append([]string{}, labels...), "nr")}, + {u.UMBB.Carriers, gauge, float64(len(rad.CaLte)), append(append([]string{}, labels...), "lte")}, + {u.UMBB.BandwidthMhz, gauge, dl, append(append([]string{}, labels...), "dl")}, + {u.UMBB.BandwidthMhz, gauge, ul, append(append([]string{}, labels...), "ul")}, + {u.UMBB.Info, gauge, 1.0, info}, + }) + + for _, sim := range m.Sim { + simLabels := append(append([]string{}, labels...), + sim.Slot.Txt, strconv.FormatBool(sim.Esim.Val), sim.Spn) + simInfo := append(append([]string{}, simLabels...), + sim.DisplayState, sim.NetworkRejectType, sim.NetworkRejectText) + + r.send([]*metric{ + {u.UMBB.SimActive, gauge, sim.Active.Val, simLabels}, + {u.UMBB.SimRxBytes, counter, sim.RxBytes, simLabels}, + {u.UMBB.SimTxBytes, counter, sim.TxBytes, simLabels}, + {u.UMBB.SimStateSeconds, gauge, sim.DisplayStateElapsed, simLabels}, + {u.UMBB.SimRejectAge, gauge, sim.NetworkRejectAge, simLabels}, + {u.UMBB.SimInfo, gauge, 1.0, simInfo}, + }) + } +} + +// mbbBandwidth sums the downlink and uplink bandwidth of every aggregated carrier, NR and LTE. +// A carrier with no uplink reports a null bandwidth, which decodes as zero. +// A sum that overflows is reported as zero: InfluxDB rejects a whole point with an infinite field. +func mbbBandwidth(rad unifi.MBBRadio) (dl, ul float64) { + for _, c := range rad.CaNr { + dl += c.DlBwMhz.Val + ul += c.UlBwMhz.Val + } + + for _, c := range rad.CaLte { + dl += c.DlBwMhz.Val + ul += c.UlBwMhz.Val + } + + return finiteOrZero(dl), finiteOrZero(ul) +} + +func finiteOrZero(v float64) float64 { + if math.IsInf(v, 0) || math.IsNaN(v) { + return 0 + } + + return v +} diff --git a/pkg/promunifi/umbb_test.go b/pkg/promunifi/umbb_test.go new file mode 100644 index 00000000..bce0a05f --- /dev/null +++ b/pkg/promunifi/umbb_test.go @@ -0,0 +1,118 @@ +//nolint:testpackage // white-box: exercises the unexported descriptors and export path. +package promunifi + +import ( + "encoding/json" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/unpoller/unifi/v6" +) + +// Trimmed from a U5G Max (UMBBE630) on 5G SA. Identifiers and addresses are omitted. +const u5gMaxJSON = `{ + "adopted": true, + "type": "umbb", + "model": "UMBBE630", + "name": "U5G Max", + "mac": "00:00:5e:00:53:02", + "version": "7.5.3.19533", + "uptime": 5264387, + "mbb": { + "mode": "failover", + "state": "ready", + "sim": [ + {"active": false, "slot": 1, "esim": false, "display_state": "no-sim", "display_state_elapsed": 5264323}, + { + "active": true, "slot": 2, "esim": true, "spn": "EE", + "display_state": "operational", "display_state_elapsed": 5230, + "network_reject_type": "wds", "network_reject_text": "Carrier rejected data", "network_reject_age": 45, + "rxbytes": "3013150278176", "txbytes": "725650860811" + } + ], + "radio": { + "5g_sa_mode": true, "band": "n78", "rat": "5G", "networkoperator": "EE", "current_slot": 2, + "has_coverage": true, "roaming": false, "registration_state": 1, + "rsrp": -96, "rsrq": -13, "snr": 17.600000381469727, "signal": 3, "signal_percent": 95, + "ca_lte": [], + "ca_nr": [ + {"band": 78, "dl_arfcn": 637334, "dl_bw_mhz": 40.0, "primary": true, "ul_arfcn": 637334, "ul_bw_mhz": 40.0}, + {"band": 78, "dl_arfcn": 646666, "dl_bw_mhz": 40.0, "primary": false, "ul_arfcn": 0, "ul_bw_mhz": null} + ] + } + } +}` + +func TestExportUMBB(t *testing.T) { + t.Parallel() + + var d unifi.UMBB + + require.NoError(t, json.Unmarshal([]byte(u5gMaxJSON), &d)) + + d.SiteName = "Default" + d.SourceName = "https://udm.example" + + r := &fakeReport{} + u := testProm() + u.UMBB = descUMBB("unifi_") + u.exportUMBB(r, &d) + + a := assert.New(t) + labels := []string{"umbb", "Default", "U5G Max", "https://udm.example", ""} + + a.InDelta(-96, findMetric(t, r.sent, "unifi_mbb_rsrp_dbm", labels).value, 0.001) + a.InDelta(-13, findMetric(t, r.sent, "unifi_mbb_rsrq_db", labels).value, 0.001) + a.InDelta(17.6, findMetric(t, r.sent, "unifi_mbb_snr_db", labels).value, 0.001) + a.InDelta(3, findMetric(t, r.sent, "unifi_mbb_signal_bars", labels).value, 0.001) + a.InDelta(95, findMetric(t, r.sent, "unifi_mbb_signal_percent", labels).value, 0.001) + a.InDelta(1, findMetric(t, r.sent, "unifi_mbb_coverage", labels).value, 0.001) + a.InDelta(2, findMetric(t, r.sent, "unifi_mbb_carriers", append(labels, "nr")).value, 0.001) + a.InDelta(0, findMetric(t, r.sent, "unifi_mbb_carriers", append(labels, "lte")).value, 0.001) + a.InDelta(80, findMetric(t, r.sent, "unifi_mbb_bandwidth_mhz", append(labels, "dl")).value, 0.001) + a.InDelta(40, findMetric(t, r.sent, "unifi_mbb_bandwidth_mhz", append(labels, "ul")).value, 0.001, + "a null uplink bandwidth adds nothing") + a.InDelta(1, findMetric(t, r.sent, "unifi_mbb_info", + append(labels, "ready", "failover", "5G", "n78", "EE", "true", "2")).value, 0.001) + + noSim := append(labels, "1", "false", "") + a.InDelta(0, findMetric(t, r.sent, "unifi_mbb_sim_active", noSim).value, 0.001) + a.InDelta(1, findMetric(t, r.sent, "unifi_mbb_sim_info", append(noSim, "no-sim", "", "")).value, 0.001) + + esim := append(labels, "2", "true", "EE") + a.InDelta(1, findMetric(t, r.sent, "unifi_mbb_sim_active", esim).value, 0.001) + a.InDelta(3013150278176, findMetric(t, r.sent, "unifi_mbb_sim_receive_bytes_total", esim).value, 1) + a.InDelta(725650860811, findMetric(t, r.sent, "unifi_mbb_sim_transmit_bytes_total", esim).value, 1) + a.InDelta(5230, findMetric(t, r.sent, "unifi_mbb_sim_state_seconds", esim).value, 0.001) + a.InDelta(45, findMetric(t, r.sent, "unifi_mbb_sim_network_reject_age", esim).value, 0.001) + a.InDelta(1, findMetric(t, r.sent, "unifi_mbb_sim_info", + append(esim, "operational", "wds", "Carrier rejected data")).value, 0.001) +} + +func TestExportUMBBUnadoptedIsSkipped(t *testing.T) { + t.Parallel() + + r := &fakeReport{} + u := testProm() + u.UMBB = descUMBB("unifi_") + u.exportUMBB(r, &unifi.UMBB{Name: "U5G Max"}) + + assert.Empty(t, r.sent) +} + +// findMetric returns the one series with this name and exact label values. +func findMetric(t *testing.T, sent []*metric, name string, labels []string) recordedMetric { + t.Helper() + + for _, m := range sent { + if prometheusDescName(t, m.Desc) == name && slices.Equal(m.Labels, labels) { + return recordedMetric{value: metricFloat(t, m), labels: m.Labels} + } + } + + t.Fatalf("no %s series with labels %q", name, labels) + + return recordedMetric{} +} From 5ed9b00598059452ec8e5a9b4d20de033b63e883 Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Thu, 1 Oct 2026 14:10:30 +0100 Subject: [PATCH 2/3] chore(deps): bump github.com/unpoller/unifi/v6 to v6.3.0 v6.3.0 includes UMBB device support (unpoller/unifi#252), so the temporary replace pointing at a fork is no longer needed. --- go.mod | 4 +--- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 646a43d0..9d58feb8 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/prometheus/common v0.71.0 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.12.1 - github.com/unpoller/unifi/v6 v6.2.0 + github.com/unpoller/unifi/v6 v6.3.0 go.opentelemetry.io/otel v1.46.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0 @@ -71,5 +71,3 @@ require ( golang.org/x/sys v0.48.0 // indirect google.golang.org/protobuf v1.36.12 // indirect ) - -replace github.com/unpoller/unifi/v6 => github.com/tomwilkie/unifi/v6 v6.2.1-0.20261001091512-6281bfb42116 diff --git a/go.sum b/go.sum index ae4885ff..7fd4f831 100644 --- a/go.sum +++ b/go.sum @@ -121,8 +121,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/tomwilkie/unifi/v6 v6.2.1-0.20261001091512-6281bfb42116 h1:Bpq6WiKwFwgfb8T3JMCWK/VooyLIp8oI1zmmI99Riks= -github.com/tomwilkie/unifi/v6 v6.2.1-0.20261001091512-6281bfb42116/go.mod h1:fJ1lU/mpKrQ8Jv3b2yq399ATlrUuiOZAMbKWcw/1GaU= +github.com/unpoller/unifi/v6 v6.3.0 h1:J7etDpdkarEG9sOiSORtzZB93RDyzXu9KqZ4cWoBYcQ= +github.com/unpoller/unifi/v6 v6.3.0/go.mod h1:fJ1lU/mpKrQ8Jv3b2yq399ATlrUuiOZAMbKWcw/1GaU= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= From dd8a42476afee07cafe3009f4c39a0d3eacf595d Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Thu, 1 Oct 2026 21:30:49 +0100 Subject: [PATCH 3/3] feat(umbb): review fixes for cellular modem metrics - Skip signal readings and max bitrates the modem does not report, so a missing reading is not exported as 0 dBm. - Export the 5G NR leg (rsrp_nr, rsrq_nr, snr_nr) separately; the plain values are the LTE anchor in 5G NSA mode. - Export max negotiated bitrate, internet connectivity, and per-SIM card present, PIN blocked, data warning and data limited. - Add primary slot, MCC/MNC, cell ID, PCI and channel to mbb_info, and has_carrier/metered to mbb_sim_info. - Rename the mbb_mode label/field to failover_mode, matching lte_info. - Drop network_reject_age: its unit is undocumented and it does not behave like a running age. - Clarify descriptions for registration_state, signal and SIM byte counters, and count UMBB devices in the Prometheus export log line. Signed-off-by: Tom Wilkie --- .../integration_test_expectations.yaml | 14 ++- pkg/datadogunifi/umbb.go | 72 ++++++++---- .../integration_test_expectations.yaml | 23 +++- pkg/influxunifi/umbb.go | 52 ++++++-- pkg/otelunifi/umbb.go | 58 ++++++--- pkg/promunifi/report.go | 4 +- pkg/promunifi/umbb.go | 111 +++++++++++++----- pkg/promunifi/umbb_test.go | 57 ++++++++- 8 files changed, 307 insertions(+), 84 deletions(-) diff --git a/pkg/datadogunifi/integration_test_expectations.yaml b/pkg/datadogunifi/integration_test_expectations.yaml index c65aeda5..10f5e068 100644 --- a/pkg/datadogunifi/integration_test_expectations.yaml +++ b/pkg/datadogunifi/integration_test_expectations.yaml @@ -567,19 +567,25 @@ gauges: - unifi.udb.user_num_sta - unifi.udb.user_wlan_num_sta - unifi.umbb_sim.active + - unifi.umbb_sim.card_present + - unifi.umbb_sim.data_limited + - unifi.umbb_sim.data_warning - unifi.umbb_sim.display_state_elapsed - - unifi.umbb_sim.network_reject_age + - unifi.umbb_sim.pin_blocked - unifi.umbb_sim.rx_bytes - unifi.umbb_sim.tx_bytes - unifi.umbb.cpu - unifi.umbb.current_slot - unifi.umbb.dl_bandwidth_mhz - unifi.umbb.has_coverage + - unifi.umbb.internet - unifi.umbb.last_seen - unifi.umbb.loadavg_1 - unifi.umbb.loadavg_15 - unifi.umbb.loadavg_5 - unifi.umbb.lte_carriers + - unifi.umbb.lte_max_bitrate_dl + - unifi.umbb.lte_max_bitrate_ul - unifi.umbb.mem - unifi.umbb.mem_buffer - unifi.umbb.mem_total @@ -587,6 +593,12 @@ gauges: - unifi.umbb.memory - unifi.umbb.network - unifi.umbb.nr_carriers + - unifi.umbb.nr_max_bitrate_dl + - unifi.umbb.nr_max_bitrate_ul + - unifi.umbb.nr_rsrp + - unifi.umbb.nr_rsrq + - unifi.umbb.nr_snr + - unifi.umbb.primary_slot - unifi.umbb.probe - unifi.umbb.registration_state - unifi.umbb.roaming diff --git a/pkg/datadogunifi/umbb.go b/pkg/datadogunifi/umbb.go index 14c638e4..49c86817 100644 --- a/pkg/datadogunifi/umbb.go +++ b/pkg/datadogunifi/umbb.go @@ -21,20 +21,20 @@ func (u *DatadogUnifi) batchUMBB(r report, s *unifi.UMBB) { dl, ul := mbbBandwidth(rad) 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, - "mbb_state": s.Mbb.State, - "mbb_mode": s.Mbb.Mode, - "rat": rad.Rat, - "band": rad.Band, - "operator": rad.NetworkOperator, + "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, + "mbb_state": s.Mbb.State, + "failover_mode": s.Mbb.Mode, + "rat": rad.Rat, + "band": rad.Band, + "operator": rad.NetworkOperator, }) data := CombineFloat64( @@ -50,11 +50,8 @@ func (u *DatadogUnifi) batchUMBB(r report, s *unifi.UMBB) { "uplink_uptime": s.Uplink.Uptime.Val, "sa_mode": rad.SA5GMode.Float64(), "current_slot": rad.CurrentSlot.Val, - "rsrp": rad.Rsrp.Val, - "rsrq": rad.Rsrq.Val, - "snr": rad.Snr.Val, - "signal": rad.Signal.Val, - "signal_percent": rad.SignalPercent.Val, + "primary_slot": s.MbbOverrides.PrimarySlot.Val, + "internet": s.Internet.Float64(), "has_coverage": rad.HasCoverage.Float64(), "roaming": rad.Roaming.Float64(), "registration_state": rad.RegistrationState.Val, @@ -62,7 +59,8 @@ func (u *DatadogUnifi) batchUMBB(r report, s *unifi.UMBB) { "lte_carriers": float64(len(rad.CaLte)), "dl_bandwidth_mhz": dl, "ul_bandwidth_mhz": ul, - }) + }, + mbbOptional(rad)) r.addCount(umbbT) reportGaugeForFloat64Map(r, metricNamespace("umbb"), data, tags) @@ -79,13 +77,18 @@ func (u *DatadogUnifi) batchUMBB(r report, s *unifi.UMBB) { "esim": strconv.FormatBool(sim.Esim.Val), "spn": sim.Spn, "display_state": sim.DisplayState, + "has_carrier": strconv.FormatBool(sim.HasCarrier.Val), + "metered": strconv.FormatBool(sim.Metered.Val), "network_reject_type": sim.NetworkRejectType, "network_reject_text": sim.NetworkRejectText, }) simData := map[string]float64{ "active": sim.Active.Float64(), + "card_present": sim.CardPresent.Float64(), + "pin_blocked": sim.PinBlocked.Float64(), + "data_warning": sim.DataWarning.Float64(), + "data_limited": sim.DataLimited.Float64(), "display_state_elapsed": sim.DisplayStateElapsed.Val, - "network_reject_age": sim.NetworkRejectAge.Val, "rx_bytes": sim.RxBytes.Val, "tx_bytes": sim.TxBytes.Val, } @@ -94,6 +97,33 @@ func (u *DatadogUnifi) batchUMBB(r report, s *unifi.UMBB) { } } +// mbbOptional returns the signal readings and bitrates the modem reported, keyed by field name. +// Missing readings are left out, so they are not mistaken for 0 dBm. +func mbbOptional(rad unifi.MBBRadio) map[string]float64 { + out := map[string]float64{} + + for k, v := range map[string]unifi.FlexInt{ + "rsrp": rad.Rsrp, + "rsrq": rad.Rsrq, + "snr": rad.Snr, + "nr_rsrp": rad.RsrpNr, + "nr_rsrq": rad.RsrqNr, + "nr_snr": rad.SnrNr, + "signal": rad.Signal, + "signal_percent": rad.SignalPercent, + "lte_max_bitrate_dl": rad.MaxBitrateDl, + "lte_max_bitrate_ul": rad.MaxBitrateUl, + "nr_max_bitrate_dl": rad.MaxBitrateDlNr, + "nr_max_bitrate_ul": rad.MaxBitrateUlNr, + } { + if v.Txt != "" { + out[k] = v.Val + } + } + + return out +} + // mbbBandwidth sums the downlink and uplink bandwidth of every aggregated carrier, NR and LTE. // A carrier with no uplink reports a null bandwidth, which decodes as zero. // A sum that overflows is reported as zero: InfluxDB rejects a whole point with an infinite field. diff --git a/pkg/influxunifi/integration_test_expectations.yaml b/pkg/influxunifi/integration_test_expectations.yaml index 2275345e..0b713e20 100644 --- a/pkg/influxunifi/integration_test_expectations.yaml +++ b/pkg/influxunifi/integration_test_expectations.yaml @@ -683,24 +683,38 @@ points: - version fields: band: string + cell_id: float + channel: float cpu: float current_slot: float dl_bandwidth_mhz: float + failover_mode: string has_coverage: bool + internet: bool ip: string last_seen: float loadavg_1: float loadavg_5: float loadavg_15: float lte_carriers: float - mbb_mode: string + lte_max_bitrate_dl: float + lte_max_bitrate_ul: float mbb_state: string + mcc: float mem: float mem_buffer: float mem_total: float mem_used: float + mnc: float nr_carriers: float + nr_max_bitrate_dl: float + nr_max_bitrate_ul: float + nr_rsrp: float + nr_rsrq: float + nr_snr: float operator: string + pci: float + primary_slot: float rat: string registration_state: float roaming: bool @@ -737,11 +751,16 @@ points: - type fields: active: bool + card_present: bool + data_limited: bool + data_warning: bool display_state: string display_state_elapsed: float - network_reject_age: float + has_carrier: bool + metered: bool network_reject_text: string network_reject_type: string + pin_blocked: bool rx_bytes: float tx_bytes: float unifi_alarm: diff --git a/pkg/influxunifi/umbb.go b/pkg/influxunifi/umbb.go index 5ee0fcc3..408fc341 100644 --- a/pkg/influxunifi/umbb.go +++ b/pkg/influxunifi/umbb.go @@ -43,18 +43,20 @@ func (u *InfluxUnifi) batchUMBB(r report, s *unifi.UMBB) { "uplink_max_speed": s.Uplink.MaxSpeed.Val, "uplink_latency": s.Uplink.Latency.Val, "uplink_uptime": s.Uplink.Uptime.Val, + "internet": s.Internet.Val, "mbb_state": s.Mbb.State, - "mbb_mode": s.Mbb.Mode, + "failover_mode": s.Mbb.Mode, "rat": rad.Rat, "band": rad.Band, "operator": rad.NetworkOperator, "sa_mode": rad.SA5GMode.Val, "current_slot": rad.CurrentSlot.Val, - "rsrp": rad.Rsrp.Val, - "rsrq": rad.Rsrq.Val, - "snr": rad.Snr.Val, - "signal": rad.Signal.Val, - "signal_percent": rad.SignalPercent.Val, + "primary_slot": s.MbbOverrides.PrimarySlot.Val, + "mcc": rad.Mcc.Val, + "mnc": rad.Mnc.Val, + "cell_id": rad.CellID.Val, + "pci": rad.Pci.Val, + "channel": rad.Channel.Val, "has_coverage": rad.HasCoverage.Val, "roaming": rad.Roaming.Val, "registration_state": rad.RegistrationState.Val, @@ -64,6 +66,10 @@ func (u *InfluxUnifi) batchUMBB(r report, s *unifi.UMBB) { "ul_bandwidth_mhz": ul, }) + for k, v := range mbbOptional(rad) { + fields[k] = v + } + r.addCount(umbbT) r.send(&metric{Table: "umbb", Tags: tags, Fields: fields}) @@ -83,11 +89,16 @@ func (u *InfluxUnifi) batchUMBB(r report, s *unifi.UMBB) { }, Fields: map[string]any{ "active": sim.Active.Val, + "card_present": sim.CardPresent.Val, + "pin_blocked": sim.PinBlocked.Val, + "has_carrier": sim.HasCarrier.Val, + "metered": sim.Metered.Val, + "data_warning": sim.DataWarning.Val, + "data_limited": sim.DataLimited.Val, "display_state": sim.DisplayState, "display_state_elapsed": sim.DisplayStateElapsed.Val, "network_reject_type": sim.NetworkRejectType, "network_reject_text": sim.NetworkRejectText, - "network_reject_age": sim.NetworkRejectAge.Val, "rx_bytes": sim.RxBytes.Val, "tx_bytes": sim.TxBytes.Val, }, @@ -95,6 +106,33 @@ func (u *InfluxUnifi) batchUMBB(r report, s *unifi.UMBB) { } } +// mbbOptional returns the signal readings and bitrates the modem reported, keyed by field name. +// Missing readings are left out, so they are not mistaken for 0 dBm. +func mbbOptional(rad unifi.MBBRadio) map[string]float64 { + out := map[string]float64{} + + for k, v := range map[string]unifi.FlexInt{ + "rsrp": rad.Rsrp, + "rsrq": rad.Rsrq, + "snr": rad.Snr, + "nr_rsrp": rad.RsrpNr, + "nr_rsrq": rad.RsrqNr, + "nr_snr": rad.SnrNr, + "signal": rad.Signal, + "signal_percent": rad.SignalPercent, + "lte_max_bitrate_dl": rad.MaxBitrateDl, + "lte_max_bitrate_ul": rad.MaxBitrateUl, + "nr_max_bitrate_dl": rad.MaxBitrateDlNr, + "nr_max_bitrate_ul": rad.MaxBitrateUlNr, + } { + if v.Txt != "" { + out[k] = v.Val + } + } + + return out +} + // mbbBandwidth sums the downlink and uplink bandwidth of every aggregated carrier, NR and LTE. // A carrier with no uplink reports a null bandwidth, which decodes as zero. // A sum that overflows is reported as zero: InfluxDB rejects a whole point with an infinite field. diff --git a/pkg/otelunifi/umbb.go b/pkg/otelunifi/umbb.go index 0c331c01..30193651 100644 --- a/pkg/otelunifi/umbb.go +++ b/pkg/otelunifi/umbb.go @@ -28,7 +28,7 @@ func (u *OtelOutput) exportUMBB(ctx context.Context, meter metric.Meter, r *Repo attribute.String("type", s.Type), attribute.String("ip", s.IP), attribute.String("mbb_state", s.Mbb.State), - attribute.String("mbb_mode", s.Mbb.Mode), + attribute.String("failover_mode", s.Mbb.Mode), attribute.String("rat", rad.Rat), attribute.String("band", rad.Band), attribute.String("operator", rad.NetworkOperator), @@ -47,22 +47,40 @@ func (u *OtelOutput) exportUMBB(ctx context.Context, meter metric.Meter, r *Repo "UMBB CPU utilization percentage", s.SystemStats.CPU.Val, attrs) u.recordGauge(ctx, meter, r, "unifi_device_umbb_mem_utilization", "UMBB memory utilization percentage", s.SystemStats.Mem.Val, attrs) - u.recordGauge(ctx, meter, r, "unifi_device_umbb_rsrp_dbm", - "Cellular RSRP in dBm", rad.Rsrp.Val, attrs) - u.recordGauge(ctx, meter, r, "unifi_device_umbb_rsrq_db", - "Cellular RSRQ in dB", rad.Rsrq.Val, attrs) - u.recordGauge(ctx, meter, r, "unifi_device_umbb_snr_db", - "Cellular signal-to-noise ratio in dB", rad.Snr.Val, attrs) - u.recordGauge(ctx, meter, r, "unifi_device_umbb_signal_bars", - "Cellular signal strength in bars (0-5)", rad.Signal.Val, attrs) - u.recordGauge(ctx, meter, r, "unifi_device_umbb_signal_percent", - "Cellular signal strength percentage", rad.SignalPercent.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_internet", + "Modem reports internet connectivity (1) or not (0)", s.Internet.Float64(), attrs) + + // Signal readings and bitrates are left out when the modem does not report them, + // so a missing reading is not mistaken for 0 dBm. + for _, g := range []struct { + name, desc string + val unifi.FlexInt + }{ + {"unifi_device_umbb_rsrp_dbm", "Cellular RSRP of the serving cell in dBm. LTE anchor in 5G NSA mode", rad.Rsrp}, + {"unifi_device_umbb_rsrq_db", "Cellular RSRQ of the serving cell in dB. LTE anchor in 5G NSA mode", rad.Rsrq}, + {"unifi_device_umbb_snr_db", "Cellular signal-to-noise ratio of the serving cell in dB. LTE anchor in 5G NSA mode", rad.Snr}, + {"unifi_device_umbb_nr_rsrp_dbm", "5G NR RSRP in dBm", rad.RsrpNr}, + {"unifi_device_umbb_nr_rsrq_db", "5G NR RSRQ in dB", rad.RsrqNr}, + {"unifi_device_umbb_nr_snr_db", "5G NR signal-to-noise ratio in dB", rad.SnrNr}, + {"unifi_device_umbb_signal_bars", "Cellular signal strength in bars, as shown by the controller", rad.Signal}, + {"unifi_device_umbb_signal_percent", "Cellular signal strength percentage (0-100)", rad.SignalPercent}, + {"unifi_device_umbb_lte_max_bitrate_dl_bps", "Maximum LTE downlink bitrate in bits per second", rad.MaxBitrateDl}, + {"unifi_device_umbb_lte_max_bitrate_ul_bps", "Maximum LTE uplink bitrate in bits per second", rad.MaxBitrateUl}, + {"unifi_device_umbb_nr_max_bitrate_dl_bps", "Maximum 5G NR downlink bitrate in bits per second", rad.MaxBitrateDlNr}, + {"unifi_device_umbb_nr_max_bitrate_ul_bps", "Maximum 5G NR uplink bitrate in bits per second", rad.MaxBitrateUlNr}, + } { + if g.val.Txt != "" { + u.recordGauge(ctx, meter, r, g.name, g.desc, g.val.Val, attrs) + } + } + u.recordGauge(ctx, meter, r, "unifi_device_umbb_coverage", "Modem has cellular coverage (1) or not (0)", rad.HasCoverage.Float64(), attrs) u.recordGauge(ctx, meter, r, "unifi_device_umbb_roaming", "Modem is roaming (1) or on its home network (0)", rad.Roaming.Float64(), attrs) u.recordGauge(ctx, meter, r, "unifi_device_umbb_registration_state", - "Cellular network registration state code", rad.RegistrationState.Val, attrs) + "Cellular network registration state code (undocumented; likely 0 not registered, 1 home, "+ + "2 searching, 3 denied, 4 unknown, 5 roaming)", rad.RegistrationState.Val, attrs) u.recordGauge(ctx, meter, r, "unifi_device_umbb_nr_carriers", "Aggregated 5G NR component carriers in use", float64(len(rad.CaNr)), attrs) u.recordGauge(ctx, meter, r, "unifi_device_umbb_lte_carriers", @@ -85,6 +103,8 @@ func (u *OtelOutput) exportUMBB(ctx context.Context, meter metric.Meter, r *Repo attribute.String("esim", strconv.FormatBool(sim.Esim.Val)), attribute.String("spn", sim.Spn), attribute.String("display_state", sim.DisplayState), + attribute.Bool("has_carrier", sim.HasCarrier.Val), + attribute.Bool("metered", sim.Metered.Val), attribute.String("network_reject_type", sim.NetworkRejectType), attribute.String("network_reject_text", sim.NetworkRejectText), ) @@ -93,12 +113,18 @@ func (u *OtelOutput) exportUMBB(ctx context.Context, meter metric.Meter, r *Repo "SIM slot is the active data SIM (1) or not (0)", sim.Active.Float64(), simAttrs) u.recordGauge(ctx, meter, r, "unifi_device_umbb_sim_state_seconds", "Seconds the SIM has been in its current display state", sim.DisplayStateElapsed.Val, simAttrs) - u.recordGauge(ctx, meter, r, "unifi_device_umbb_sim_network_reject_age", - "Age of the last carrier network reject, as reported by the controller", sim.NetworkRejectAge.Val, simAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_sim_card_present", + "SIM card or eSIM profile is present (1) or not (0)", sim.CardPresent.Float64(), simAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_sim_pin_blocked", + "SIM is PIN blocked (1) or not (0)", sim.PinBlocked.Float64(), simAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_sim_data_warning", + "SIM has passed its data usage warning threshold (1) or not (0)", sim.DataWarning.Float64(), simAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_umbb_sim_data_limited", + "SIM has reached its data limit (1) or not (0)", sim.DataLimited.Float64(), simAttrs) u.recordGauge(ctx, meter, r, "unifi_device_umbb_sim_receive_bytes_total", - "Bytes received over this SIM", sim.RxBytes.Val, simAttrs) + "Bytes received over this SIM. Cumulative; not reset when the modem reboots", sim.RxBytes.Val, simAttrs) u.recordGauge(ctx, meter, r, "unifi_device_umbb_sim_transmit_bytes_total", - "Bytes transmitted over this SIM", sim.TxBytes.Val, simAttrs) + "Bytes transmitted over this SIM. Cumulative; not reset when the modem reboots", sim.TxBytes.Val, simAttrs) } } diff --git a/pkg/promunifi/report.go b/pkg/promunifi/report.go index da9520d4..9485d4ac 100644 --- a/pkg/promunifi/report.go +++ b/pkg/promunifi/report.go @@ -52,9 +52,9 @@ func (r *Report) report(c poller.Logger, descs map[*prometheus.Desc]bool) { m := r.Metrics c.Logf("UniFi Measurements Exported. Site: %d, Client: %d, "+ - "UAP: %d, USG/UDM: %d, USW: %d, DPI Site/Client: %d/%d, Countries: %d, Desc: %d, "+ + "UAP: %d, USG/UDM: %d, USW: %d, UMBB: %d, DPI Site/Client: %d/%d, Countries: %d, Desc: %d, "+ "Metric: %d, Bytes: %d, Err: %d, 0s: %d, Req/Total: %v / %v", - len(m.Sites), len(m.Clients), r.UAP, r.UDM+r.USG+r.UXG, r.USW, len(m.SitesDPI), + len(m.Sites), len(m.Clients), r.UAP, r.UDM+r.USG+r.UXG, r.USW, r.UMBB, len(m.SitesDPI), len(m.ClientsDPI), len(m.CountryTraffic), len(descs), r.Total, r.Bytes, r.Errors, r.Zeros, r.Fetch.Round(time.Millisecond/oneDecimalPoint), r.Elapsed.Round(time.Millisecond/oneDecimalPoint)) diff --git a/pkg/promunifi/umbb.go b/pkg/promunifi/umbb.go index eed12e2e..a847e336 100644 --- a/pkg/promunifi/umbb.go +++ b/pkg/promunifi/umbb.go @@ -10,9 +10,13 @@ import ( // umbb holds the cellular modem descriptors for UMBB (mobile broadband) devices, such as the U5G Max. type umbb struct { + Internet *prometheus.Desc Rsrp *prometheus.Desc Rsrq *prometheus.Desc Snr *prometheus.Desc + RsrpNr *prometheus.Desc + RsrqNr *prometheus.Desc + SnrNr *prometheus.Desc SignalBars *prometheus.Desc SignalPercent *prometheus.Desc Coverage *prometheus.Desc @@ -20,12 +24,16 @@ type umbb struct { RegistrationState *prometheus.Desc Carriers *prometheus.Desc BandwidthMhz *prometheus.Desc + MaxBitrate *prometheus.Desc Info *prometheus.Desc SimActive *prometheus.Desc + SimCardPresent *prometheus.Desc + SimPinBlocked *prometheus.Desc + SimDataWarning *prometheus.Desc + SimDataLimited *prometheus.Desc SimRxBytes *prometheus.Desc SimTxBytes *prometheus.Desc SimStateSeconds *prometheus.Desc - SimRejectAge *prometheus.Desc SimInfo *prometheus.Desc } @@ -35,26 +43,44 @@ func descUMBB(ns string) *umbb { nd := prometheus.NewDesc return &umbb{ - Rsrp: nd(ns+"mbb_rsrp_dbm", "Cellular RSRP in dBm.", labels, nil), - Rsrq: nd(ns+"mbb_rsrq_db", "Cellular RSRQ in dB.", labels, nil), - Snr: nd(ns+"mbb_snr_db", "Cellular signal-to-noise ratio in dB.", labels, nil), - SignalBars: nd(ns+"mbb_signal_bars", "Cellular signal strength in bars (0-5).", labels, nil), - SignalPercent: nd(ns+"mbb_signal_percent", "Cellular signal strength percentage.", labels, nil), - Coverage: nd(ns+"mbb_coverage", "Modem has cellular coverage (1) or not (0).", labels, nil), - Roaming: nd(ns+"mbb_roaming", "Modem is roaming (1) or on its home network (0).", labels, nil), - RegistrationState: nd(ns+"mbb_registration_state", "Cellular network registration state code.", labels, nil), - Carriers: nd(ns+"mbb_carriers", "Aggregated component carriers in use.", append(labels, "carrier_type"), nil), - BandwidthMhz: nd(ns+"mbb_bandwidth_mhz", "Total aggregated carrier bandwidth in MHz.", append(labels, "direction"), nil), + Internet: nd(ns+"mbb_internet", "Modem reports internet connectivity (1) or not (0).", labels, nil), + Rsrp: nd(ns+"mbb_rsrp_dbm", "Cellular RSRP of the serving cell in dBm. LTE anchor in 5G NSA mode.", labels, nil), + Rsrq: nd(ns+"mbb_rsrq_db", "Cellular RSRQ of the serving cell in dB. LTE anchor in 5G NSA mode.", labels, nil), + Snr: nd(ns+"mbb_snr_db", "Cellular signal-to-noise ratio of the serving cell in dB. LTE anchor in 5G NSA mode.", labels, nil), + RsrpNr: nd(ns+"mbb_nr_rsrp_dbm", "5G NR RSRP in dBm. Same as mbb_rsrp_dbm in 5G SA mode.", labels, nil), + RsrqNr: nd(ns+"mbb_nr_rsrq_db", "5G NR RSRQ in dB. Same as mbb_rsrq_db in 5G SA mode.", labels, nil), + SnrNr: nd(ns+"mbb_nr_snr_db", "5G NR signal-to-noise ratio in dB. Same as mbb_snr_db in 5G SA mode.", labels, nil), + SignalBars: nd(ns+"mbb_signal_bars", + "Cellular signal strength in bars, as shown by the controller.", labels, nil), + SignalPercent: nd(ns+"mbb_signal_percent", + "Cellular signal strength percentage (0-100), as computed by the controller.", labels, nil), + Coverage: nd(ns+"mbb_coverage", "Modem has cellular coverage (1) or not (0).", labels, nil), + Roaming: nd(ns+"mbb_roaming", "Modem is roaming (1) or on its home network (0).", labels, nil), + RegistrationState: nd(ns+"mbb_registration_state", + "Cellular network registration state code. Undocumented; likely 0 not registered, 1 registered home, "+ + "2 searching, 3 denied, 4 unknown, 5 registered roaming.", labels, nil), + Carriers: nd(ns+"mbb_carriers", "Aggregated component carriers in use.", append(labels, "carrier_type"), nil), + BandwidthMhz: nd(ns+"mbb_bandwidth_mhz", "Total aggregated carrier bandwidth in MHz.", + append(labels, "direction"), nil), + MaxBitrate: nd(ns+"mbb_max_bitrate_bps", "Maximum bitrate negotiated with the network, in bits per second.", + append(labels, "carrier_type", "direction"), nil), Info: nd(ns+"mbb_info", "Cellular modem state. Always 1; state and network details are labels.", - append(labels, "mbb_state", "mbb_mode", "rat", "band", "operator", "sa_mode", "current_slot"), nil), - SimActive: nd(ns+"mbb_sim_active", "SIM slot is the active data SIM (1) or not (0).", labelS, nil), - SimRxBytes: nd(ns+"mbb_sim_receive_bytes_total", "Bytes received over this SIM.", labelS, nil), - SimTxBytes: nd(ns+"mbb_sim_transmit_bytes_total", "Bytes transmitted over this SIM.", labelS, nil), - SimStateSeconds: nd(ns+"mbb_sim_state_seconds", "Seconds the SIM has been in its current display state.", labelS, nil), - SimRejectAge: nd(ns+"mbb_sim_network_reject_age", - "Age of the last carrier network reject, as reported by the controller.", labelS, nil), + append(labels, "mbb_state", "failover_mode", "rat", "band", "operator", "sa_mode", "current_slot", + "primary_slot", "mcc", "mnc", "cell_id", "pci", "channel"), nil), + SimActive: nd(ns+"mbb_sim_active", "SIM slot is the active data SIM (1) or not (0).", labelS, nil), + SimCardPresent: nd(ns+"mbb_sim_card_present", "SIM card or eSIM profile is present (1) or not (0).", labelS, nil), + SimPinBlocked: nd(ns+"mbb_sim_pin_blocked", "SIM is PIN blocked (1) or not (0).", labelS, nil), + SimDataWarning: nd(ns+"mbb_sim_data_warning", "SIM has passed its data usage warning threshold (1) or not (0).", + labelS, nil), + SimDataLimited: nd(ns+"mbb_sim_data_limited", "SIM has reached its data limit (1) or not (0).", labelS, nil), + SimRxBytes: nd(ns+"mbb_sim_receive_bytes_total", + "Bytes received over this SIM. Cumulative; not reset when the modem reboots.", labelS, nil), + SimTxBytes: nd(ns+"mbb_sim_transmit_bytes_total", + "Bytes transmitted over this SIM. Cumulative; not reset when the modem reboots.", labelS, nil), + SimStateSeconds: nd(ns+"mbb_sim_state_seconds", "Seconds the SIM has been in its current display state.", + labelS, nil), SimInfo: nd(ns+"mbb_sim_info", "SIM state. Always 1; display state and last network reject are labels.", - append(labelS, "display_state", "network_reject_type", "network_reject_text"), nil), + append(labelS, "display_state", "has_carrier", "metered", "network_reject_type", "network_reject_text"), nil), } } @@ -74,7 +100,7 @@ func (u *promUnifi) exportUMBB(r report, d *unifi.UMBB) { u.exportSYSstats(r, labels, d.SysStats, d.SystemStats) u.exportDeviceUplink(r, labels, d.Uplink) - u.exportMBB(r, labels, d.Mbb) + u.exportMBB(r, labels, d) r.send([]*metric{ {u.Device.Info, gauge, 1.0, append(append([]string{}, baseLabels...), infoLabels...)}, @@ -84,19 +110,17 @@ func (u *promUnifi) exportUMBB(r report, d *unifi.UMBB) { }) } -func (u *promUnifi) exportMBB(r report, labels []string, m unifi.MBB) { +func (u *promUnifi) exportMBB(r report, labels []string, d *unifi.UMBB) { + m := d.Mbb rad := m.Radio dl, ul := mbbBandwidth(rad) info := append(append([]string{}, labels...), m.State, m.Mode, rad.Rat, rad.Band, rad.NetworkOperator, - strconv.FormatBool(rad.SA5GMode.Val), rad.CurrentSlot.Txt) + strconv.FormatBool(rad.SA5GMode.Val), rad.CurrentSlot.Txt, d.MbbOverrides.PrimarySlot.Txt, + rad.Mcc.Txt, rad.Mnc.Txt, rad.CellID.Txt, rad.Pci.Txt, rad.Channel.Txt) r.send([]*metric{ - {u.UMBB.Rsrp, gauge, rad.Rsrp, labels}, - {u.UMBB.Rsrq, gauge, rad.Rsrq, labels}, - {u.UMBB.Snr, gauge, rad.Snr, labels}, - {u.UMBB.SignalBars, gauge, rad.Signal, labels}, - {u.UMBB.SignalPercent, gauge, rad.SignalPercent, labels}, + {u.UMBB.Internet, gauge, d.Internet.Val, labels}, {u.UMBB.Coverage, gauge, rad.HasCoverage.Val, labels}, {u.UMBB.Roaming, gauge, rad.Roaming.Val, labels}, {u.UMBB.RegistrationState, gauge, rad.RegistrationState, labels}, @@ -107,18 +131,47 @@ func (u *promUnifi) exportMBB(r report, labels []string, m unifi.MBB) { {u.UMBB.Info, gauge, 1.0, info}, }) + // Signal readings and bitrates are left out when the modem does not report them, + // so a missing reading is not mistaken for 0 dBm. + for _, s := range []struct { + desc *prometheus.Desc + val unifi.FlexInt + dims []string + }{ + {u.UMBB.Rsrp, rad.Rsrp, nil}, + {u.UMBB.Rsrq, rad.Rsrq, nil}, + {u.UMBB.Snr, rad.Snr, nil}, + {u.UMBB.RsrpNr, rad.RsrpNr, nil}, + {u.UMBB.RsrqNr, rad.RsrqNr, nil}, + {u.UMBB.SnrNr, rad.SnrNr, nil}, + {u.UMBB.SignalBars, rad.Signal, nil}, + {u.UMBB.SignalPercent, rad.SignalPercent, nil}, + {u.UMBB.MaxBitrate, rad.MaxBitrateDl, []string{"lte", "dl"}}, + {u.UMBB.MaxBitrate, rad.MaxBitrateUl, []string{"lte", "ul"}}, + {u.UMBB.MaxBitrate, rad.MaxBitrateDlNr, []string{"nr", "dl"}}, + {u.UMBB.MaxBitrate, rad.MaxBitrateUlNr, []string{"nr", "ul"}}, + } { + if s.val.Txt != "" { + r.send([]*metric{{s.desc, gauge, s.val.Val, append(append([]string{}, labels...), s.dims...)}}) + } + } + for _, sim := range m.Sim { simLabels := append(append([]string{}, labels...), sim.Slot.Txt, strconv.FormatBool(sim.Esim.Val), sim.Spn) simInfo := append(append([]string{}, simLabels...), - sim.DisplayState, sim.NetworkRejectType, sim.NetworkRejectText) + sim.DisplayState, strconv.FormatBool(sim.HasCarrier.Val), strconv.FormatBool(sim.Metered.Val), + sim.NetworkRejectType, sim.NetworkRejectText) r.send([]*metric{ {u.UMBB.SimActive, gauge, sim.Active.Val, simLabels}, + {u.UMBB.SimCardPresent, gauge, sim.CardPresent.Val, simLabels}, + {u.UMBB.SimPinBlocked, gauge, sim.PinBlocked.Val, simLabels}, + {u.UMBB.SimDataWarning, gauge, sim.DataWarning.Val, simLabels}, + {u.UMBB.SimDataLimited, gauge, sim.DataLimited.Val, simLabels}, {u.UMBB.SimRxBytes, counter, sim.RxBytes, simLabels}, {u.UMBB.SimTxBytes, counter, sim.TxBytes, simLabels}, {u.UMBB.SimStateSeconds, gauge, sim.DisplayStateElapsed, simLabels}, - {u.UMBB.SimRejectAge, gauge, sim.NetworkRejectAge, simLabels}, {u.UMBB.SimInfo, gauge, 1.0, simInfo}, }) } diff --git a/pkg/promunifi/umbb_test.go b/pkg/promunifi/umbb_test.go index bce0a05f..889f8916 100644 --- a/pkg/promunifi/umbb_test.go +++ b/pkg/promunifi/umbb_test.go @@ -20,13 +20,16 @@ const u5gMaxJSON = `{ "mac": "00:00:5e:00:53:02", "version": "7.5.3.19533", "uptime": 5264387, + "internet": true, + "mbb_overrides": {"primary_slot": 1}, "mbb": { "mode": "failover", "state": "ready", "sim": [ - {"active": false, "slot": 1, "esim": false, "display_state": "no-sim", "display_state_elapsed": 5264323}, + {"active": false, "slot": 1, "esim": false, "card_present": false, "display_state": "no-sim", "display_state_elapsed": 5264323}, { - "active": true, "slot": 2, "esim": true, "spn": "EE", + "active": true, "slot": 2, "esim": true, "spn": "EE", "card_present": true, "has_carrier": true, + "metered": false, "pin_blocked": false, "data_warning": true, "data_limited": false, "display_state": "operational", "display_state_elapsed": 5230, "network_reject_type": "wds", "network_reject_text": "Carrier rejected data", "network_reject_age": 45, "rxbytes": "3013150278176", "txbytes": "725650860811" @@ -35,6 +38,9 @@ const u5gMaxJSON = `{ "radio": { "5g_sa_mode": true, "band": "n78", "rat": "5G", "networkoperator": "EE", "current_slot": 2, "has_coverage": true, "roaming": false, "registration_state": 1, + "mcc": 234, "mnc": 30, "cell_id": 2719245, "pci": 216, "channel": 646080, + "max_bitrate_dl": 0, "max_bitrate_ul": 0, "max_bitrate_dl_nr": 640000000, "max_bitrate_ul_nr": 224000000, + "rsrp_nr": -96, "rsrq_nr": -13, "snr_nr": 17.600000381469727, "rsrp": -96, "rsrq": -13, "snr": 17.600000381469727, "signal": 3, "signal_percent": 95, "ca_lte": [], "ca_nr": [ @@ -74,21 +80,60 @@ func TestExportUMBB(t *testing.T) { a.InDelta(80, findMetric(t, r.sent, "unifi_mbb_bandwidth_mhz", append(labels, "dl")).value, 0.001) a.InDelta(40, findMetric(t, r.sent, "unifi_mbb_bandwidth_mhz", append(labels, "ul")).value, 0.001, "a null uplink bandwidth adds nothing") + a.InDelta(-96, findMetric(t, r.sent, "unifi_mbb_nr_rsrp_dbm", labels).value, 0.001) + a.InDelta(-13, findMetric(t, r.sent, "unifi_mbb_nr_rsrq_db", labels).value, 0.001) + a.InDelta(17.6, findMetric(t, r.sent, "unifi_mbb_nr_snr_db", labels).value, 0.001) + a.InDelta(1, findMetric(t, r.sent, "unifi_mbb_internet", labels).value, 0.001) + a.InDelta(640000000, findMetric(t, r.sent, "unifi_mbb_max_bitrate_bps", append(labels, "nr", "dl")).value, 1) + a.InDelta(224000000, findMetric(t, r.sent, "unifi_mbb_max_bitrate_bps", append(labels, "nr", "ul")).value, 1) + a.InDelta(0, findMetric(t, r.sent, "unifi_mbb_max_bitrate_bps", append(labels, "lte", "dl")).value, 1) a.InDelta(1, findMetric(t, r.sent, "unifi_mbb_info", - append(labels, "ready", "failover", "5G", "n78", "EE", "true", "2")).value, 0.001) + append(labels, "ready", "failover", "5G", "n78", "EE", "true", "2", "1", + "234", "30", "2719245", "216", "646080")).value, 0.001) noSim := append(labels, "1", "false", "") a.InDelta(0, findMetric(t, r.sent, "unifi_mbb_sim_active", noSim).value, 0.001) - a.InDelta(1, findMetric(t, r.sent, "unifi_mbb_sim_info", append(noSim, "no-sim", "", "")).value, 0.001) + a.InDelta(0, findMetric(t, r.sent, "unifi_mbb_sim_card_present", noSim).value, 0.001) + a.InDelta(1, findMetric(t, r.sent, "unifi_mbb_sim_info", + append(noSim, "no-sim", "false", "false", "", "")).value, 0.001) esim := append(labels, "2", "true", "EE") a.InDelta(1, findMetric(t, r.sent, "unifi_mbb_sim_active", esim).value, 0.001) a.InDelta(3013150278176, findMetric(t, r.sent, "unifi_mbb_sim_receive_bytes_total", esim).value, 1) a.InDelta(725650860811, findMetric(t, r.sent, "unifi_mbb_sim_transmit_bytes_total", esim).value, 1) a.InDelta(5230, findMetric(t, r.sent, "unifi_mbb_sim_state_seconds", esim).value, 0.001) - a.InDelta(45, findMetric(t, r.sent, "unifi_mbb_sim_network_reject_age", esim).value, 0.001) + a.InDelta(1, findMetric(t, r.sent, "unifi_mbb_sim_card_present", esim).value, 0.001) + a.InDelta(0, findMetric(t, r.sent, "unifi_mbb_sim_pin_blocked", esim).value, 0.001) + a.InDelta(1, findMetric(t, r.sent, "unifi_mbb_sim_data_warning", esim).value, 0.001) + a.InDelta(0, findMetric(t, r.sent, "unifi_mbb_sim_data_limited", esim).value, 0.001) a.InDelta(1, findMetric(t, r.sent, "unifi_mbb_sim_info", - append(esim, "operational", "wds", "Carrier rejected data")).value, 0.001) + append(esim, "operational", "true", "false", "wds", "Carrier rejected data")).value, 0.001) +} + +func TestExportUMBBMissingSignalIsSkipped(t *testing.T) { + t.Parallel() + + var d unifi.UMBB + + require.NoError(t, json.Unmarshal([]byte(`{"adopted": true, "type": "umbb", "name": "U5G Max", + "mbb": {"state": "searching", "radio": {"has_coverage": false}}}`), &d)) + + r := &fakeReport{} + u := testProm() + u.UMBB = descUMBB("unifi_") + u.exportUMBB(r, &d) + + for _, m := range r.sent { + name := prometheusDescName(t, m.Desc) + assert.NotContains(t, []string{ + "unifi_mbb_rsrp_dbm", "unifi_mbb_rsrq_db", "unifi_mbb_snr_db", + "unifi_mbb_nr_rsrp_dbm", "unifi_mbb_nr_rsrq_db", "unifi_mbb_nr_snr_db", + "unifi_mbb_signal_bars", "unifi_mbb_signal_percent", "unifi_mbb_max_bitrate_bps", + }, name, "a reading the modem did not report must not be exported as 0") + } + + labels := []string{"umbb", "", "U5G Max", "", ""} + assert.InDelta(t, 0, findMetric(t, r.sent, "unifi_mbb_coverage", labels).value, 0.001) } func TestExportUMBBUnadoptedIsSkipped(t *testing.T) {