From 281975389d3515150baf180393820b37d88bf925 Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Tue, 29 Sep 2026 16:10:39 -0500 Subject: [PATCH] feat: export live U-LTE failover status U-LTE modules are polled as access points, but their modem state never reached the metric outputs, so dashboards could only see a rolling WAN uptime percentage. Co-authored-by: Cursor --- .../integration_test_expectations.yaml | 7 + pkg/datadogunifi/uap.go | 44 +++++ .../integration_test_expectations.yaml | 24 +++ pkg/influxunifi/uap.go | 45 ++++++ pkg/otelunifi/uap.go | 48 ++++++ pkg/promunifi/lte_test.go | 152 ++++++++++++++++++ pkg/promunifi/uap.go | 59 +++++++ 7 files changed, 379 insertions(+) create mode 100644 pkg/promunifi/lte_test.go diff --git a/pkg/datadogunifi/integration_test_expectations.yaml b/pkg/datadogunifi/integration_test_expectations.yaml index d50d65fa..1a1aecc1 100644 --- a/pkg/datadogunifi/integration_test_expectations.yaml +++ b/pkg/datadogunifi/integration_test_expectations.yaml @@ -174,6 +174,13 @@ gauges: - unifi.uap.uplink_up - unifi.uap.uptime - unifi.uap.user_num_sta + - unifi.uap_lte.connected + - unifi.uap_lte.failover + - unifi.uap_lte.rsrp + - unifi.uap_lte.rsrq + - unifi.uap_lte.rssi + - unifi.uap_lte.rx_chan + - unifi.uap_lte.tx_chan - unifi.uap_radios.ast_be_xmit - unifi.uap_radios.channel - unifi.uap_radios.cu_self_rx diff --git a/pkg/datadogunifi/uap.go b/pkg/datadogunifi/uap.go index 22859920..8fb5bc44 100644 --- a/pkg/datadogunifi/uap.go +++ b/pkg/datadogunifi/uap.go @@ -84,6 +84,7 @@ func (u *DatadogUnifi) batchUAP(r report, s *unifi.UAP) { metricName := metricNamespace("uap") reportGaugeForFloat64Map(r, metricName, data, tags) + u.batchLTE(r, s) u.processVAPTable(r, tags, s.VapTable) u.batchPortTable(r, tags, s.PortTable) @@ -131,6 +132,49 @@ func (u *DatadogUnifi) processUAPstats(ap *unifi.Ap) map[string]float64 { } } +// batchLTE writes live cellular backup status for U-LTE and U-LTE-Pro. +// Those modules are reported as access points; wired WAN ports do not carry this data. +func (u *DatadogUnifi) batchLTE(r report, s *unifi.UAP) { + if !hasLTEStatus(s) { + return + } + + tags := cleanTags(map[string]string{ + "mac": s.Mac, + "site_name": s.SiteName, + "source": s.SourceName, + "name": s.Name, + "model": s.Model, + "type": s.Type, + "state": s.LteState.Txt, + "failover_mode": s.LteFailoverMode, + "signal": s.LteSignal, + "rat": s.LteRat, + "mode": s.LteMode, + "band": s.LteBand, + "pdp_type": s.LtePdpType, + "operator": s.LteNetworkOperator, + }) + data := map[string]float64{ + "connected": s.LteConnected.Float64(), + "failover": s.LteFailover.Float64(), + "rssi": s.LteRssi.Val, + "rsrp": s.LteRsrp.Val, + "rsrq": s.LteRsrq.Val, + "rx_chan": s.LteRxChannel.Val, + "tx_chan": s.LteTxChannel.Val, + } + + reportGaugeForFloat64Map(r, metricNamespace("uap_lte"), data, tags) +} + +// hasLTEStatus reports whether this access point is a cellular backup module. +// U-LTE and U-LTE-Pro are typed as UAPs; their live modem status arrives as lte_* fields. +func hasLTEStatus(d *unifi.UAP) bool { + return d.LteState.Txt != "" || d.LteFailoverMode != "" || d.LteRat != "" || + d.LteNetworkOperator != "" || d.LteSignal != "" || d.LteConnected.Txt != "" +} + // processVAPTable creates points for Wifi Radios. This works with several types of UAP-capable devices. func (u *DatadogUnifi) processVAPTable(r report, t map[string]string, vt unifi.VapTable) { // nolint: funlen for _, s := range vt { diff --git a/pkg/influxunifi/integration_test_expectations.yaml b/pkg/influxunifi/integration_test_expectations.yaml index a9b5039d..2c64cb30 100644 --- a/pkg/influxunifi/integration_test_expectations.yaml +++ b/pkg/influxunifi/integration_test_expectations.yaml @@ -297,6 +297,30 @@ points: uplink_up: bool uptime: float user-num_sta: int + uap_lte: + tags: + - band + - failover_mode + - mac + - mode + - model + - name + - operator + - pdp_type + - rat + - signal + - site_name + - source + - state + - type + fields: + connected: bool + failover: bool + rsrp: float + rsrq: float + rssi: float + rx_chan: float + tx_chan: float uap_radios: tags: - channel diff --git a/pkg/influxunifi/uap.go b/pkg/influxunifi/uap.go index 2933b319..d04eb098 100644 --- a/pkg/influxunifi/uap.go +++ b/pkg/influxunifi/uap.go @@ -78,6 +78,7 @@ func (u *InfluxUnifi) batchUAP(r report, s *unifi.UAP) { r.addCount(uapT) r.send(&metric{Table: "uap", Tags: tags, Fields: fields}) + u.batchLTE(r, s) u.processRadTable(r, tags, s.RadioTable, s.RadioTableStats) u.processVAPTable(r, tags, s.VapTable) u.batchPortTable(r, tags, s.PortTable) @@ -125,6 +126,50 @@ func (u *InfluxUnifi) processUAPstats(ap *unifi.Ap) map[string]any { } } +// batchLTE writes live cellular backup status for U-LTE and U-LTE-Pro. +// Those modules are reported as access points; wired WAN ports do not carry this data. +func (u *InfluxUnifi) batchLTE(r report, s *unifi.UAP) { + if !hasLTEStatus(s) { + return + } + + r.send(&metric{ + Table: "uap_lte", + Tags: map[string]string{ + "mac": s.Mac, + "site_name": s.SiteName, + "source": s.SourceName, + "name": s.Name, + "model": s.Model, + "type": s.Type, + "state": s.LteState.Txt, + "failover_mode": s.LteFailoverMode, + "signal": s.LteSignal, + "rat": s.LteRat, + "mode": s.LteMode, + "band": s.LteBand, + "pdp_type": s.LtePdpType, + "operator": s.LteNetworkOperator, + }, + Fields: map[string]any{ + "connected": s.LteConnected.Val, + "failover": s.LteFailover.Val, + "rssi": s.LteRssi.Val, + "rsrp": s.LteRsrp.Val, + "rsrq": s.LteRsrq.Val, + "rx_chan": s.LteRxChannel.Val, + "tx_chan": s.LteTxChannel.Val, + }, + }) +} + +// hasLTEStatus reports whether this access point is a cellular backup module. +// U-LTE and U-LTE-Pro are typed as UAPs; their live modem status arrives as lte_* fields. +func hasLTEStatus(d *unifi.UAP) bool { + return d.LteState.Txt != "" || d.LteFailoverMode != "" || d.LteRat != "" || + d.LteNetworkOperator != "" || d.LteSignal != "" || d.LteConnected.Txt != "" +} + // radioBand maps a UniFi radio identifier to its frequency band in GHz. // UniFi reports the radio as ng (2.4 GHz), na (5 GHz) or 6e (6 GHz); the band // is not carried as its own field on the radio/VAP structs, so it is derived diff --git a/pkg/otelunifi/uap.go b/pkg/otelunifi/uap.go index 34dc7236..17567f42 100644 --- a/pkg/otelunifi/uap.go +++ b/pkg/otelunifi/uap.go @@ -47,6 +47,8 @@ func (u *OtelOutput) exportUAP(ctx context.Context, meter metric.Meter, r *Repor u.recordGauge(ctx, meter, r, "unifi_device_uap_up", "Whether UAP is up (1) or down (0)", up, attrs) + u.exportLTE(ctx, meter, r, s) + for _, radio := range s.RadioTable { radioAttrs := attribute.NewSet( attribute.String("mac", s.Mac), @@ -85,3 +87,49 @@ func (u *OtelOutput) exportUAP(ctx context.Context, meter metric.Meter, r *Repor "UAP VAP transmit bytes total", vap.TxBytes.Val, vapAttrs) } } + +// hasLTEStatus reports whether this access point is a cellular backup module. +// U-LTE and U-LTE-Pro are typed as UAPs; their live modem status arrives as lte_* fields. +func hasLTEStatus(d *unifi.UAP) bool { + return d.LteState.Txt != "" || d.LteFailoverMode != "" || d.LteRat != "" || + d.LteNetworkOperator != "" || d.LteSignal != "" || d.LteConnected.Txt != "" +} + +// exportLTE emits live cellular backup status for a U-LTE or U-LTE-Pro module. +func (u *OtelOutput) exportLTE(ctx context.Context, meter metric.Meter, r *Report, s *unifi.UAP) { + if !hasLTEStatus(s) { + return + } + + 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("type", s.Type), + attribute.String("lte_state", s.LteState.Txt), + attribute.String("lte_failover_mode", s.LteFailoverMode), + attribute.String("lte_signal", s.LteSignal), + attribute.String("lte_rat", s.LteRat), + attribute.String("lte_mode", s.LteMode), + attribute.String("lte_band", s.LteBand), + attribute.String("lte_pdp_type", s.LtePdpType), + attribute.String("lte_operator", s.LteNetworkOperator), + ) + + u.recordGauge(ctx, meter, r, "unifi_device_uap_lte_connected", + "LTE modem connected to the carrier (1) or not (0)", s.LteConnected.Float64(), attrs) + u.recordGauge(ctx, meter, r, "unifi_device_uap_lte_failover", + "LTE failover carrying traffic (1) or standing by (0)", s.LteFailover.Float64(), attrs) + u.recordGauge(ctx, meter, r, "unifi_device_uap_lte_rssi_dbm", + "LTE RSSI in dBm", s.LteRssi.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_uap_lte_rsrp_dbm", + "LTE RSRP in dBm", s.LteRsrp.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_uap_lte_rsrq_db", + "LTE RSRQ in dB", s.LteRsrq.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_uap_lte_rx_channel", + "LTE receive channel", s.LteRxChannel.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_uap_lte_tx_channel", + "LTE transmit channel", s.LteTxChannel.Val, attrs) +} diff --git a/pkg/promunifi/lte_test.go b/pkg/promunifi/lte_test.go new file mode 100644 index 00000000..43ad7f33 --- /dev/null +++ b/pkg/promunifi/lte_test.go @@ -0,0 +1,152 @@ +//nolint:testpackage // white-box: exercises the unexported descriptors and export path. +package promunifi + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/unpoller/unifi/v6" +) + +// Captured from a U-LTE-Pro (model ULTEPEU) while the module was in +// "LTE Failover Ready" / SIM standby. IMEI, ICCID, and cell identity are omitted. +const ulteStandbyJSON = `{ + "adopted": true, + "type": "uap", + "model": "ULTEPEU", + "name": "U-LTE-Pro", + "mac": "aa:bb:cc:dd:ee:ff", + "lte_state": "ready", + "lte_failover": false, + "lte_failover_mode": "failover", + "lte_connected": "yes", + "lte_networkoperator": "o2 - de", + "lte_signal": "Good signal strength (3)", + "lte_rssi": "-58", + "lte_rsrp": "-95", + "lte_rsrq": "-16", + "lte_rat": "LTE", + "lte_mode": "LTE", + "lte_band": "eutran-3", + "lte_rx_chan": "1600", + "lte_tx_chan": "19600", + "lte_pdptype": "IPV4" +}` + +func testProm() *promUnifi { + return &promUnifi{ + Device: descDevice("unifi_"), + UAP: descUAP("unifi_"), + USG: descUSG("unifi_"), + } +} + +func unmarshalULTE(t *testing.T, payload string) *unifi.UAP { + t.Helper() + + var d unifi.UAP + + require.NoError(t, json.Unmarshal([]byte(payload), &d)) + + d.SiteName = "Default" + d.SourceName = "https://udm.example" + + return &d +} + +func TestExportULTEStandbyStatus(t *testing.T) { + t.Parallel() + + d := unmarshalULTE(t, ulteStandbyJSON) + r := &fakeReport{} + testProm().exportUAP(r, d) + + got := metricsByName(t, r.sent) + a := assert.New(t) + + a.Equal(1.0, got["unifi_lte_connected"].value) + a.Equal(0.0, got["unifi_lte_failover"].value) + a.InDelta(-58, got["unifi_lte_rssi_dbm"].value, 0.001) + a.InDelta(-95, got["unifi_lte_rsrp_dbm"].value, 0.001) + a.InDelta(-16, got["unifi_lte_rsrq_db"].value, 0.001) + a.InDelta(1600, got["unifi_lte_rx_channel"].value, 0.001) + a.InDelta(19600, got["unifi_lte_tx_channel"].value, 0.001) + a.Equal(1.0, got["unifi_lte_info"].value) + a.Equal([]string{ + "uap", "Default", "U-LTE-Pro", "https://udm.example", "", + "ready", "failover", "Good signal strength (3)", "LTE", "LTE", "eutran-3", "IPV4", "o2 - de", + }, got["unifi_lte_info"].labels) +} + +func TestExportULTEFailoverActive(t *testing.T) { + t.Parallel() + + d := unmarshalULTE(t, ulteStandbyJSON) + require.NoError(t, json.Unmarshal([]byte(`true`), &d.LteFailover)) + + r := &fakeReport{} + testProm().exportUAP(r, d) + + got := metricsByName(t, r.sent) + assert.Equal(t, 1.0, got["unifi_lte_failover"].value) + assert.Equal(t, 1.0, got["unifi_lte_connected"].value) +} + +func TestExportUAPWithoutLTEOmitsModemMetrics(t *testing.T) { + t.Parallel() + + d := &unifi.UAP{ + Adopted: unifi.FlexBool{Val: true, Txt: "true"}, + Type: "uap", + Name: "Hallway", + SiteName: "Default", + SourceName: "https://udm.example", + } + r := &fakeReport{} + testProm().exportUAP(r, d) + + for name := range metricsByName(t, r.sent) { + assert.NotContains(t, name, "lte", "a wireless AP must not emit modem metrics") + } +} + +type recordedMetric struct { + value float64 + labels []string +} + +func metricsByName(t *testing.T, sent []*metric) map[string]recordedMetric { + t.Helper() + + out := make(map[string]recordedMetric, len(sent)) + + for _, m := range sent { + name := prometheusDescName(t, m.Desc) + out[name] = recordedMetric{value: metricFloat(t, m), labels: m.Labels} + } + + return out +} + +func metricFloat(t *testing.T, m *metric) float64 { + t.Helper() + + switch v := m.Value.(type) { + case float64: + return v + case unifi.FlexInt: + return v.Val + case bool: + if v { + return 1 + } + + return 0 + default: + t.Fatalf("metric value is %T", m.Value) + + return 0 + } +} diff --git a/pkg/promunifi/uap.go b/pkg/promunifi/uap.go index eba7d5b9..a6c121d7 100644 --- a/pkg/promunifi/uap.go +++ b/pkg/promunifi/uap.go @@ -78,6 +78,15 @@ type uap struct { RadioNumSta *prometheus.Desc RadioTxPackets *prometheus.Desc RadioTxRetries *prometheus.Desc + // LTE backup modem (U-LTE / U-LTE-Pro). These devices are typed as UAPs. + LteConnected *prometheus.Desc + LteFailover *prometheus.Desc + LteRssi *prometheus.Desc + LteRsrp *prometheus.Desc + LteRsrq *prometheus.Desc + LteRxChannel *prometheus.Desc + LteTxChannel *prometheus.Desc + LteInfo *prometheus.Desc } type rogueap struct { @@ -114,6 +123,11 @@ func descUAP(ns string) *uap { // nolint: funlen labelA := []string{"stat", "site_name", "name", "source", "tag"} // stat + labels[1:] labelV := []string{"vap_name", "bssid", "radio", "band", "radio_name", "essid", "usage", "mac", "site_name", "name", "source", "tag"} labelR := []string{"radio_name", "radio", "band", "mac", "site_name", "name", "source", "tag"} + labelL := []string{"type", "site_name", "name", "source", "tag"} + labelLI := []string{ + "type", "site_name", "name", "source", "tag", + "lte_state", "failover_mode", "signal", "rat", "mode", "band", "pdp_type", "operator", + } nd := prometheus.NewDesc return &uap{ @@ -189,6 +203,14 @@ func descUAP(ns string) *uap { // nolint: funlen RadioNumSta: nd(ns+"radio_stations", "Radio Total Station Count", append(labelR, "station_type"), nil), RadioTxPackets: nd(ns+"radio_transmit_packets", "Radio Transmitted Packets", labelR, nil), RadioTxRetries: nd(ns+"radio_transmit_retries", "Radio Transmit Retries", labelR, nil), + LteConnected: nd(ns+"lte_connected", "LTE modem connected to the carrier (1) or not (0). U-LTE devices only.", labelL, nil), + LteFailover: nd(ns+"lte_failover", "LTE failover is carrying traffic (1) or standing by (0). U-LTE devices only.", labelL, nil), + LteRssi: nd(ns+"lte_rssi_dbm", "LTE RSSI in dBm. U-LTE devices only.", labelL, nil), + LteRsrp: nd(ns+"lte_rsrp_dbm", "LTE RSRP in dBm. U-LTE devices only.", labelL, nil), + LteRsrq: nd(ns+"lte_rsrq_db", "LTE RSRQ in dB. U-LTE devices only.", labelL, nil), + LteRxChannel: nd(ns+"lte_rx_channel", "LTE receive channel. U-LTE devices only.", labelL, nil), + LteTxChannel: nd(ns+"lte_tx_channel", "LTE transmit channel. U-LTE devices only.", labelL, nil), + LteInfo: nd(ns+"lte_info", "LTE modem state. Always 1; carrier and signal details are labels. U-LTE devices only.", labelLI, nil), } } @@ -230,6 +252,7 @@ func (u *promUnifi) exportUAP(r report, d *unifi.UAP) { u.exportSYSstats(r, labels, d.SysStats, d.SystemStats) u.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta) u.exportRADtable(r, labels, d.RadioTable, d.RadioTableStats, d.Mac) + u.exportLTE(r, d, labels) // UAP uplink metrics. The uplink "type" (e.g. "wire" / "wireless") // is encoded as the first label, matching the convention used by // USG/USW/UBB/UDB so dashboards can group by port. @@ -401,3 +424,39 @@ func (u *promUnifi) exportRADtable(r report, labels []string, rt unifi.RadioTabl } } } + +// hasLTEStatus reports whether this access point is a cellular backup module. +// U-LTE and U-LTE-Pro are typed as UAPs; their live modem status arrives as lte_* fields +// on the device list, not on the gateway WAN ports. +func hasLTEStatus(d *unifi.UAP) bool { + return d.LteState.Txt != "" || d.LteFailoverMode != "" || d.LteRat != "" || + d.LteNetworkOperator != "" || d.LteSignal != "" || d.LteConnected.Txt != "" +} + +func (u *promUnifi) exportLTE(r report, d *unifi.UAP, labels []string) { + if !hasLTEStatus(d) { + return + } + + info := append(append([]string{}, labels...), + d.LteState.Txt, + d.LteFailoverMode, + d.LteSignal, + d.LteRat, + d.LteMode, + d.LteBand, + d.LtePdpType, + d.LteNetworkOperator, + ) + + r.send([]*metric{ + {u.UAP.LteConnected, gauge, d.LteConnected.Val, labels}, + {u.UAP.LteFailover, gauge, d.LteFailover.Val, labels}, + {u.UAP.LteRssi, gauge, d.LteRssi, labels}, + {u.UAP.LteRsrp, gauge, d.LteRsrp, labels}, + {u.UAP.LteRsrq, gauge, d.LteRsrq, labels}, + {u.UAP.LteRxChannel, gauge, d.LteRxChannel, labels}, + {u.UAP.LteTxChannel, gauge, d.LteTxChannel, labels}, + {u.UAP.LteInfo, gauge, 1.0, info}, + }) +}