Merge pull request #1100 from unpoller/feat/protect-air-quality

feat(protect): export air quality readings when present
This commit is contained in:
Cody Lee
2026-09-29 12:31:33 -04:00
committed by GitHub
6 changed files with 251 additions and 34 deletions
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+37 -10
View File
@@ -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)
}
}
+33 -8
View File
@@ -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
}
}
+76 -13
View File
@@ -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 {
+102
View File
@@ -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]
}