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 <cursoragent@cursor.com>
This commit is contained in:
Cody Lee
2026-09-29 09:46:13 -05:00
co-authored by Cursor
parent dcbb4070dc
commit 912ea91ac0
4 changed files with 248 additions and 31 deletions
+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]
}