You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
devices/sen6x/sen6x_test.go

809 lines
18 KiB
Go

// Copyright 2026 The Periph Authors. All rights reserved.
// Use of this source code is governed under the Apache License, Version 2.0
// that can be found in the LICENSE file.
package sen6x
import (
"testing"
"time"
"periph.io/x/conn/v3/i2c/i2ctest"
)
func TestNew(t *testing.T) {
dev := New(&i2ctest.Playback{}, SEN66)
if dev == nil {
t.Fatalf("dev is nil")
}
if dev.model != SEN66 {
t.Fatalf("got model %v, want %v", dev.model, SEN66)
}
}
func TestDevString(t *testing.T) {
d := newTestDev(t, nil, SEN66)
got := d.String()
want := "SEN66"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
// sen66MeasurementOps returns the i2ctest.IO entries for a single SEN66
// measurement cycle. If withGetDataReady is true then the returned ops will
// start with GetDataReady returning ready=true.
func sen66MeasurementOps(t *testing.T, withGetDataReady bool) []i2ctest.IO {
t.Helper()
// SEN66 measurement data.
data := []byte{
0x00, 0x04, 0x45, // PM1.0
0x00, 0x05, 0x74, // PM2.5
0x00, 0x06, 0x27, // PM4.0
0x00, 0x06, 0x27, // PM10.0
0x14, 0xf0, 0xee, // RH
0x11, 0x6b, 0x4a, // Temp
0x00, 0x64, 0xfe, // VOC
0x00, 0x46, 0x1a, // NOx
0x01, 0xf4, 0x33, // CO2
}
ops := []i2ctest.IO{}
if withGetDataReady {
ops = append(ops,
// GetDataReady write.
i2ctest.IO{Addr: i2cAddr, W: []byte{0x02, 0x02}},
// GetDataReady read.
i2ctest.IO{Addr: i2cAddr, R: []byte{0x00, 0x01, crc8(0x00, 0x01)}},
)
}
ops = append(ops,
// ReadMeasuredValues write.
i2ctest.IO{Addr: i2cAddr, W: []byte{0x03, 0x00}},
// ReadMeasuredValues read.
i2ctest.IO{Addr: i2cAddr, R: data},
)
return ops
}
func TestSenseContinuous(t *testing.T) {
// Set the device's sampling interval to a small value so that tests run
// quickly. Set it back after the tests finish.
originalSamplingInterval := deviceSamplingInterval
defer func() {
deviceSamplingInterval = originalSamplingInterval
}()
deviceSamplingInterval = 2 * time.Millisecond
// We'll use intervals shorter and longer than the device's native interval.
shortTestInterval := deviceSamplingInterval - time.Millisecond
longTestInterval := deviceSamplingInterval + time.Millisecond
cases := []senseContinuousTestCase{
{
name: "success short interval",
model: SEN66,
// An interval shorter than the device's internal sampling interval
// will cause GetDataReady to be polled.
interval: shortTestInterval,
expectedMeasurementCount: 3,
ops: func() []i2ctest.IO {
// StartContinuousMeasurement write.
ops := []i2ctest.IO{
{Addr: i2cAddr, W: []byte{0x00, 0x21}},
}
// Three measurements.
for range 3 {
ops = append(ops, sen66MeasurementOps(t, true)...)
}
// StopMeasurement from Halt.
ops = append(ops,
i2ctest.IO{Addr: i2cAddr, W: []byte{0x01, 0x04}},
)
return ops
}(),
},
{
name: "success long interval",
model: SEN66,
// An interval longer than the device's internal sampling interval will
// result in GetDataReady not being polled before reading measurements.
interval: longTestInterval,
expectedMeasurementCount: 3,
ops: func() []i2ctest.IO {
// StartContinuousMeasurement write.
ops := []i2ctest.IO{
{Addr: i2cAddr, W: []byte{0x00, 0x21}},
}
// Three measurements.
for range 3 {
ops = append(ops, sen66MeasurementOps(t, false)...)
}
// StopMeasurement from Halt.
ops = append(ops,
i2ctest.IO{Addr: i2cAddr, W: []byte{0x01, 0x04}},
)
return ops
}(),
},
{
name: "fails to start",
model: SEN66,
interval: shortTestInterval,
expectedMeasurementCount: 0,
ops: []i2ctest.IO{
// StartContinuousMeasurement write has no matching op
// so i2ctest will return an error, simulating a bus error.
// StopMeasurement from Halt.
{Addr: i2cAddr, W: []byte{0x01, 0x04}},
},
dontPanic: true,
},
{
name: "waitOnDataReady fails",
model: SEN66,
interval: shortTestInterval,
expectedMeasurementCount: 0,
ops: []i2ctest.IO{
// StartContinuousMeasurement write.
{Addr: i2cAddr, W: []byte{0x00, 0x21}},
// GetDataReady write.
{Addr: i2cAddr, W: []byte{0x02, 0x02}},
// No GetDataReady read. This simulates a bus error in waitOnDataReady.
// StopMeasurement from Halt.
{Addr: i2cAddr, W: []byte{0x01, 0x04}},
},
dontPanic: true,
},
{
name: "doReadMeasuredValues fails",
model: SEN66,
interval: shortTestInterval,
expectedMeasurementCount: 0,
ops: []i2ctest.IO{
// StartContinuousMeasurement write.
{Addr: i2cAddr, W: []byte{0x00, 0x21}},
// A partial waitOnDataReady cycle that omits the measurement itself.
// GetDataReady write.
{Addr: i2cAddr, W: []byte{0x02, 0x02}},
// GetDataReady read.
{Addr: i2cAddr, R: []byte{0x00, 0x01, crc8(0x00, 0x01)}},
// ReadMeasuredValues write.
{Addr: i2cAddr, W: []byte{0x03, 0x00}},
// No ReadMeasuredValues read. This simulates a bus error in doReadMeasuredValues.
// StopMeasurement from Halt.
{Addr: i2cAddr, W: []byte{0x01, 0x04}},
},
dontPanic: true,
},
}
runSenseContinuousTests(t, cases)
}
func TestDevStartContinuousMeasurement(t *testing.T) {
cases := []writeTestCase{
{
name: "success",
model: SEN66,
tx: []byte{0x00, 0x21},
},
}
runWriteTests(t, cases, (*Dev).StartContinuousMeasurement)
}
func TestDevStopMeasurement(t *testing.T) {
cases := []writeTestCase{
{
name: "success",
model: SEN66,
tx: []byte{0x01, 0x04},
},
}
runWriteTests(t, cases, (*Dev).StopMeasurement)
}
func TestDevGetDataReady(t *testing.T) {
cmd := []byte{0x02, 0x02}
cases := []writeAndReadTestCase[bool]{
{
name: "false",
model: SEN66,
tx: cmd,
rx: []byte{0x00, 0x00, 0x81},
want: false,
},
{
name: "true",
model: SEN66,
tx: cmd,
rx: []byte{0x00, 0x01, 0xb0},
want: true,
},
{
name: "bad CRC",
model: SEN66,
tx: cmd,
rx: []byte{0x00, 0x01, 0xff},
wantErr: true,
},
}
runWriteAndReadTests(t, cases, (*Dev).GetDataReady)
}
func TestDevReadMeasuredValues(t *testing.T) {
cases := []writeAndReadTestCase[*SensorValues]{
{
name: "SEN62",
model: SEN62,
tx: []byte{0x04, 0xA3},
rx: []byte{
0x00, 0x04, 0x45, // PM1.0
0x00, 0x05, 0x74, // PM2.5
0x00, 0x06, 0x27, // PM4.0
0x00, 0x06, 0x27, // PM10.0
0x14, 0xf0, 0xee, // RH
0x11, 0x6b, 0x4a, // Temp
},
want: &SensorValues{
PM1: ptr(float32(0.4)),
PM25: ptr(float32(0.5)),
PM4: ptr(float32(0.6)),
PM10: ptr(float32(0.6)),
RH: ptr(float32(53.6)),
Temp: ptr(float32(22.295)),
},
},
{
name: "SEN63C",
model: SEN63C,
tx: []byte{0x04, 0x71},
rx: []byte{
0x00, 0x04, 0x45, // PM1.0
0x00, 0x05, 0x74, // PM2.5
0x00, 0x06, 0x27, // PM4.0
0x00, 0x06, 0x27, // PM10.0
0x14, 0xf0, 0xee, // RH
0x11, 0x6b, 0x4a, // Temp
0x01, 0xf4, 0x33, // CO2
},
want: &SensorValues{
PM1: ptr(float32(0.4)),
PM25: ptr(float32(0.5)),
PM4: ptr(float32(0.6)),
PM10: ptr(float32(0.6)),
RH: ptr(float32(53.6)),
Temp: ptr(float32(22.295)),
CO2: ptr(int16(500)),
},
},
{
name: "SEN65",
model: SEN65,
tx: []byte{0x04, 0x46},
rx: []byte{
0x00, 0x04, 0x45, // PM1.0
0x00, 0x05, 0x74, // PM2.5
0x00, 0x06, 0x27, // PM4.0
0x00, 0x06, 0x27, // PM10.0
0x14, 0xf0, 0xee, // RH
0x11, 0x6b, 0x4a, // Temp
0x00, 0x64, 0xfe, // VOC
0x00, 0x46, 0x1a, // NOx
},
want: &SensorValues{
PM1: ptr(float32(0.4)),
PM25: ptr(float32(0.5)),
PM4: ptr(float32(0.6)),
PM10: ptr(float32(0.6)),
RH: ptr(float32(53.6)),
Temp: ptr(float32(22.295)),
VOC: ptr(float32(10)),
NOx: ptr(float32(7)),
},
},
{
name: "SEN66",
model: SEN66,
tx: []byte{0x03, 0x00},
rx: []byte{
0x00, 0x04, 0x45, // PM1.0
0x00, 0x05, 0x74, // PM2.5
0x00, 0x06, 0x27, // PM4.0
0x00, 0x06, 0x27, // PM10.0
0x14, 0xf0, 0xee, // RH
0x11, 0x6b, 0x4a, // Temp
0x00, 0x64, 0xfe, // VOC
0x00, 0x46, 0x1a, // NOx
0x01, 0xf4, 0x33, // CO2
},
want: &SensorValues{
PM1: ptr(float32(0.4)),
PM25: ptr(float32(0.5)),
PM4: ptr(float32(0.6)),
PM10: ptr(float32(0.6)),
RH: ptr(float32(53.6)),
Temp: ptr(float32(22.295)),
VOC: ptr(float32(10)),
NOx: ptr(float32(7)),
CO2: ptr(int16(500)),
},
},
{
name: "SEN68",
model: SEN68,
tx: []byte{0x04, 0x67},
rx: []byte{
0x00, 0x04, 0x45, // PM1.0
0x00, 0x05, 0x74, // PM2.5
0x00, 0x06, 0x27, // PM4.0
0x00, 0x06, 0x27, // PM10.0
0x14, 0xf0, 0xee, // RH
0x11, 0x6b, 0x4a, // Temp
0x00, 0x64, 0xfe, // VOC
0x00, 0x46, 0x1a, // NOx
0x01, 0xf4, 0x33, // HCHO
},
want: &SensorValues{
PM1: ptr(float32(0.4)),
PM25: ptr(float32(0.5)),
PM4: ptr(float32(0.6)),
PM10: ptr(float32(0.6)),
RH: ptr(float32(53.6)),
Temp: ptr(float32(22.295)),
VOC: ptr(float32(10)),
NOx: ptr(float32(7)),
HCHO: ptr(float32(50)),
},
},
{
name: "SEN69C",
model: SEN69C,
tx: []byte{0x04, 0xb5},
rx: []byte{
0x00, 0x04, 0x45, // PM1.0
0x00, 0x05, 0x74, // PM2.5
0x00, 0x06, 0x27, // PM4.0
0x00, 0x06, 0x27, // PM10.0
0x14, 0xf0, 0xee, // RH
0x11, 0x6b, 0x4a, // Temp
0x00, 0x64, 0xfe, // VOC
0x00, 0x46, 0x1a, // NOx
0x01, 0xf4, 0x33, // HCHO
0x01, 0xf4, 0x33, // CO2
},
want: &SensorValues{
PM1: ptr(float32(0.4)),
PM25: ptr(float32(0.5)),
PM4: ptr(float32(0.6)),
PM10: ptr(float32(0.6)),
RH: ptr(float32(53.6)),
Temp: ptr(float32(22.295)),
VOC: ptr(float32(10)),
NOx: ptr(float32(7)),
HCHO: ptr(float32(50)),
CO2: ptr(int16(500)),
},
},
{
// This covers the uint16 CO2 encoding used by the SEN66.
name: "SEN66 all values unknown",
model: SEN66,
tx: []byte{0x03, 0x00},
rx: []byte{
0xff, 0xff, 0xac, // PM1.0
0xff, 0xff, 0xac, // PM2.5
0xff, 0xff, 0xac, // PM4.0
0xff, 0xff, 0xac, // PM10.0
0x7f, 0xff, 0x8f, // RH
0x7f, 0xff, 0x8f, // Temp
0x7f, 0xff, 0x8f, // VOC
0x7f, 0xff, 0x8f, // NOx
0xff, 0xff, 0xac, // CO2 as uint
},
want: &SensorValues{},
},
{
// This covers the unset/unknown value for all measurements in the
// SEN6x family, but notably it uses the int16 CO2 encoding also used
// by the SEN63C.
name: "SEN69C all values unknown",
model: SEN69C,
tx: []byte{0x04, 0xb5},
rx: []byte{
0xff, 0xff, 0xac, // PM1.0
0xff, 0xff, 0xac, // PM2.5
0xff, 0xff, 0xac, // PM4.0
0xff, 0xff, 0xac, // PM10.0
0x7f, 0xff, 0x8f, // RH
0x7f, 0xff, 0x8f, // Temp
0x7f, 0xff, 0x8f, // VOC
0x7f, 0xff, 0x8f, // NOx
0xff, 0xff, 0xac, // HCHO
0x7f, 0xff, 0x8f, // CO2 as int
},
want: &SensorValues{},
},
{
name: "bad crc",
model: SEN62,
tx: []byte{0x04, 0xA3},
rx: []byte{
0x00, 0x04, 0x45, // PM1.0
0x00, 0x05, 0x74, // PM2.5
0x00, 0x06, 0x27, // PM4.0
0x00, 0x06, 0xff, // PM10.0 with incorrect CRC (should be 0x27)
0x14, 0xf0, 0xee, // RH
0x11, 0x6b, 0x4a, // Temp
},
wantErr: true,
},
{
name: "unknown model",
model: Model(-1),
wantErr: true,
},
}
runWriteAndReadTests(t, cases, (*Dev).ReadMeasuredValues)
}
func TestDevReadMeasuredRawValues(t *testing.T) {
cases := []writeAndReadTestCase[*RawSensorValues]{
{
name: "SEN62",
model: SEN62,
tx: []byte{0x04, 0x92},
rx: []byte{
0x14, 0x0e, 0x73, // RH
0x11, 0xea, 0x01, // Temp
},
want: &RawSensorValues{
RH: ptr(float32(51.34)),
Temp: ptr(float32(22.93)),
},
},
{
name: "SEN63C",
model: SEN63C,
tx: []byte{0x04, 0x92},
rx: []byte{
0x14, 0x0e, 0x73, // RH
0x11, 0xea, 0x01, // Temp
},
want: &RawSensorValues{
RH: ptr(float32(51.34)),
Temp: ptr(float32(22.93)),
},
},
{
name: "SEN65",
model: SEN65,
tx: []byte{0x04, 0x55},
rx: []byte{
0x14, 0x0e, 0x73, // RH
0x11, 0xea, 0x01, // Temp
0x72, 0xc9, 0xac, // VOC
0x49, 0x45, 0x03, // NOx
},
want: &RawSensorValues{
RH: ptr(float32(51.34)),
Temp: ptr(float32(22.93)),
VOC: ptr(uint16(29385)),
NOx: ptr(uint16(18757)),
},
},
{
name: "SEN66",
model: SEN66,
tx: []byte{0x04, 0x05},
rx: []byte{
0x14, 0x0e, 0x73, // RH
0x11, 0xea, 0x01, // Temp
0x72, 0xc9, 0xac, // VOC
0x49, 0x45, 0x03, // NOx
0x01, 0xc8, 0x8b, // CO2
},
want: &RawSensorValues{
RH: ptr(float32(51.34)),
Temp: ptr(float32(22.93)),
VOC: ptr(uint16(29385)),
NOx: ptr(uint16(18757)),
CO2: ptr(uint16(456)),
},
},
{
name: "SEN68",
model: SEN68,
tx: []byte{0x04, 0x55},
rx: []byte{
0x14, 0x0e, 0x73, // RH
0x11, 0xea, 0x01, // Temp
0x72, 0xc9, 0xac, // VOC
0x49, 0x45, 0x03, // NOx
},
want: &RawSensorValues{
RH: ptr(float32(51.34)),
Temp: ptr(float32(22.93)),
VOC: ptr(uint16(29385)),
NOx: ptr(uint16(18757)),
},
},
{
name: "SEN69C",
model: SEN69C,
tx: []byte{0x04, 0x55},
rx: []byte{
0x14, 0x0e, 0x73, // RH
0x11, 0xea, 0x01, // Temp
0x72, 0xc9, 0xac, // VOC
0x49, 0x45, 0x03, // NOx
},
want: &RawSensorValues{
RH: ptr(float32(51.34)),
Temp: ptr(float32(22.93)),
VOC: ptr(uint16(29385)),
NOx: ptr(uint16(18757)),
},
},
{
// SEN66 covers all raw measurements.
name: "SEN66 all values unknown",
model: SEN66,
tx: []byte{0x04, 0x05},
rx: []byte{
0x7f, 0xff, 0x8f, // RH
0x7f, 0xff, 0x8f, // Temp
0xff, 0xff, 0xac, // VOC
0xff, 0xff, 0xac, // NOx
0xff, 0xff, 0xac, // CO2
},
want: &RawSensorValues{},
},
{
name: "SEN66",
model: SEN66,
tx: []byte{0x04, 0x05},
rx: []byte{
0x14, 0x0e, 0x73, // RH
0x11, 0xea, 0x01, // Temp
0x72, 0xc9, 0xac, // VOC
0x49, 0x45, 0xff, // NOx with incorrect CRC (should be 0x03)
0x01, 0xc8, 0x8b, // CO2
},
wantErr: true,
},
{
name: "unknown model",
model: Model(-1),
wantErr: true,
},
}
runWriteAndReadTests(t, cases, (*Dev).ReadMeasuredRawValues)
}
func TestDevGetProductName(t *testing.T) {
cmd := []byte{0xd0, 0x14}
cases := []writeAndReadTestCase[string]{
{
name: "SEN66",
model: SEN66,
tx: cmd,
rx: []byte{
0x53, 0x45, 0x83, // "SE"
0x4e, 0x36, 0x06, // "N6"
0x36, 0x00, 0x69, // "6\0"
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
},
want: "SEN66",
},
{
name: "bad CRC",
model: SEN66,
tx: cmd,
rx: []byte{
0x53, 0x45, 0x83, // "SE"
0x4e, 0x36, 0xff, // "N6" with incorrect CRC (should be 0x06)
0x36, 0x00, 0x69, // "6\0"
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
},
wantErr: true,
},
}
runWriteAndReadTests(t, cases, (*Dev).GetProductName)
}
func TestDevGetSerialNumber(t *testing.T) {
cmd := []byte{0xd0, 0x33}
cases := []writeAndReadTestCase[string]{
{
name: "serial number from device",
model: SEN66,
tx: cmd,
rx: []byte{
0x32, 0x34, 0xeb, // "24"
0x43, 0x34, 0x24, // "C4"
0x45, 0x45, 0xb7, // "EE"
0x31, 0x37, 0x95, // "17"
0x46, 0x41, 0x5e, // "FA"
0x43, 0x37, 0x77, // "C7"
0x43, 0x35, 0x15, // "C5"
0x43, 0x42, 0x7a, // "CB"
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
},
want: "24C4EE17FAC7C5CB",
},
{
name: "bad CRC",
model: SEN66,
tx: cmd,
rx: []byte{
0x32, 0x34, 0xeb, // "24"
0x43, 0x34, 0x24, // "C4"
0x45, 0x45, 0xb7, // "EE"
0x31, 0x37, 0xff, // "17" with incorrect CRC (should be 0x95)
0x46, 0x41, 0x5e, // "FA"
0x43, 0x37, 0x77, // "C7"
0x43, 0x35, 0x15, // "C5"
0x43, 0x42, 0x7a, // "CB"
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
0x00, 0x00, 0x81,
},
wantErr: true,
},
}
runWriteAndReadTests(t, cases, (*Dev).GetSerialNumber)
}
func TestDevGetVersion(t *testing.T) {
cases := []struct {
name string
response []byte
wantMajor uint8
wantMinor uint8
wantErr bool
}{
{
name: "version 4.0",
response: []byte{0x04, 0x00, 0x2},
wantMajor: 4,
wantMinor: 0,
},
{
name: "version 1.2",
response: []byte{0x01, 0x02, crc8(0x01, 0x02)},
wantMajor: 1,
wantMinor: 2,
},
{
name: "bad CRC",
response: []byte{0x04, 0x00, 0xff},
wantErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
bus := i2ctest.Playback{
Ops: []i2ctest.IO{
{Addr: i2cAddr, W: []byte{0xd1, 0x00}},
{Addr: i2cAddr, R: tc.response},
},
}
defer func() {
if err := bus.Close(); err != nil {
t.Error(err)
}
}()
d := newTestDev(t, &bus, SEN66)
gotMajor, gotMinor, err := d.GetVersion()
if tc.wantErr && err == nil {
t.Fatalf("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !tc.wantErr {
if gotMajor != tc.wantMajor {
t.Errorf("got major version %d, want %d", gotMajor, tc.wantMajor)
}
if gotMinor != tc.wantMinor {
t.Errorf("got minor version %d, want %d", gotMinor, tc.wantMinor)
}
}
})
}
}
func TestDevDeviceReset(t *testing.T) {
cases := []writeTestCase{
{
name: "success",
model: SEN66,
tx: []byte{0xd3, 0x04},
},
}
runWriteTests(t, cases, (*Dev).DeviceReset)
}
func TestDevStartFanCleaning(t *testing.T) {
cases := []writeTestCase{
{
name: "success",
model: SEN66,
tx: []byte{0x56, 0x07},
},
}
runWriteTests(t, cases, (*Dev).StartFanCleaning)
}