mirror of
https://github.com/unpoller/unpoller.git
synced 2026-09-29 19:11:17 +02:00
Merge pull request #1100 from unpoller/feat/protect-air-quality
feat(protect): export air quality readings when present
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user