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= 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] +}