From 912ea91ac0b3fd76161abc39c93013a5f775ed7d Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Tue, 29 Sep 2026 09:46:13 -0500 Subject: [PATCH 1/2] feat(protect): export air quality readings when present Prefer a known stats channel and fall back to airQuality for temperature and humidity. Omit channels whose status is unknown, and add the particulate and gas series only when the controller includes them. Co-authored-by: Cursor --- pkg/datadogunifi/protect.go | 47 ++++++++++++---- pkg/influxunifi/protect.go | 41 +++++++++++--- pkg/promunifi/protect.go | 89 ++++++++++++++++++++++++----- pkg/promunifi/protect_test.go | 102 ++++++++++++++++++++++++++++++++++ 4 files changed, 248 insertions(+), 31 deletions(-) diff --git a/pkg/datadogunifi/protect.go b/pkg/datadogunifi/protect.go index f1485de9..f2b00df3 100644 --- a/pkg/datadogunifi/protect.go +++ b/pkg/datadogunifi/protect.go @@ -83,18 +83,45 @@ func (u *DatadogUnifi) batchProtectSensor(r report, d *unifi.ProtectDevices, s * _ = r.reportGauge(metricName("battery_low"), boolToFloat64(s.BatteryStatus.IsLow.Val), tags) } - if s.Stats != nil { - if s.Stats.Temperature != nil { - _ = r.reportGauge(metricName("temperature"), s.Stats.Temperature.Value.Val, tags) + if temp, ok := s.TemperatureReading(); ok { + _ = r.reportGauge(metricName("temperature"), temp, tags) + } + + if humidity, ok := s.HumidityReading(); ok { + _ = r.reportGauge(metricName("humidity"), humidity, tags) + } + + if light, ok := s.LightReading(); ok { + _ = r.reportGauge(metricName("light"), light, tags) + } + + if s.AirQuality == nil { + return + } + + aq := s.AirQuality + channels := []struct { + name string + reading *unifi.ProtectSensorStatValue + }{ + {"aqi", aq.AQI}, + {"co2_ppm", aq.CO2}, + {"tvoc", aq.TVOC}, + {"voc", aq.VOC}, + {"vape", aq.Vape}, + {"pm1_0", aq.PM1p0}, + {"pm2_5", aq.PM2p5}, + {"pm4_0", aq.PM4p0}, + {"pm10", aq.PM10p0}, + } + + for _, ch := range channels { + value, ok := ch.reading.Reading() + if !ok { + continue } - if s.Stats.Humidity != nil { - _ = r.reportGauge(metricName("humidity"), s.Stats.Humidity.Value.Val, tags) - } - - if s.Stats.Light != nil { - _ = r.reportGauge(metricName("light"), s.Stats.Light.Value.Val, tags) - } + _ = r.reportGauge(metricName(ch.name), value, tags) } } diff --git a/pkg/influxunifi/protect.go b/pkg/influxunifi/protect.go index 20669b36..b05cd45b 100644 --- a/pkg/influxunifi/protect.go +++ b/pkg/influxunifi/protect.go @@ -48,17 +48,42 @@ func (u *InfluxUnifi) batchProtectDevices(r report, d *unifi.ProtectDevices) { fields["battery_low"] = s.BatteryStatus.IsLow.Val } - if s.Stats != nil { - if s.Stats.Temperature != nil { - fields["temperature"] = s.Stats.Temperature.Value.Val + if temp, ok := s.TemperatureReading(); ok { + fields["temperature"] = temp + } + + if humidity, ok := s.HumidityReading(); ok { + fields["humidity"] = humidity + } + + if light, ok := s.LightReading(); ok { + fields["light"] = light + } + + if s.AirQuality != nil { + aq := s.AirQuality + channels := []struct { + name string + reading *unifi.ProtectSensorStatValue + }{ + {"aqi", aq.AQI}, + {"co2_ppm", aq.CO2}, + {"tvoc", aq.TVOC}, + {"voc", aq.VOC}, + {"vape", aq.Vape}, + {"pm1_0", aq.PM1p0}, + {"pm2_5", aq.PM2p5}, + {"pm4_0", aq.PM4p0}, + {"pm10", aq.PM10p0}, } - if s.Stats.Humidity != nil { - fields["humidity"] = s.Stats.Humidity.Value.Val - } + for _, ch := range channels { + value, ok := ch.reading.Reading() + if !ok { + continue + } - if s.Stats.Light != nil { - fields["light"] = s.Stats.Light.Value.Val + fields[ch.name] = value } } diff --git a/pkg/promunifi/protect.go b/pkg/promunifi/protect.go index e665e3e4..826b0dbb 100644 --- a/pkg/promunifi/protect.go +++ b/pkg/promunifi/protect.go @@ -21,6 +21,15 @@ type protectDevices struct { SensorTemperature *prometheus.Desc SensorHumidity *prometheus.Desc SensorLight *prometheus.Desc + SensorAQI *prometheus.Desc + SensorCO2 *prometheus.Desc + SensorTVOC *prometheus.Desc + SensorVOC *prometheus.Desc + SensorVape *prometheus.Desc + SensorPM1 *prometheus.Desc + SensorPM2_5 *prometheus.Desc + SensorPM4 *prometheus.Desc + SensorPM10 *prometheus.Desc SensorIsOpened *prometheus.Desc SensorIsMotion *prometheus.Desc // Cameras. @@ -56,6 +65,24 @@ func descProtectDevices(ns string) *protectDevices { // nolint: funlen "Protect sensor humidity reading", device, nil), SensorLight: prometheus.NewDesc(ns+"sensor_light_lux", "Protect sensor light reading", device, nil), + SensorAQI: prometheus.NewDesc(ns+"sensor_aqi", + "Protect air quality index", device, nil), + SensorCO2: prometheus.NewDesc(ns+"sensor_co2_ppm", + "Protect air quality CO2 reading in ppm", device, nil), + SensorTVOC: prometheus.NewDesc(ns+"sensor_tvoc", + "Protect air quality total volatile organic compounds", device, nil), + SensorVOC: prometheus.NewDesc(ns+"sensor_voc", + "Protect air quality volatile organic compounds", device, nil), + SensorVape: prometheus.NewDesc(ns+"sensor_vape", + "Protect air quality vape detection reading", device, nil), + SensorPM1: prometheus.NewDesc(ns+"sensor_pm1_0", + "Protect air quality PM1.0 reading", device, nil), + SensorPM2_5: prometheus.NewDesc(ns+"sensor_pm2_5", + "Protect air quality PM2.5 reading", device, nil), + SensorPM4: prometheus.NewDesc(ns+"sensor_pm4_0", + "Protect air quality PM4.0 reading", device, nil), + SensorPM10: prometheus.NewDesc(ns+"sensor_pm10", + "Protect air quality PM10 reading", device, nil), SensorIsOpened: prometheus.NewDesc(ns+"sensor_is_opened", "Protect door/window sensor is opened (1) or closed (0)", device, nil), SensorIsMotion: prometheus.NewDesc(ns+"sensor_is_motion_detected", @@ -112,19 +139,7 @@ func (u *promUnifi) exportProtectDevices(r report, d *unifi.ProtectDevices) { r.send([]*metric{{u.ProtectDevices.SensorBatteryLow, gauge, isLow, labels}}) } - if s.Stats != nil { - if s.Stats.Temperature != nil { - r.send([]*metric{{u.ProtectDevices.SensorTemperature, gauge, s.Stats.Temperature.Value.Val, labels}}) - } - - if s.Stats.Humidity != nil { - r.send([]*metric{{u.ProtectDevices.SensorHumidity, gauge, s.Stats.Humidity.Value.Val, labels}}) - } - - if s.Stats.Light != nil { - r.send([]*metric{{u.ProtectDevices.SensorLight, gauge, s.Stats.Light.Value.Val, labels}}) - } - } + u.exportProtectSensorReadings(r, s, labels) isOpened := 0.0 if s.IsOpened.Val { @@ -222,6 +237,54 @@ func (u *promUnifi) exportProtectDevices(r report, d *unifi.ProtectDevices) { } } +// exportProtectSensorReadings emits environmental and air-quality gauges for one sensor. +// +// A channel whose status is "unknown" is omitted. Temperature and humidity prefer +// stats and fall back to airQuality, which is where a UP-AirQuality sensor reports +// them. The air-quality series are emitted only when that object is present. +func (u *promUnifi) exportProtectSensorReadings(r report, s *unifi.ProtectSensor, labels []string) { + if temp, ok := s.TemperatureReading(); ok { + r.send([]*metric{{u.ProtectDevices.SensorTemperature, gauge, temp, labels}}) + } + + if humidity, ok := s.HumidityReading(); ok { + r.send([]*metric{{u.ProtectDevices.SensorHumidity, gauge, humidity, labels}}) + } + + if light, ok := s.LightReading(); ok { + r.send([]*metric{{u.ProtectDevices.SensorLight, gauge, light, labels}}) + } + + if s.AirQuality == nil { + return + } + + aq := s.AirQuality + channels := []struct { + desc *prometheus.Desc + reading *unifi.ProtectSensorStatValue + }{ + {u.ProtectDevices.SensorAQI, aq.AQI}, + {u.ProtectDevices.SensorCO2, aq.CO2}, + {u.ProtectDevices.SensorTVOC, aq.TVOC}, + {u.ProtectDevices.SensorVOC, aq.VOC}, + {u.ProtectDevices.SensorVape, aq.Vape}, + {u.ProtectDevices.SensorPM1, aq.PM1p0}, + {u.ProtectDevices.SensorPM2_5, aq.PM2p5}, + {u.ProtectDevices.SensorPM4, aq.PM4p0}, + {u.ProtectDevices.SensorPM10, aq.PM10p0}, + } + + for _, ch := range channels { + value, ok := ch.reading.Reading() + if !ok { + continue + } + + r.send([]*metric{{ch.desc, gauge, value, labels}}) + } +} + // exportProtectIdentity emits the presence and state gauges shared by every Protect device kind // and returns the label set used for that device's other metrics. func (u *promUnifi) exportProtectIdentity(r report, sourceName string, id *unifi.ProtectDeviceIdentity) []string { diff --git a/pkg/promunifi/protect_test.go b/pkg/promunifi/protect_test.go index 5ab088e4..0cefa786 100644 --- a/pkg/promunifi/protect_test.go +++ b/pkg/promunifi/protect_test.go @@ -3,6 +3,7 @@ package promunifi import ( "reflect" + "strings" "testing" "github.com/prometheus/client_golang/prometheus" @@ -132,3 +133,104 @@ func TestExportProtectDevicesSensorTolerantOfNilBatteryAndStats(t *testing.T) { // identity(2) + is_opened/is_motion(2), no battery or stats series. assert.Len(t, r.sent, 4) } + +func TestExportProtectSensorAirQuality(t *testing.T) { + t.Parallel() + + u := &promUnifi{ProtectDevices: descProtectDevices("unifi_")} + + unknownOnly := &fakeReport{} + u.exportProtectDevices(unknownOnly, &unifi.ProtectDevices{ + SourceName: "https://protect.example", + Sensors: []*unifi.ProtectSensor{{ + ProtectDeviceIdentity: unifi.ProtectDeviceIdentity{Name: "AQ", Type: "UP-AirQuality", ModelKey: "sensor", State: "CONNECTED"}, + Stats: &unifi.ProtectSensorStats{ + Temperature: &unifi.ProtectSensorStatValue{Status: "unknown"}, + Humidity: &unifi.ProtectSensorStatValue{Status: "unknown"}, + Light: &unifi.ProtectSensorStatValue{Status: "unknown"}, + }, + }}, + }) + + unknown := readingsByName(t, unknownOnly.sent) + assert.NotContains(t, unknown, "unifi_protect_sensor_temperature_celsius") + assert.NotContains(t, unknown, "unifi_protect_sensor_humidity_percent") + assert.NotContains(t, unknown, "unifi_protect_sensor_light_lux") + assert.NotContains(t, unknown, "unifi_protect_sensor_aqi") + + withAir := &fakeReport{} + u.exportProtectDevices(withAir, &unifi.ProtectDevices{ + SourceName: "https://protect.example", + Sensors: []*unifi.ProtectSensor{{ + ProtectDeviceIdentity: unifi.ProtectDeviceIdentity{Name: "AQ", Type: "UP-AirQuality", ModelKey: "sensor", State: "CONNECTED"}, + Stats: &unifi.ProtectSensorStats{ + Temperature: &unifi.ProtectSensorStatValue{Status: "unknown"}, + Humidity: &unifi.ProtectSensorStatValue{Status: "unknown"}, + }, + AirQuality: &unifi.ProtectAirQuality{ + AQI: &unifi.ProtectSensorStatValue{Value: unifi.FlexFloat{Val: 7}, Status: "neutral"}, + Vape: &unifi.ProtectSensorStatValue{Value: unifi.FlexFloat{Val: 0}, Status: "safe"}, + CO2: &unifi.ProtectSensorStatValue{Value: unifi.FlexFloat{Val: 452}, Status: "neutral"}, + PM2p5: &unifi.ProtectSensorStatValue{Value: unifi.FlexFloat{Val: 1.79}, Status: "neutral"}, + Humidity: &unifi.ProtectSensorStatValue{Value: unifi.FlexFloat{Val: 51}, Status: "neutral"}, + Temperature: &unifi.ProtectSensorStatValue{Value: unifi.FlexFloat{Val: 25.4}, Status: "neutral"}, + }, + }}, + }) + + got := readingsByName(t, withAir.sent) + assert.InDelta(t, 25.4, got["unifi_protect_sensor_temperature_celsius"], 0.001) + assert.InDelta(t, 51, got["unifi_protect_sensor_humidity_percent"], 0.001) + assert.InDelta(t, 7, got["unifi_protect_sensor_aqi"], 0.001) + assert.InDelta(t, 0, got["unifi_protect_sensor_vape"], 0.001) + assert.InDelta(t, 452, got["unifi_protect_sensor_co2_ppm"], 0.001) + assert.InDelta(t, 1.79, got["unifi_protect_sensor_pm2_5"], 0.001) + assert.NotContains(t, got, "unifi_protect_sensor_tvoc") + + statsWin := &fakeReport{} + u.exportProtectDevices(statsWin, &unifi.ProtectDevices{ + SourceName: "https://protect.example", + Sensors: []*unifi.ProtectSensor{{ + ProtectDeviceIdentity: unifi.ProtectDeviceIdentity{Name: "Both", Type: "sensor", ModelKey: "sensor", State: "CONNECTED"}, + Stats: &unifi.ProtectSensorStats{ + Temperature: &unifi.ProtectSensorStatValue{Value: unifi.FlexFloat{Val: 21.5}, Status: "safe"}, + }, + AirQuality: &unifi.ProtectAirQuality{ + Temperature: &unifi.ProtectSensorStatValue{Value: unifi.FlexFloat{Val: 99}, Status: "neutral"}, + }, + }}, + }) + + assert.InDelta(t, 21.5, readingsByName(t, statsWin.sent)["unifi_protect_sensor_temperature_celsius"], 0.001) +} + +func readingsByName(t *testing.T, sent []*metric) map[string]float64 { + t.Helper() + + out := make(map[string]float64, len(sent)) + for _, m := range sent { + name := prometheusDescName(t, m.Desc) + value, ok := m.Value.(float64) + require.True(t, ok, "metric %s value is %T", name, m.Value) + + out[name] = value + } + + return out +} + +func prometheusDescName(t *testing.T, desc *prometheus.Desc) string { + t.Helper() + + const key = `fqName: "` + + text := desc.String() + start := strings.Index(text, key) + require.GreaterOrEqual(t, start, 0, "descriptor has no fqName: %s", text) + + rest := text[start+len(key):] + end := strings.Index(rest, `"`) + require.GreaterOrEqual(t, end, 0, "descriptor fqName is unclosed: %s", text) + + return rest[:end] +} From c742df91e88c7e52535bc92fb30d2ac418b19f10 Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Tue, 29 Sep 2026 11:28:06 -0500 Subject: [PATCH 2/2] chore(deps): bump github.com/unpoller/unifi/v6 to v6.2.0 Co-authored-by: Cursor --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 35e864c7..f478aebe 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.1.3 + github.com/unpoller/unifi/v6 v6.2.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 2c22a6c6..8a67f019 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.1.3 h1:npoNlGCSc6dMe/P9ZL0fe7fLw4vb7nJSTVqKLmwDjLY= -github.com/unpoller/unifi/v6 v6.1.3/go.mod h1:fJ1lU/mpKrQ8Jv3b2yq399ATlrUuiOZAMbKWcw/1GaU= +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/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=