Merge pull request #1101 from unpoller/feat/lte-failover-status

feat: export live U-LTE failover status
This commit is contained in:
Cody Lee
2026-09-29 17:17:46 -04:00
committed by GitHub
7 changed files with 379 additions and 0 deletions
@@ -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
+44
View File
@@ -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 {
@@ -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
+45
View File
@@ -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
+48
View File
@@ -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)
}
+152
View File
@@ -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
}
}
+59
View File
@@ -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},
})
}