diff --git a/go.mod b/go.mod index f478aebe..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 diff --git a/go.sum b/go.sum index 8a67f019..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/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/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= 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..10f5e068 100644 --- a/pkg/datadogunifi/integration_test_expectations.yaml +++ b/pkg/datadogunifi/integration_test_expectations.yaml @@ -566,6 +566,58 @@ gauges: - unifi.udb.uptime - 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.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 + - unifi.umbb.mem_used + - 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 + - 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..49c86817 --- /dev/null +++ b/pkg/datadogunifi/umbb.go @@ -0,0 +1,150 @@ +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, + "failover_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, + "primary_slot": s.MbbOverrides.PrimarySlot.Val, + "internet": s.Internet.Float64(), + "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, + }, + mbbOptional(rad)) + + 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, + "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, + "rx_bytes": sim.RxBytes.Val, + "tx_bytes": sim.TxBytes.Val, + } + + reportGaugeForFloat64Map(r, metricNamespace("umbb_sim"), simData, simTags) + } +} + +// 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. +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..0b713e20 100644 --- a/pkg/influxunifi/integration_test_expectations.yaml +++ b/pkg/influxunifi/integration_test_expectations.yaml @@ -671,6 +671,98 @@ 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 + 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 + 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 + 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 + card_present: bool + data_limited: bool + data_warning: bool + display_state: string + display_state_elapsed: 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: tags: - action diff --git a/pkg/influxunifi/umbb.go b/pkg/influxunifi/umbb.go new file mode 100644 index 00000000..408fc341 --- /dev/null +++ b/pkg/influxunifi/umbb.go @@ -0,0 +1,159 @@ +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, + "internet": s.Internet.Val, + "mbb_state": s.Mbb.State, + "failover_mode": s.Mbb.Mode, + "rat": rad.Rat, + "band": rad.Band, + "operator": rad.NetworkOperator, + "sa_mode": rad.SA5GMode.Val, + "current_slot": rad.CurrentSlot.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, + "nr_carriers": float64(len(rad.CaNr)), + "lte_carriers": float64(len(rad.CaLte)), + "dl_bandwidth_mhz": dl, + "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}) + + 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, + "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, + "rx_bytes": sim.RxBytes.Val, + "tx_bytes": sim.TxBytes.Val, + }, + }) + } +} + +// 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. +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..30193651 --- /dev/null +++ b/pkg/otelunifi/umbb.go @@ -0,0 +1,154 @@ +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("failover_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_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 (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", + "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.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), + ) + + 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_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. 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. Cumulative; not reset when the modem reboots", 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..9485d4ac 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() @@ -51,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)) @@ -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..a847e336 --- /dev/null +++ b/pkg/promunifi/umbb.go @@ -0,0 +1,203 @@ +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 { + 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 + Roaming *prometheus.Desc + 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 + 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{ + 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", "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", "has_carrier", "metered", "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) + + 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, 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, d.MbbOverrides.PrimarySlot.Txt, + rad.Mcc.Txt, rad.Mnc.Txt, rad.CellID.Txt, rad.Pci.Txt, rad.Channel.Txt) + + r.send([]*metric{ + {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}, + {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}, + }) + + // 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, 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.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..889f8916 --- /dev/null +++ b/pkg/promunifi/umbb_test.go @@ -0,0 +1,163 @@ +//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, + "internet": true, + "mbb_overrides": {"primary_slot": 1}, + "mbb": { + "mode": "failover", + "state": "ready", + "sim": [ + {"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", "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" + } + ], + "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": [ + {"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(-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", "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(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(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", "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) { + 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{} +}