sen6x: Add driver for Sensirion SEN6x family of environmental sensors

The Sensirion SEN6x family includes 6 sensors (SEN62, SEN63C, SEN65,
SEN66, SEN68, SEN69C) that support the following measurements, in
different combinations depending on the model:

  - Particulate matter (PM1.0, PM2.5, PM4, and PM10, with the addition
    of PM0.5 from the Read Number Concentration Values command)
  - Relative humidity
  - Temperature
  - VOC
  - NOx
  - Formaldehyde (HCHO)
  - CO2

Datasheet: https://sensirion.com/media/documents/FAFC548D/693FBB15/PS_DS_SEN6x.pdf
pull/118/head
Michael Traver 2 weeks ago
parent 1752d1b57a
commit d375a1ab43

@ -0,0 +1,200 @@
// 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 (
"encoding/binary"
"errors"
)
// PerformForcedCO2Recalibration executes a forced recalibration (FRC) of the CO2
// signal. It returns the correction value in ppm CO2.
//
// To successfully conduct an accurate FRC the following steps need to be taken:
// 1. Start a measurement with [Dev.StartContinuousMeasurement] and operate the sensor for
// at least 3 minutes in an environment with homogeneous and constant CO2 concentration.
// If applicable, the reference value for altitude or pressure compensation must be provided
// to the sensor beforehand with [Dev.SetSensorAltitude] or [Dev.SetAmbientPressure],
// respectively.
// 2. Stop the measurement with [Dev.StopMeasurement] and wait at least 1400 ms.
// 3. Call [Dev.PerformForcedCO2Recalibration] with the reference CO2 concentration that
// the sensor should be set to. The recalibration procedure will take about 500 ms to
// complete, during which time no other functions can be executed. A return value of
// 0xffff indicates that the FRC has failed, and this method will return a non-nil
// error in that case.
//
// Note: This configuration is persistent, i.e. the parameters will be retained
// during a device reset or power cycle.
func (d *Dev) PerformForcedCO2Recalibration(refCO2PPM uint16) (int16, error) {
if !d.model.hasCO2() {
return 0, errors.New("sen6x: PerformForcedCO2Recalibration requires a CO2-equipped model")
}
d.mu.Lock()
defer d.mu.Unlock()
data, err := d.writeAndRead(cmdPerformForcedCO2RecalibrationSEN63CSEN66SEN69C, packWordsWithCRC([]uint16{refCO2PPM}))
if err != nil {
return 0, err
}
correction := binary.BigEndian.Uint16(data[0:2])
if correction == 0xffff {
return 0, errors.New("sen6x: Forced CO2 recalibration failed, got return value of 0xffff")
}
// The datasheet specifies that the FRC correction in ppm is equal to the
// return value - 0x8000 (i.e., int16's max plus 1). The STCC4's datasheet
// corroborates this and provides the example of a -100 ppm correction:
// -100 ppm = 32668 - 0x8000. Since the raw correction value returned from
// the device is a uint16 and 0xffff is reserved to indicate failure, the
// range of the final computed ppm value is [0 - 0x8000, 0xffff - 1 - 0x8000] =
// [-32768, 32766], which is within the range of int16. We can therefore
// safely cast back to int16 after performing the calculation.
return int16(int32(correction) - 0x8000), nil
}
// PerformCO2SensorFactoryReset resets all CO2 sensor configuration settings stored
// in the EEPROM and erases the forced recalibration (FRC) and automatic self-calibration
// (ASC) algorithm history of the CO2 sensor, restarting the bypass phase. Refer
// to the [datasheet of the STCC4] for more information.
//
// NOTE: On the SEN66, this command is available only on firmware versions >= 1.2.
// It is available in all firmware versions on the SEN63C and SEN69C.
//
// [datasheet of the STCC4]: https://sensirion.com/media/documents/6AED4B15/69295E41/CD_DS_STCC4_D1.pdf
func (d *Dev) PerformCO2SensorFactoryReset() error {
if !d.model.hasCO2() {
return errors.New("sen6x: PerformCO2SensorFactoryReset requires a CO2-equipped model")
}
d.mu.Lock()
defer d.mu.Unlock()
return d.writeAndWait(cmdPerformCO2SensorFactoryResetSEN63CSEN66SEN69C, nil)
}
// GetCO2SensorAutomaticSelfCalibration gets the status of the CO2 sensor automatic
// self-calibration (ASC). The CO2 sensor supports ASC for long-term stability of
// the CO2 output. It can be enabled or disabled. By default, it is enabled.
func (d *Dev) GetCO2SensorAutomaticSelfCalibration() (bool, error) {
if !d.model.hasCO2() {
return false, errors.New("sen6x: GetCO2SensorAutomaticSelfCalibration requires a CO2-equipped model")
}
d.mu.Lock()
defer d.mu.Unlock()
data, err := d.writeAndRead(cmdGetCO2AutoSelfCalibrationSEN63CSEN66SEN69C, nil)
if err != nil {
return false, err
}
return data[1] == 1, nil
}
// SetCO2SensorAutomaticSelfCalibration sets the status of the CO2 sensor automatic
// self-calibration (ASC). The CO2 sensor supports ASC for long-term stability of
// the CO2 output. This feature can be enabled or disabled. By default, it is enabled.
//
// ASC can be disabled for testing under lab conditions where concentrations below
// 400ppm are expected, to avoid an alteration of the baseline. In the field, ASC must
// be enabled and exposure to fresh air (i.e. CO2 concentration at 400 ppm) at least
// once per week is required to reach datasheet specifications.
//
// Note: This configuration is volatile, i.e. it will be reverted to its default
// value after a device reset.
func (d *Dev) SetCO2SensorAutomaticSelfCalibration(enable bool) error {
if !d.model.hasCO2() {
return errors.New("sen6x: SetCO2SensorAutomaticSelfCalibration requires a CO2-equipped model")
}
// The datasheet breaks down the input into two bytes, the first of which is
// always 0x00 and the second of which is 0x01 to enable and 0x00 to disable.
// That's equivalent to a 16-bit word set to 0x0000 or 0x0001.
var enableWord uint16
if enable {
enableWord = 1
}
d.mu.Lock()
defer d.mu.Unlock()
return d.writeAndWait(cmdSetCO2AutoSelfCalibrationSEN63CSEN66SEN69C, packWordsWithCRC([]uint16{enableWord}))
}
// GetAmbientPressure gets the ambient pressure (in hPa) that was set with
// [Dev.SetAmbientPressure]. It is used for pressure compensation by the CO2 sensor.
func (d *Dev) GetAmbientPressure() (uint16, error) {
if !d.model.hasCO2() {
return 0, errors.New("sen6x: GetAmbientPressure requires a CO2-equipped model")
}
d.mu.Lock()
defer d.mu.Unlock()
data, err := d.writeAndRead(cmdGetAmbientPressureSEN63CSEN66SEN69C, nil)
if err != nil {
return 0, err
}
return binary.BigEndian.Uint16(data[0:2]), nil
}
// SetAmbientPressure sets the ambient pressure value, in hPa. It is used for
// pressure compensation by the CO2 sensor. Setting an ambient pressure overrides
// any pressure compensation based on a previously set sensor altitude.
//
// Use of this command is recommended for applications experiencing significant
// ambient pressure changes to ensure CO2 sensor accuracy. Valid input values are
// 700 to 1,200 hPa. Device default: 1013 hPa
//
// Note: This configuration is volatile, i.e. the pressure will be reverted to
// its default value after a device reset
func (d *Dev) SetAmbientPressure(hPa uint16) error {
if !d.model.hasCO2() {
return errors.New("sen6x: SetAmbientPressure requires a CO2-equipped model")
}
d.mu.Lock()
defer d.mu.Unlock()
return d.writeAndWait(cmdSetAmbientPressureSEN63CSEN66SEN69C, packWordsWithCRC([]uint16{hPa}))
}
// GetSensorAltitude gets the current sensor altitude, in meters. It is used for
// pressure compensation by the CO2 sensor.
func (d *Dev) GetSensorAltitude() (uint16, error) {
if !d.model.hasCO2() {
return 0, errors.New("sen6x: GetSensorAltitude requires a CO2-equipped model")
}
d.mu.Lock()
defer d.mu.Unlock()
data, err := d.writeAndRead(cmdGetSensorAltitudeSEN63CSEN66SEN69C, nil)
if err != nil {
return 0, err
}
return binary.BigEndian.Uint16(data[0:2]), nil
}
// SetSensorAltitude sets the current sensor altitude, in meters. It is used for
// pressure compensation by the CO2 sensor. The default sensor altitude value is
// 0 meters above sea level. Valid input values are 0 to 3000 m.
//
// Note: This configuration is volatile, i.e. the altitude will be reverted to
// its default value after a device reset.
func (d *Dev) SetSensorAltitude(meters uint16) error {
if !d.model.hasCO2() {
return errors.New("sen6x: SetSensorAltitude requires a CO2-equipped model")
}
d.mu.Lock()
defer d.mu.Unlock()
return d.writeAndWait(cmdSetSensorAltitudeSEN63CSEN66SEN69C, packWordsWithCRC([]uint16{meters}))
}

@ -0,0 +1,247 @@
// 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"
func TestDevPerformForcedCO2Recalibration(t *testing.T) {
cases := []writeAndReadTestCase[int16]{
{
name: "success, min correction",
model: SEN66,
tx: []byte{
0x67, 0x7, // Command
0x0, 0xaf, 0x53, // Ref CO2 ppm
},
rx: []byte{0x00, 0x00, 0x81},
want: -32768,
},
{
name: "success, max correction",
model: SEN66,
tx: []byte{
0x67, 0x7, // Command
0x0, 0xaf, 0x53, // Ref CO2 ppm
},
rx: []byte{0xff, 0xfe, 0x9d},
want: 32766,
},
{
name: "calibration failed",
model: SEN66,
tx: []byte{
0x67, 0x7, // Command
0x0, 0xaf, 0x53, // Ref CO2 ppm
},
rx: []byte{0xff, 0xff, 0xac},
wantErr: true,
},
{
// writeAndRead will fail because no response is set.
name: "read error",
model: SEN66,
tx: []byte{
0x67, 0x7, // Command
0x0, 0xaf, 0x53, // Ref CO2 ppm
},
wantErr: true,
dontPanic: true,
},
{
// This fails before sending any data, so no tx or rx set.
name: "model without CO2 capability",
model: SEN62,
wantErr: true,
},
}
runWriteAndReadTests(t, cases, func(d *Dev) (int16, error) {
return d.PerformForcedCO2Recalibration(175)
})
}
func TestDevPerformCO2SensorFactoryReset(t *testing.T) {
cases := []writeTestCase{
{
name: "success",
model: SEN66,
tx: []byte{0x67, 0x54},
},
{
// This fails before sending any data, so no tx set.
name: "model without CO2 capability",
model: SEN62,
wantErr: true,
},
}
runWriteTests(t, cases, (*Dev).PerformCO2SensorFactoryReset)
}
func TestDevGetCO2SensorAutomaticSelfCalibration(t *testing.T) {
cmd := []byte{0x67, 0x11}
cases := []writeAndReadTestCase[bool]{
{
name: "enabled",
model: SEN66,
tx: cmd,
rx: []byte{0x00, 0x01, 0xb0},
want: true,
},
{
name: "disabled",
model: SEN66,
tx: cmd,
rx: []byte{0x00, 0x00, 0x81},
want: false,
},
{
// writeAndRead will fail because no response is set.
name: "read error",
model: SEN66,
tx: cmd,
wantErr: true,
dontPanic: true,
},
{
// This fails before sending any data, so no tx or rx set.
name: "model without CO2 capability",
model: SEN62,
wantErr: true,
},
}
runWriteAndReadTests(t, cases, (*Dev).GetCO2SensorAutomaticSelfCalibration)
}
func TestDevSetCO2SensorAutomaticSelfCalibration(t *testing.T) {
cases := []writeTestCase{
{
name: "success",
model: SEN66,
tx: []byte{
0x67, 0x11, // Command
0x00, 0x01, 0xb0, // Auto self calibration boolean
},
},
{
// This fails before sending any data, so no tx set.
name: "model without CO2 capability",
model: SEN62,
wantErr: true,
},
}
runWriteTests(t, cases, func(d *Dev) error {
return d.SetCO2SensorAutomaticSelfCalibration(true)
})
}
func TestDevGetAmbientPressure(t *testing.T) {
cmd := []byte{0x67, 0x20}
cases := []writeAndReadTestCase[uint16]{
{
name: "success",
model: SEN66,
tx: cmd,
rx: []byte{0x03, 0xf5, 0xdb},
want: 1013,
},
{
// writeAndRead will fail because no response is set.
name: "read error",
model: SEN66,
tx: cmd,
wantErr: true,
dontPanic: true,
},
{
// This fails before sending any data, so no tx or rx set.
name: "model without CO2 capability",
model: SEN62,
wantErr: true,
},
}
runWriteAndReadTests(t, cases, (*Dev).GetAmbientPressure)
}
func TestDevSetAmbientPressure(t *testing.T) {
cases := []writeTestCase{
{
name: "success",
model: SEN66,
tx: []byte{
0x67, 0x20, // Command
0x03, 0x23, 0x79, // Pressure
},
},
{
// This fails before sending any data, so no tx set.
name: "model without CO2 capability",
model: SEN62,
wantErr: true,
},
}
runWriteTests(t, cases, func(d *Dev) error {
return d.SetAmbientPressure(803)
})
}
func TestDevGetSensorAltitude(t *testing.T) {
cmd := []byte{0x67, 0x36}
cases := []writeAndReadTestCase[uint16]{
{
name: "success",
model: SEN66,
tx: cmd,
rx: []byte{0x07, 0x7c, 0xaa},
want: 1916,
},
{
// writeAndRead will fail because no response is set.
name: "read error",
model: SEN66,
tx: cmd,
wantErr: true,
dontPanic: true,
},
{
// This fails before sending any data, so no tx or rx set.
name: "model without CO2 capability",
model: SEN62,
wantErr: true,
},
}
runWriteAndReadTests(t, cases, (*Dev).GetSensorAltitude)
}
func TestDevSetSensorAltitude(t *testing.T) {
cases := []writeTestCase{
{
name: "success",
model: SEN66,
tx: []byte{
0x67, 0x36, // Command
0x07, 0x7c, 0xaa, // Altitude
},
},
{
// This fails before sending any data, so no tx set.
name: "model without CO2 capability",
model: SEN62,
wantErr: true,
},
}
runWriteTests(t, cases, func(d *Dev) error {
return d.SetSensorAltitude(1916)
})
}

@ -0,0 +1,320 @@
// 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 (
"time"
)
// command is a SEN6x I2C command, including execution time
// and the number of bytes sent in response.
type command struct {
id uint16
execTime time.Duration
// The number of bytes sent by the device in response
// to the command, including CRC bytes.
rxDataLen int
}
var (
// I2C sequence type: Send
// During measurement: no
cmdStartContinuousMeasurement = command{
id: 0x0021,
execTime: 50 * time.Millisecond,
}
// I2C sequence type: Send
// During measurement: yes
cmdStopMeasurement = command{
id: 0x0104,
execTime: 1400 * time.Millisecond,
}
// I2C sequence type: Read
// During measurement: yes
cmdGetDataReady = command{
id: 0x0202,
execTime: 20 * time.Millisecond,
rxDataLen: 3,
}
// I2C sequence type: Read
// During measurement: yes
cmdReadMeasuredValuesSEN62 = command{
id: 0x04a3,
execTime: 20 * time.Millisecond,
rxDataLen: 18,
}
// I2C sequence type: Read
// During measurement: yes
cmdReadMeasuredValuesSEN63C = command{
id: 0x0471,
execTime: 20 * time.Millisecond,
rxDataLen: 21,
}
// I2C sequence type: Read
// During measurement: yes
cmdReadMeasuredValuesSEN65 = command{
id: 0x0446,
execTime: 20 * time.Millisecond,
rxDataLen: 24,
}
// I2C sequence type: Read
// During measurement: yes
cmdReadMeasuredValuesSEN66 = command{
id: 0x0300,
execTime: 20 * time.Millisecond,
rxDataLen: 27,
}
// I2C sequence type: Read
// During measurement: yes
cmdReadMeasuredValuesSEN68 = command{
id: 0x0467,
execTime: 20 * time.Millisecond,
rxDataLen: 27,
}
// I2C sequence type: Read
// During measurement: yes
cmdReadMeasuredValuesSEN69C = command{
id: 0x04b5,
execTime: 20 * time.Millisecond,
rxDataLen: 30,
}
// I2C sequence type: Read
// During measurement: yes
cmdReadMeasuredRawValuesSEN62SEN63C = command{
id: 0x0492,
execTime: 20 * time.Millisecond,
rxDataLen: 6,
}
// I2C sequence type: Read
// During measurement: yes
cmdReadMeasuredRawValuesSEN65SEN68SEN69C = command{
id: 0x0455,
execTime: 20 * time.Millisecond,
rxDataLen: 12,
}
// I2C sequence type: Read
// During measurement: yes
cmdReadMeasuredRawValuesSEN66 = command{
id: 0x0405,
execTime: 20 * time.Millisecond,
rxDataLen: 15,
}
// I2C sequence type: Read
// During measurement: yes
cmdReadNumberConcentrationValues = command{
id: 0x0316,
execTime: 20 * time.Millisecond,
rxDataLen: 15,
}
// I2C sequence type: Write
// During measurement: yes
cmdSetTemperatureOffsetParams = command{
id: 0x60b2,
execTime: 20 * time.Millisecond,
}
// I2C sequence type: Write
// During measurement: no
cmdSetTemperatureAccelParams = command{
id: 0x6100,
execTime: 20 * time.Millisecond,
}
// I2C sequence type: Read
// During measurement: yes
cmdGetProductName = command{
id: 0xd014,
execTime: 20 * time.Millisecond,
rxDataLen: 48,
}
// I2C sequence type: Read
// During measurement: yes
cmdGetSerialNumber = command{
id: 0xd033,
execTime: 20 * time.Millisecond,
rxDataLen: 48,
}
// I2C sequence type: Read
// During measurement: yes
cmdReadDeviceStatus = command{
id: 0xd206,
execTime: 20 * time.Millisecond,
rxDataLen: 6,
}
// I2C sequence type: Read
// During measurement: yes
cmdReadAndClearDeviceStatus = command{
id: 0xd210,
execTime: 20 * time.Millisecond,
rxDataLen: 6,
}
// I2C sequence type: Read
// During measurement: yes
cmdGetVersion = command{
id: 0xd100,
execTime: 20 * time.Millisecond,
rxDataLen: 3,
}
// I2C sequence type: Send
// During measurement: no
cmdDeviceReset = command{
id: 0xd304,
execTime: 1200 * time.Millisecond,
}
// I2C sequence type: Send
// During measurement: no
cmdStartFanCleaning = command{
id: 0x5607,
execTime: 20 * time.Millisecond,
}
// I2C sequence type: Send
// During measurement: no
cmdActivateSHTHeater = command{
id: 0x6765,
// Execution time depends on the sensor's firmware version. See
// [Dev.ActivateSHTHeater] for details.
execTime: 20 * time.Millisecond,
}
// I2C sequence type: Read
// During measurement: no
//
// This command is available only in certain sensor firmware versions.
// See [Dev.GetSHTHeaterMeasurements] for details.
cmdGetSHTHeaterMeasurements = command{
id: 0x6790,
execTime: 20 * time.Millisecond,
rxDataLen: 6,
}
// I2C sequence type: Read
// During measurement: no
cmdGetVOCAlgorithmTuningParamsSEN65SEN66SEN68SEN69C = command{
id: 0x60d0,
execTime: 20 * time.Millisecond,
rxDataLen: 18,
}
// I2C sequence type: Write
// During measurement: no
cmdSetVOCAlgorithmTuningParamsSEN65SEN66SEN68SEN69C = command{
id: 0x60d0,
execTime: 20 * time.Millisecond,
}
// I2C sequence type: Read
// During measurement: yes
cmdGetVOCAlgorithmStateSEN65SEN66SEN68SEN69C = command{
id: 0x6181,
execTime: 20 * time.Millisecond,
rxDataLen: 12,
}
// I2C sequence type: Write
// During measurement: no
cmdSetVOCAlgorithmStateSEN65SEN66SEN68SEN69C = command{
id: 0x6181,
execTime: 20 * time.Millisecond,
}
// I2C sequence type: Read
// During measurement: no
cmdGetNOxAlgorithmTuningParamsSEN65SEN66SEN68SEN69C = command{
id: 0x60e1,
execTime: 20 * time.Millisecond,
rxDataLen: 18,
}
// I2C sequence type: Write
// During measurement: no
cmdSetNOxAlgorithmTuningParamsSEN65SEN66SEN68SEN69C = command{
id: 0x60e1,
execTime: 20 * time.Millisecond,
}
// I2C sequence type: Send and read
// During measurement: no
cmdPerformForcedCO2RecalibrationSEN63CSEN66SEN69C = command{
id: 0x6707,
execTime: 500 * time.Millisecond,
rxDataLen: 3,
}
// I2C sequence type: Send
// During measurement: no
//
// On the SEN66, this command is available only in firmware versions >= 1.2.
// It is available in all firmware versions on the SEN63C and SEN69C.
cmdPerformCO2SensorFactoryResetSEN63CSEN66SEN69C = command{
id: 0x6754,
execTime: 1400 * time.Millisecond,
}
// I2C sequence type: Read
// During measurement: no
cmdGetCO2AutoSelfCalibrationSEN63CSEN66SEN69C = command{
id: 0x6711,
execTime: 20 * time.Millisecond,
rxDataLen: 3,
}
// I2C sequence type: Write
// During measurement: no
cmdSetCO2AutoSelfCalibrationSEN63CSEN66SEN69C = command{
id: 0x6711,
execTime: 20 * time.Millisecond,
}
// I2C sequence type: Read
// During measurement: yes
cmdGetAmbientPressureSEN63CSEN66SEN69C = command{
id: 0x6720,
execTime: 20 * time.Millisecond,
rxDataLen: 3,
}
// I2C sequence type: Write
// During measurement: yes
cmdSetAmbientPressureSEN63CSEN66SEN69C = command{
id: 0x6720,
execTime: 20 * time.Millisecond,
}
// I2C sequence type: Read
// During measurement: no
cmdGetSensorAltitudeSEN63CSEN66SEN69C = command{
id: 0x6736,
execTime: 20 * time.Millisecond,
rxDataLen: 3,
}
// I2C sequence type: Write
// During measurement: no
cmdSetSensorAltitudeSEN63CSEN66SEN69C = command{
id: 0x6736,
execTime: 20 * time.Millisecond,
}
)

@ -0,0 +1,86 @@
// 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 (
"errors"
"fmt"
)
const (
crcInit byte = 0xff
crcPolynomial byte = 0x31
)
// crc8 computes the CRC-8-Dallas/Maxim checksum for a pair of data bytes.
func crc8(b0, b1 byte) byte {
crc := crcInit
for _, b := range [2]byte{b0, b1} {
crc ^= b
for i := 0; i < 8; i++ {
if crc&0x80 != 0 {
crc = (crc << 1) ^ crcPolynomial
} else {
crc <<= 1
}
}
}
return crc
}
// validateAndStripCRC validates the CRC byte after each 16-bit word in raw and returns
// the data bytes with CRC bytes removed. raw must have length divisible by 3 (each 16 bit
// word is followed by a CRC byte).
func validateAndStripCRC(raw []byte) ([]byte, error) {
if len(raw)%3 != 0 {
return nil, fmt.Errorf("sen6x: data length is not a multiple of 3: %d", len(raw))
}
data := make([]byte, 0, len(raw)/3*2)
for i := 0; i < len(raw); i += 3 {
if crc := crc8(raw[i], raw[i+1]); crc != raw[i+2] {
return nil, fmt.Errorf("sen6x: CRC mismatch at word %d: got %#02x, want %#02x", i/3, crc, raw[i+2])
}
data = append(data, raw[i], raw[i+1])
}
return data, nil
}
// packWordsWithCRC packs 16-bit words with their CRCs, ready for transmission
// to the device.
func packWordsWithCRC(words []uint16) []byte {
result := make([]byte, 0, len(words)*3)
for _, w := range words {
high := byte(w >> 8)
low := byte(w)
result = append(result, high, low, crc8(high, low))
}
return result
}
// packBytesWithCRC packs bytes with CRC values for each pair of bytes, ready
// for transmission to the device. The number of bytes in b must be even.
func packBytesWithCRC(b []byte) ([]byte, error) {
if len(b)%2 != 0 {
return nil, errors.New("sen6x: cannot pack bytes with CRC, number of bytes must be even")
}
result := make([]byte, 0, len(b)*3/2)
for i := 0; i < len(b); i += 2 {
// gosec emits CWE-118, "slice index out of range", for the two i+1
// indexes below. This is a false positive: we know that the length of
// b is even because of the check above. The loop body doesn't execute
// if b's length is 0, a length of 1 is impossible, 2 works fine, 3 is
// impossible, and so on.
// #nosec CWE-118
result = append(result, b[i], b[i+1], crc8(b[i], b[i+1]))
}
return result, nil
}

@ -0,0 +1,211 @@
// 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 (
"bytes"
"testing"
)
func TestCRC8(t *testing.T) {
cases := []struct {
name string
b0 byte
b1 byte
want byte
}{
{
// From the datasheet: crc8(0xbeef) = 0x92
name: "datasheet example",
b0: 0xbe,
b1: 0xef,
want: 0x92,
},
{
// All zeros.
name: "zeros",
b0: 0x00,
b1: 0x00,
want: 0x81,
},
{
// All ones.
name: "ones",
b0: 0xff,
b1: 0xff,
want: 0xac,
},
{
// Single bit in b0.
name: "single bit b0",
b0: 0x01,
b1: 0x00,
want: 0x75,
},
{
// Single bit in b1.
name: "single bit b1",
b0: 0x00,
b1: 0x01,
want: 0xb0,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := crc8(tc.b0, tc.b1)
if got != tc.want {
t.Errorf("got %#02x, want %#02x", got, tc.want)
}
})
}
}
func TestValidateAndStripCRC(t *testing.T) {
cases := []struct {
name string
raw []byte
want []byte
wantErr bool
}{
{
name: "single word",
// 0xbeef with CRC 0x92 from datasheet example.
raw: []byte{0xbe, 0xef, 0x92},
want: []byte{0xbe, 0xef},
},
{
name: "two words",
raw: []byte{0xbe, 0xef, 0x92, 0x00, 0x00, 0x81},
want: []byte{0xbe, 0xef, 0x00, 0x00},
},
{
name: "wrong CRC on first word",
raw: []byte{0xbe, 0xef, 0x00},
wantErr: true,
},
{
name: "wrong CRC on second word",
raw: []byte{0xbe, 0xef, 0x92, 0x00, 0x00, 0x00},
wantErr: true,
},
{
name: "length not multiple of 3",
raw: []byte{0xbe, 0xef},
wantErr: true,
},
{
name: "empty input",
raw: []byte{},
want: []byte{},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := validateAndStripCRC(tc.raw)
if err != nil && !tc.wantErr {
t.Fatalf("unexpected error: %v", err)
}
if err == nil && tc.wantErr {
t.Fatal("expected error, got nil")
}
if !tc.wantErr && !bytes.Equal(got, tc.want) {
t.Errorf("got %#v, want %#v", got, tc.want)
}
})
}
}
func TestPackWordsWithCRC(t *testing.T) {
cases := []struct {
name string
raw []uint16
want []byte
}{
{
name: "single word",
// 0xbeef with CRC of 0x92 from datasheet example.
raw: []uint16{0xbeef},
want: []byte{0xbe, 0xef, 0x92},
},
{
name: "two words",
raw: []uint16{0xbeef, 0x0000},
want: []byte{0xbe, 0xef, 0x92, 0x00, 0x00, 0x81},
},
{
name: "empty",
raw: []uint16{},
want: []byte{},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := packWordsWithCRC(tc.raw)
if !bytes.Equal(got, tc.want) {
t.Errorf("got %#v, want %#v", got, tc.want)
}
})
}
}
func TestPackBytesWithCRC(t *testing.T) {
cases := []struct {
name string
b []byte
want []byte
wantErr bool
}{
{
name: "single word",
// 0xbeef with CRC of 0x92 from datasheet example.
b: []byte{0xbe, 0xef},
want: []byte{0xbe, 0xef, 0x92},
},
{
name: "two words",
b: []byte{0xbe, 0xef, 0x00, 0x00},
want: []byte{0xbe, 0xef, 0x92, 0x00, 0x00, 0x81},
},
{
name: "empty",
b: []byte{},
want: []byte{},
},
{
name: "odd number of bytes",
b: []byte{0xbe},
wantErr: true,
},
{
name: "odd number of bytes, more than one",
b: []byte{0xbe, 0xef, 0x00},
wantErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := packBytesWithCRC(tc.b)
if err != nil && !tc.wantErr {
t.Fatalf("unexpected error: %v", err)
}
if err == nil && tc.wantErr {
t.Fatal("expected error, got nil")
}
if !tc.wantErr && !bytes.Equal(got, tc.want) {
t.Errorf("got %#v, want %#v", got, tc.want)
}
})
}
}

@ -0,0 +1,56 @@
// 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 controls the Sensirion SEN6x family of environmental sensors over I²C.
//
// # Details
//
// These sensors measure the following, in different combinations depending on the model:
// - Particulate matter (PM1.0, PM2.5, PM4, and PM10, with the addition of PM0.5
// from the Read Number Concentration Values command)
// - Relative humidity
// - Temperature
// - VOC
// - NOx
// - Formaldehyde (HCHO)
// - CO2
//
// Sensor model capabilities:
// - [SEN62]: PM, RH, T
// - [SEN63C]: PM, RH, T, CO2
// - [SEN65]: PM, RH, T, VOC, NOx
// - [SEN66]: PM, RH, T, VOC, NOx, CO2
// - [SEN68]: PM, RH, T, VOC, NOx, HCHO
// - [SEN69C]: PM, RH, T, VOC, NOx, HCHO, CO2
//
// All SEN6x sensors use a JST GH 1.25mm-pitch 6 pin connector (model number
// [GHR-06V-S], which uses connector model number [SSHL-002T-P0.2]).
//
// # Datasheet
//
// [Datasheet] for all SEN6x sensors. Also see ["What is Sensirion's VOC Index?"]
// and ["What is Sensirion's NOx Index?"].
//
// # Other resources
//
// Adafruit makes a nifty [breakout board] that bridges the SEN6x's JST GH connector
// with the standard STEMMA QT / Qwiic connector and includes a 3.3V regulator and
// level shifter so that the sensors work with either 3.3V or 5V power and logic.
// They also make a [cable] that works with the SEN6x, but you can of course make your
// own with the parts mentioned above.
//
// [SEN62]: https://sensirion.com/products/catalog/SEN62
// [SEN63C]: https://sensirion.com/products/catalog/SEN63C
// [SEN65]: https://sensirion.com/products/catalog/SEN65
// [SEN66]: https://sensirion.com/products/catalog/SEN66
// [SEN68]: https://sensirion.com/products/catalog/SEN68
// [SEN69C]: https://sensirion.com/products/catalog/SEN69C
// [GHR-06V-S]: https://www.digikey.com/en/products/detail/jst-sales-america-inc/GHR-06V-S/807818
// [SSHL-002T-P0.2]: https://www.digikey.com/en/products/detail/jst-sales-america-inc/SSHL-002T-P0-2/27687535
// [Datasheet]: https://sensirion.com/media/documents/FAFC548D/693FBB15/PS_DS_SEN6x.pdf
// ["What is Sensirion's VOC Index?"]: https://sensirion.com/media/documents/02232963/6294E043/Info_Note_VOC_Index.pdf
// ["What is Sensirion's NOx Index?"]: https://sensirion.com/media/documents/9F289B95/6294DFFC/Info_Note_NOx_Index.pdf
// [breakout board]: https://www.adafruit.com/product/6331
// [cable]: https://www.adafruit.com/product/5754
package sen6x

@ -0,0 +1,68 @@
// 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.
//go:generate -command stringer go run golang.org/x/tools/cmd/stringer@latest
//go:generate stringer -type=Model
package sen6x
// Model represents the various sensor models in the SEN6x family.
type Model int
const (
SEN62 Model = iota
SEN63C
SEN65
SEN66
SEN68
SEN69C
)
// hasCO2 returns true if the model has a CO2 sensor.
//
// Some CO2 capabilities behave differently on SEN63C and SEN69C than they do on
// SEN66. This package abstracts away the differences where possible and documents
// them otherwise.
//
// Based on the description in [datasheet] section 4.8.32, Perform CO2 Sensor Factory
// Reset, it would appear that SEN63C, SEN66, and SEN69C all use the [STCC4] CO2
// sensor. It seems, then, that it is the SEN6x firmware that accounts for
// the CO2 differences.
//
// However, section 1.5 of the [datasheet], CO2 Specifications, shows different
// specs for SEN63C and SEN69C vs. SEN66. One would expect the same specs if the
// same sensor were used. So it's not totally clear how the internals differ.
//
// Regardless, you'll notice that in some locations in the code we check sensor
// model to differentiate CO2-related actions.
//
// [datasheet]: https://sensirion.com/media/documents/FAFC548D/693FBB15/PS_DS_SEN6x.pdf
// [STCC4]: https://sensirion.com/media/documents/6AED4B15/69295E41/CD_DS_STCC4_D1.pdf
func (m Model) hasCO2() bool {
switch m {
case SEN63C, SEN66, SEN69C:
return true
}
return false
}
// hasVOCNOx returns true if the model has VOC and NOx sensors.
func (m Model) hasVOCNOx() bool {
switch m {
case SEN65, SEN66, SEN68, SEN69C:
return true
}
return false
}
// hasHCHO returns true if the model has a formaldehyde sensor.
func (m Model) hasHCHO() bool {
switch m {
case SEN68, SEN69C:
return true
}
return false
}

@ -0,0 +1,29 @@
// Code generated by "stringer -type=Model"; DO NOT EDIT.
package sen6x
import "strconv"
func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_ = x[SEN62-0]
_ = x[SEN63C-1]
_ = x[SEN65-2]
_ = x[SEN66-3]
_ = x[SEN68-4]
_ = x[SEN69C-5]
}
const _Model_name = "SEN62SEN63CSEN65SEN66SEN68SEN69C"
var _Model_index = [...]uint8{0, 5, 11, 16, 21, 26, 32}
func (i Model) String() string {
idx := int(i) - 0
if i < 0 || idx >= len(_Model_index)-1 {
return "Model(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _Model_name[_Model_index[idx]:_Model_index[idx+1]]
}

@ -0,0 +1,54 @@
// 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 "encoding/binary"
// PMNumberConcentrations contains particulate matter measurements in particles/cm³ ("number concentration").
type PMNumberConcentrations struct {
PM05, PM1, PM25, PM4, PM10 *uint16
}
// ReadNumberConcentrationValues reads the current particulate matter concentration
// measurements in particles/cm³ ("number concentration") rather than the more
// conventional μg/m³.
//
// [Dev.GetDataReady] may be used to check if new data is available since the
// last read operation. If no new data is available, the previous values will
// be returned. If no data is available at all (e.g. measurement not running
// for at least one second), all values will be nil.
func (d *Dev) ReadNumberConcentrationValues() (*PMNumberConcentrations, error) {
d.mu.Lock()
defer d.mu.Unlock()
data, err := d.writeAndRead(cmdReadNumberConcentrationValues, nil)
if err != nil {
return nil, err
}
nc := &PMNumberConcentrations{}
if rawPM05 := binary.BigEndian.Uint16(data[0:2]); rawPM05 != 0xffff {
nc.PM05 = ptr(rawPM05)
}
if rawPM1 := binary.BigEndian.Uint16(data[2:4]); rawPM1 != 0xffff {
nc.PM1 = ptr(rawPM1)
}
if rawPM25 := binary.BigEndian.Uint16(data[4:6]); rawPM25 != 0xffff {
nc.PM25 = ptr(rawPM25)
}
if rawPM4 := binary.BigEndian.Uint16(data[6:8]); rawPM4 != 0xffff {
nc.PM4 = ptr(rawPM4)
}
if rawPM10 := binary.BigEndian.Uint16(data[8:10]); rawPM10 != 0xffff {
nc.PM10 = ptr(rawPM10)
}
return nc, nil
}

@ -0,0 +1,63 @@
// 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"
)
func TestDevReadNumberConcentrationValues(t *testing.T) {
cmd := []byte{0x03, 0x16}
cases := []writeAndReadTestCase[*PMNumberConcentrations]{
{
name: "all values set",
model: SEN66,
tx: cmd,
rx: []byte{
0x00, 0x0c, 0xfc, // PM0.5
0x00, 0x0f, 0xaf, // PM1.0
0x00, 0x0f, 0xaf, // PM2.5
0x00, 0x0f, 0xaf, // PM4.0
0x00, 0x0f, 0xaf, // PM10.0
},
want: &PMNumberConcentrations{
PM05: ptr(uint16(12)),
PM1: ptr(uint16(15)),
PM25: ptr(uint16(15)),
PM4: ptr(uint16(15)),
PM10: ptr(uint16(15)),
},
},
{
name: "all values unknown",
model: SEN66,
tx: cmd,
rx: []byte{
0xff, 0xff, 0xac, // PM0.5
0xff, 0xff, 0xac, // PM1.0
0xff, 0xff, 0xac, // PM2.5
0xff, 0xff, 0xac, // PM4.0
0xff, 0xff, 0xac, // PM10.0
},
want: &PMNumberConcentrations{},
},
{
name: "bad crc",
model: SEN66,
tx: cmd,
rx: []byte{
0x00, 0x0c, 0xfc, // PM0.5
0x00, 0x0f, 0xaf, // PM1.0
0x00, 0x0f, 0xff, // PM2.5 with incorrect CRC (should be 0xaf)
0x00, 0x0f, 0xaf, // PM4.0
0x00, 0x0f, 0xaf, // PM10.0
},
wantErr: true,
},
}
runWriteAndReadTests(t, cases, (*Dev).ReadNumberConcentrationValues)
}

@ -0,0 +1,206 @@
// 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 "encoding/binary"
type TemperatureOffsetParameters struct {
// Offset is the constant temperature factor in °C.
Offset int16
// Slope is the temperature offset slope.
Slope int16
// TimeConstant determines how fast the new slope and offset will be applied.
//
// After the specified value in seconds, 63% of the new slope and offset are
// applied. A time constant of zero means the new values will be applied
// immediately (within the next measure interval of 1 second).
TimeConstant uint16
// Slot is the temperature offset slot to be modified. Valid values are in [0, 4].
// If the value is outside this range, the parameters will not be applied.
//
// A total of five slots are available. Each slot represents one temperature
// offset. Usually slot 0 is used to compensate for the base self-heating and
// the other slots allow compensation for additional heating of components like
// screens, Wi-Fi modules, etc. that can be switched on and off independently.
Slot uint16
}
func (params TemperatureOffsetParameters) pack() []byte {
return packWordsWithCRC([]uint16{
uint16(params.Offset * 200),
uint16(params.Slope * 10000),
params.TimeConstant,
params.Slot,
})
}
type TemperatureAccelerationParameters struct {
// Filter constant K.
K uint16
// Filter constant P.
P uint16
// Time constant T1.
T1 uint16
// Time constant T2.
T2 uint16
}
func (params TemperatureAccelerationParameters) pack() []byte {
return packWordsWithCRC([]uint16{
params.K * 10,
params.P * 10,
params.T1 * 10,
params.T2 * 10,
})
}
type SHTHeaterMeasurements struct {
// If SHT sensor heating is completed, RH indicates the relative humidity
// of the SHT4x sensor.
RH *float32
// If SHT sensor heating is completed, Temp indicates the temperature in °C
// of the SHT4x sensor.
Temp *float32
}
// SetTemperatureOffsetParameters allows for compensation of temperature effects
// due to the design-in of the sensor by applying custom offsets to the ambient
// temperature.
//
// The compensated ambient temperature is calculated as follows:
//
// T_compensated = T_measured + (Slope * T_measured) + C_offset
//
// where Slope and C_offset are set with this command, smoothed with the given
// time constant. All temperatures (T_compensated, T_measured, and C_offset)
// are represented in °C.
//
// There are 5 temperature offset slots available that all contribute additively
// to T_compensated. The default values for the temperature offset parameters are
// all zero, meaning that T_compensated is equal to T_measured by default.
//
// Note: This configuration is volatile, i.e. the parameters will be reverted to
// their default value of zero after a device reset.
//
// For more details on how to compensate the temperature on the SEN6x platform,
// refer to ["SEN6x Temperature Acceleration and Compensation Instructions"].
//
// ["SEN6x Temperature Acceleration and Compensation Instructions"]: https://sensirion.com/media/documents/C964FCC8/69709EC3/PS_AN_SEN6x_Temperature_Compensation_and_Acceleration_Application_No.pdf
func (d *Dev) SetTemperatureOffsetParameters(params TemperatureOffsetParameters) error {
d.mu.Lock()
defer d.mu.Unlock()
return d.writeAndWait(cmdSetTemperatureOffsetParams, params.pack())
}
// SetTemperatureAccelerationParameters sets custom RH/T engine temperature
// acceleration parameters.
//
// It overwrites the RH/T engine's default temperature acceleration parameters
// with custom values.
//
// Note: This configuration is volatile, i.e. the parameters will be reverted to
// their default values after a device reset.
//
// For more details on how to compensate the temperature on the SEN6x platform,
// refer to ["SEN6x Temperature Acceleration and Compensation Instructions"].
//
// ["SEN6x Temperature Acceleration and Compensation Instructions"]: https://sensirion.com/media/documents/C964FCC8/69709EC3/PS_AN_SEN6x_Temperature_Compensation_and_Acceleration_Application_No.pdf
func (d *Dev) SetTemperatureAccelerationParameters(params TemperatureAccelerationParameters) error {
d.mu.Lock()
defer d.mu.Unlock()
return d.writeAndWait(cmdSetTemperatureAccelParams, params.pack())
}
// ActivateSHTHeater activates the SHT sensor's built-in heater to reverse
// humidity creep at high humidity. It activates the heater at 200 mW for 1 s,
// after which the heater is deactivated.
//
// "SHT" refers to the SHT4x family of relative humidity and temperature sensors.
// See the [SHT4x datasheet].
//
// For firmware versions listed below, [Dev.GetSHTHeaterMeasurements] may be polled
// to check whether heating is finished in order to trigger another cycle and maximize
// the duty cycle. Older firmware versions do not support [Dev.GetSHTHeaterMeasurements].
//
// Wait at least 20 s after this command before starting a measurement to get
// coherent temperature values (i.e. heating consequence to disappear).
//
// The following firmware versions have a command execution time of 20 ms and
// support [Dev.GetSHTHeaterMeasurements]. Older firmware versions have an execution
// time of 1300 ms:
// - SEN62 >= 6.0
// - SEN63C >= 5.0
// - SEN65 >= 5.0
// - SEN66 >= 4.0
// - SEN68 >= 7.0
// - SEN69C >= 9.0
//
// In this package the execution time for ActivateSHTHeater is set to 20 ms, so if
// you're using a device with firmware version below those listed above then you
// should wait an additional 1280 ms after calling this method.
//
// For more information on humidity creep, see the application note
// ["Using the Integrated Heater of SHT4x in High-Humidity Environments"].
//
// ["Using the Integrated Heater of SHT4x in High-Humidity Environments"]: https://sensirion.com/media/documents/A88858C9/629626D4/Application_Note_Creep_Mitigation_SHT4x.pdf
// [SHT4x datasheet]: https://sensirion.com/media/documents/33FD6951/67EB9032/HT_DS_Datasheet_SHT4x_5.pdf
func (d *Dev) ActivateSHTHeater() error {
d.mu.Lock()
defer d.mu.Unlock()
return d.writeAndWait(cmdActivateSHTHeater, nil)
}
// GetSHTHeaterMeasurements gets the measurement values when SHT sensor heating is finished.
//
// "SHT" refers to the SHT4x family of relative humidity and temperature sensors.
// See the [SHT4x datasheet].
//
// This command is available only in these firmware versions:
// - SEN62 >= 6.0
// - SEN63C >= 5.0
// - SEN65 >= 5.0
// - SEN66 >= 4.0
// - SEN68 >= 7.0
// - SEN69C >= 9.0
//
// It must be used after [Dev.ActivateSHTHeater]. It may be polled every 50 ms to
// check if the heating cycle is finished and measurements are available.
//
// For more information on humidity creep, see the application note
// ["Using the Integrated Heater of SHT4x in High-Humidity Environments"].
//
// ["Using the Integrated Heater of SHT4x in High-Humidity Environments"]: https://sensirion.com/media/documents/A88858C9/629626D4/Application_Note_Creep_Mitigation_SHT4x.pdf
// [SHT4x datasheet]: https://sensirion.com/media/documents/33FD6951/67EB9032/HT_DS_Datasheet_SHT4x_5.pdf
func (d *Dev) GetSHTHeaterMeasurements() (*SHTHeaterMeasurements, error) {
d.mu.Lock()
defer d.mu.Unlock()
data, err := d.writeAndRead(cmdGetSHTHeaterMeasurements, nil)
if err != nil {
return nil, err
}
hm := &SHTHeaterMeasurements{}
if rawRH := binary.BigEndian.Uint16(data[0:2]); rawRH != 0x7fff {
hm.RH = ptr(float32(rawRH) / 100.0)
}
if rawTemp := int16(binary.BigEndian.Uint16(data[2:4])); rawTemp != 0x7fff {
hm.Temp = ptr(float32(rawTemp) / 200.0)
}
return hm, nil
}

@ -0,0 +1,117 @@
// 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"
)
func TestDevSetTemperatureOffsetParameters(t *testing.T) {
cases := []writeTestCase{
{
name: "success",
model: SEN66,
tx: []byte{
0x60, 0xb2, // Command
0x01, 0x90, 0x4c, // Offset
0x75, 0x30, 0x08, // Slope
0x00, 0x0a, 0x5a, // Time constant
0x00, 0x01, 0xb0, // Slot
},
},
}
params := TemperatureOffsetParameters{
Offset: 2,
Slope: 3,
TimeConstant: 10,
Slot: 1,
}
runWriteTests(t, cases, func(d *Dev) error {
return d.SetTemperatureOffsetParameters(params)
})
}
func TestDevSetTemperatureAccelerationParameters(t *testing.T) {
cases := []writeTestCase{
{
name: "success",
model: SEN66,
tx: []byte{
0x61, 0x00, // Command
0x00, 0x0a, 0x5a, // K
0x00, 0x14, 0x06, // P
0x00, 0x1e, 0xdd, // T1
0x00, 0x28, 0xbe, // T2
},
},
}
params := TemperatureAccelerationParameters{
K: 1,
P: 2,
T1: 3,
T2: 4,
}
runWriteTests(t, cases, func(d *Dev) error {
return d.SetTemperatureAccelerationParameters(params)
})
}
func TestDevActivateSHTHeater(t *testing.T) {
cases := []writeTestCase{
{
name: "success",
model: SEN66,
tx: []byte{0x67, 0x65},
},
}
runWriteTests(t, cases, (*Dev).ActivateSHTHeater)
}
func TestDevGetSHTHeaterMeasurements(t *testing.T) {
cmd := []byte{0x67, 0x90}
cases := []writeAndReadTestCase[*SHTHeaterMeasurements]{
{
name: "all values set",
model: SEN66,
tx: cmd,
rx: []byte{
0x09, 0xf0, 0xc0, // RH
0x33, 0xbb, 0x4b, // Temp
},
want: &SHTHeaterMeasurements{
RH: ptr(float32(25.44)),
Temp: ptr(float32(66.215)),
},
},
{
name: "all values unknown",
model: SEN66,
tx: cmd,
rx: []byte{
0x7f, 0xff, 0x8f, // RH
0x7f, 0xff, 0x8f, // Temp
},
want: &SHTHeaterMeasurements{},
},
{
name: "bad crc",
model: SEN66,
tx: cmd,
rx: []byte{
0x09, 0xf0, 0xff, // RH with incorrect CRC (should be 0xc0)
0x33, 0xbb, 0x4b, // Temp
},
wantErr: true,
},
}
runWriteAndReadTests(t, cases, (*Dev).GetSHTHeaterMeasurements)
}

@ -0,0 +1,530 @@
// 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 (
"encoding/binary"
"errors"
"fmt"
"log"
"sync"
"time"
"periph.io/x/conn/v3"
"periph.io/x/conn/v3/i2c"
)
const i2cAddr = 0x6b
// deviceSamplingInterval is the sensor's native sampling interval. The
// datasheet specifies a sampling interval of 1 ± 0.03 seconds so we take
// the maximum value to be safe.
var deviceSamplingInterval = 1030 * time.Millisecond
// SensorValues contains the measurements returned from the device.
// Any values not relevant to the sensor model being used will be nil.
// Any values equal to the device's "unknown" sentinel value (0x7fff or 0xfff
// depending on data type) will also be nil.
type SensorValues struct {
// Particulate matter measurements in μg/m³.
PM1, PM25, PM4, PM10 *float32
// Relative humidity.
RH *float32
// Temp in °C.
Temp *float32
// VOC level in terms of Sensirion's VOC index.
VOC *float32
// NOx level in terms of Sensirion's NOx index.
NOx *float32
// CO2 concentration in ppm.
CO2 *int16
// Formaldehyde (HCHO) concentration in ppb.
HCHO *float32
}
// RawSensorValues contains the raw measurements returned from the device.
// Any values not relevant to the sensor model being used will be nil.
// Any values equal to the device's "unknown" sentinel value (0x7fff or 0xfff
// depending on data type) will also be nil.
type RawSensorValues struct {
// Raw measured relative humidity.
RH *float32
// Raw measured temp in °C.
Temp *float32
// Raw measured VOC ticks without scale factor.
VOC *uint16
// Raw measured NOx ticks without scale factor.
NOx *uint16
// Non-interpolated CO2 concentration in ppm updated every five seconds.
//
// NOTE: This is only applicable to SEN66. While SEN63C and SEN69C also have
// CO2 sensors, only SEN66 returns raw CO2 measurements.
CO2 *uint16
}
// Dev represents a SEN6x sensor.
type Dev struct {
dev *i2c.Dev
model Model
// Sleep function that can be redefined for tests.
// Defaults to time.Sleep.
sleep func(time.Duration)
mu sync.Mutex
stop chan struct{}
wg sync.WaitGroup
}
// New creates a new SEN6x device.
func New(bus i2c.Bus, model Model) *Dev {
return &Dev{
dev: &i2c.Dev{Bus: bus, Addr: i2cAddr},
model: model,
sleep: time.Sleep,
}
}
func (d *Dev) String() string {
return d.model.String()
}
// SenseContinuous puts the sensor in measurement mode and sends measurements over
// the returned channel at the given interval. Call [Dev.Halt] to stop measurement
// and ensure resources are cleaned up (e.g. that the channel is closed and goroutines
// are stopped).
//
// After starting the measurement it takes some time (~1.1 s) until the first
// measurement results are available.
//
// It's the responsibility of the caller to retrieve the values from the
// channel as fast as possible, otherwise the interval may not be respected.
//
// Note on the interval: The sensor's internal measurement interval is 1 ± 0.03
// seconds, so an interval value less than that duration will return values at
// the device's native interval. Higher interval values will work as expected.
//
// Note for SEN63C and SEN69C only: SEN63C and SEN69C condition the CO2 sensor
// during the first 24 seconds after starting a measurement. As this process
// cannot be interrupted, the following limitations apply during this period:
// - You may stop the measurement if needed, but do not start it again until
// at least 24 seconds have passed to avoid a CO2 sensor error.
// - Do not stop the sensor and call [Dev.PerformForcedCO2Recalibration],
// [Dev.SetCO2SensorAutomaticSelfCalibration], or [Dev.PerformCO2SensorFactoryReset].
func (d *Dev) SenseContinuous(interval time.Duration) (<-chan *SensorValues, error) {
d.mu.Lock()
defer d.mu.Unlock()
if d.stop != nil {
return nil, errors.New("sen6x: already sensing continuously")
}
results := make(chan *SensorValues)
d.stop = make(chan struct{})
d.wg.Add(1)
go func() {
defer d.wg.Done()
defer close(results)
d.doSenseContinuous(interval, results, d.stop)
}()
return results, nil
}
func (d *Dev) doSenseContinuous(interval time.Duration, results chan<- *SensorValues, stop <-chan struct{}) {
t := time.NewTicker(interval)
defer t.Stop()
if err := d.StartContinuousMeasurement(); err != nil {
log.Printf("sen6x: failed to start continuous measurement: %v", err)
return
}
for {
d.mu.Lock()
if interval < deviceSamplingInterval {
if err := d.waitOnDataReady(stop); err != nil {
d.mu.Unlock()
log.Printf("sen6x: failed to check if data is ready: %v", err)
return
}
}
sv, err := d.doReadMeasuredValues()
d.mu.Unlock()
if err != nil {
log.Printf("sen6x: failed to read measured values: %v", err)
return
}
select {
case results <- sv:
case <-stop:
return
}
select {
case <-stop:
return
case <-t.C:
}
}
}
func (d *Dev) waitOnDataReady(stop <-chan struct{}) error {
t := time.NewTicker(300 * time.Millisecond)
defer t.Stop()
for {
ready, err := d.doGetDataReady()
if err != nil {
return err
}
if ready {
return nil
}
select {
case <-stop:
return nil
case <-t.C:
}
}
}
// Halt halts continuous sensing, cleans up resources, and puts the sensor in idle mode.
func (d *Dev) Halt() error {
d.mu.Lock()
defer d.mu.Unlock()
if d.stop == nil {
return nil
}
close(d.stop)
d.stop = nil
d.wg.Wait()
return d.writeAndWait(cmdStopMeasurement, nil)
}
// StartContinuousMeasurement starts a continuous measurement. After starting the
// measurement, it takes some time (~1.1 s) until the first measurement results are
// available.
//
// You may poll [Dev.GetDataReady] to check if results are ready to be read.
//
// Note for SEN63C and SEN69C only: SEN63C and SEN69C condition the CO2 sensor
// during the first 24 seconds after starting a measurement. As this process
// cannot be interrupted, the following limitations apply during this period:
// - You may stop the measurement if needed, but do not start it again until
// at least 24 seconds have passed to avoid a CO2 sensor error.
// - Do not stop the sensor and call [Dev.PerformForcedCO2Recalibration],
// [Dev.SetCO2SensorAutomaticSelfCalibration], or [Dev.PerformCO2SensorFactoryReset].
func (d *Dev) StartContinuousMeasurement() error {
d.mu.Lock()
defer d.mu.Unlock()
return d.writeAndWait(cmdStartContinuousMeasurement, nil)
}
// StopMeasurement stops the measurement and returns the sensor to idle mode.
// After sending this command, wait at least 1400 ms before starting a new measurement.
func (d *Dev) StopMeasurement() error {
d.mu.Lock()
defer d.mu.Unlock()
return d.writeAndWait(cmdStopMeasurement, nil)
}
// GetDataReady checks if new measurement results are ready to read. The data ready
// flag is automatically reset after reading the measurement values.
func (d *Dev) GetDataReady() (bool, error) {
d.mu.Lock()
defer d.mu.Unlock()
return d.doGetDataReady()
}
func (d *Dev) doGetDataReady() (bool, error) {
data, err := d.writeAndRead(cmdGetDataReady, nil)
if err != nil {
return false, err
}
return data[1] == 1, nil
}
// ReadMeasuredValues reads the current measured values. Measurement must have
// already been started by [Dev.StartContinuousMeasurement]. After starting the
// measurement, it takes some time (~1.1 s) until the first measurement results
// are available.
//
// [Dev.GetDataReady] may be polled to check if new data is available since the
// last read operation. If no new data is available, the previous values will
// be returned. If no data is available at all for a particular measurement (e.g.
// measurement not running for at least one second), it will be nil.
//
// Any values that aren't applicable to the Dev's sensor model will be nil.
func (d *Dev) ReadMeasuredValues() (*SensorValues, error) {
d.mu.Lock()
defer d.mu.Unlock()
return d.doReadMeasuredValues()
}
func (d *Dev) doReadMeasuredValues() (*SensorValues, error) {
var cmd command
switch d.model {
case SEN62:
cmd = cmdReadMeasuredValuesSEN62
case SEN63C:
cmd = cmdReadMeasuredValuesSEN63C
case SEN65:
cmd = cmdReadMeasuredValuesSEN65
case SEN66:
cmd = cmdReadMeasuredValuesSEN66
case SEN68:
cmd = cmdReadMeasuredValuesSEN68
case SEN69C:
cmd = cmdReadMeasuredValuesSEN69C
default:
return nil, fmt.Errorf("sen6x: unknown model: %v", d.model)
}
data, err := d.writeAndRead(cmd, nil)
if err != nil {
return nil, err
}
sv := &SensorValues{}
if rawPM1 := binary.BigEndian.Uint16(data[0:2]); rawPM1 != 0xffff {
sv.PM1 = ptr(float32(rawPM1) / 10.0)
}
if rawPM25 := binary.BigEndian.Uint16(data[2:4]); rawPM25 != 0xffff {
sv.PM25 = ptr(float32(rawPM25) / 10.0)
}
if rawPM4 := binary.BigEndian.Uint16(data[4:6]); rawPM4 != 0xffff {
sv.PM4 = ptr(float32(rawPM4) / 10.0)
}
if rawPM10 := binary.BigEndian.Uint16(data[6:8]); rawPM10 != 0xffff {
sv.PM10 = ptr(float32(rawPM10) / 10.0)
}
if rawRH := int16(binary.BigEndian.Uint16(data[8:10])); rawRH != 0x7fff {
sv.RH = ptr(float32(rawRH) / 100.0)
}
if rawTemp := int16(binary.BigEndian.Uint16(data[10:12])); rawTemp != 0x7fff {
sv.Temp = ptr(float32(rawTemp) / 200.0)
}
i := 12
if d.model.hasVOCNOx() {
if rawVOC := int16(binary.BigEndian.Uint16(data[i : i+2])); rawVOC != 0x7fff {
sv.VOC = ptr(float32(rawVOC) / 10.0)
}
i += 2
if rawNOx := int16(binary.BigEndian.Uint16(data[i : i+2])); rawNOx != 0x7fff {
sv.NOx = ptr(float32(rawNOx) / 10.0)
}
i += 2
}
if d.model.hasHCHO() {
if rawHCHO := binary.BigEndian.Uint16(data[i : i+2]); rawHCHO != 0xffff {
sv.HCHO = ptr(float32(rawHCHO) / 10.0)
}
i += 2
}
if d.model.hasCO2() {
if d.model == SEN66 {
// SEN66 encodes CO2 concentration as a uint16.
if rawCO2 := binary.BigEndian.Uint16(data[i : i+2]); rawCO2 != 0xffff {
sv.CO2 = ptr(int16(rawCO2))
}
} else {
// SEN63C and SEN69C encode CO2 concentration as an int16.
if rawCO2 := int16(binary.BigEndian.Uint16(data[i : i+2])); rawCO2 != 0x7fff {
sv.CO2 = ptr(rawCO2)
}
}
}
return sv, nil
}
// ReadMeasuredRawValues reads the current raw measured values.
//
// Any values that aren't applicable to Dev's sensor model will be nil.
//
// [Dev.GetDataReady] may be used to check if new data is available since the
// last read operation. If no new data is available, the previous values will
// be returned. If no data is available at all (e.g. measurement not running
// for at least one second), all values will be nil.
func (d *Dev) ReadMeasuredRawValues() (*RawSensorValues, error) {
d.mu.Lock()
defer d.mu.Unlock()
var cmd command
switch d.model {
case SEN62, SEN63C:
cmd = cmdReadMeasuredRawValuesSEN62SEN63C
case SEN65, SEN68, SEN69C:
cmd = cmdReadMeasuredRawValuesSEN65SEN68SEN69C
case SEN66:
cmd = cmdReadMeasuredRawValuesSEN66
default:
return nil, fmt.Errorf("sen6x: unknown model: %v", d.model)
}
data, err := d.writeAndRead(cmd, nil)
if err != nil {
return nil, err
}
rv := &RawSensorValues{}
if rawRH := int16(binary.BigEndian.Uint16(data[0:2])); rawRH != 0x7fff {
rv.RH = ptr(float32(rawRH) / 100)
}
if rawTemp := int16(binary.BigEndian.Uint16(data[2:4])); rawTemp != 0x7fff {
rv.Temp = ptr(float32(rawTemp) / 200)
}
i := 4
if d.model.hasVOCNOx() {
if rawVOC := binary.BigEndian.Uint16(data[i : i+2]); rawVOC != 0xffff {
rv.VOC = ptr(rawVOC)
}
i += 2
if rawNOx := binary.BigEndian.Uint16(data[i : i+2]); rawNOx != 0xffff {
rv.NOx = ptr(rawNOx)
}
i += 2
}
// We check specifically for SEN66 here instead of using d.model.hasCO2()
// because while SEN63C and SEN69C also have CO2 sensors, only SEN66 returns
// raw CO2 measurements.
if d.model == SEN66 {
if rawCO2 := binary.BigEndian.Uint16(data[i : i+2]); rawCO2 != 0xffff {
rv.CO2 = ptr(rawCO2)
}
}
return rv, nil
}
// GetProductName gets the product name from the device.
func (d *Dev) GetProductName() (string, error) {
d.mu.Lock()
defer d.mu.Unlock()
data, err := d.writeAndRead(cmdGetProductName, nil)
if err != nil {
return "", err
}
return string(data[:clen(data)]), nil
}
// GetSerialNumber gets the serial number from the device.
func (d *Dev) GetSerialNumber() (string, error) {
d.mu.Lock()
defer d.mu.Unlock()
data, err := d.writeAndRead(cmdGetSerialNumber, nil)
if err != nil {
return "", err
}
return string(data[:clen(data)]), nil
}
// GetVersion gets the firmware version, returning the major and minor version numbers.
func (d *Dev) GetVersion() (uint8, uint8, error) {
d.mu.Lock()
defer d.mu.Unlock()
data, err := d.writeAndRead(cmdGetVersion, nil)
if err != nil {
return 0, 0, err
}
return data[0], data[1], nil
}
// DeviceReset executes a reset on the device. This has the same effect as a power cycle.
func (d *Dev) DeviceReset() error {
d.mu.Lock()
defer d.mu.Unlock()
return d.writeAndWait(cmdDeviceReset, nil)
}
// StartFanCleaning triggers fan cleaning. The fan is set to the maximum speed for
// 10 seconds and then automatically stopped. Wait at least 10s after this command
// before starting a measurement.
func (d *Dev) StartFanCleaning() error {
d.mu.Lock()
defer d.mu.Unlock()
return d.writeAndWait(cmdStartFanCleaning, nil)
}
// writeAndWait writes a command followed by optional txData and
// waits for its execution time.
func (d *Dev) writeAndWait(cmd command, txData []byte) error {
buf := make([]byte, 2+len(txData))
buf[0] = byte(cmd.id >> 8)
buf[1] = byte(cmd.id)
copy(buf[2:], txData)
if err := d.dev.Tx(buf[:], nil); err != nil {
return err
}
d.sleep(cmd.execTime)
return nil
}
// writeAndRead writes a command followed by optional txData, waits for
// the command's execution time, and then reads the response.
func (d *Dev) writeAndRead(cmd command, txData []byte) ([]byte, error) {
if err := d.writeAndWait(cmd, txData); err != nil {
return nil, err
}
read := make([]byte, cmd.rxDataLen)
if err := d.dev.Tx(nil, read); err != nil {
return nil, err
}
rxData, err := validateAndStripCRC(read)
if err != nil {
return nil, err
}
return rxData, nil
}
var _ conn.Resource = &Dev{}

@ -0,0 +1,808 @@
// 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)
}

@ -0,0 +1,190 @@
// 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 "encoding/binary"
// Status represents the status of the sensor's various components, indicating
// whether or not each is in an error state.
//
// If a status value is sticky then it remains set even if the error disappears
// or if the sensor leaves measurement mode. The status will only be reset by
// [Dev.ReadAndClearDeviceStatus] or by a reset, either by calling [Dev.DeviceReset]
// or power cycling the sensor.
type Status struct {
// FanSpeedErr is the fan speed error status. A value of true indicates that the fan
// speed is too high or too low.
//
// Applies to: SEN62, SEN63C, SEN65, SEN66, SEN68, SEN69C
//
// Sticky: No
//
// Description from the datasheet:
//
// Fan is switched on, but its speed is more than 10% off the target speed
// for multiple consecutive measurement intervals. During the first 10 seconds
// after starting the measurement, the fan speed is not checked (settling time).
// Very low or very high ambient temperature could trigger this warning during
// startup. If this flag is set constantly, it might indicate a problem with
// the power supply or with the fan, and the measured PM values might be wrong.
// This flag is automatically cleared as soon as the measured speed is within 10%
// of the target speed or when leaving the measurement mode.
// Can occur only in measurement mode.
FanSpeedErr bool
// FanErr is the fan error status.
//
// Applies to: SEN62, SEN63C, SEN65, SEN66, SEN68, SEN69C
//
// Sticky: Yes
//
// Description from the datasheet:
//
// Fan is switched on, but 0 RPM is measured for multiple consecutive measurement
// intervals. This can occur if the fan is mechanically blocked or broken. Note
// that the measured values are most likely wrong if this error is reported.
// Can occur only in measurement mode.
FanErr bool
// PMSensorErr is the particulate matter sensor error status.
//
// Applies to: SEN62, SEN63C, SEN65, SEN66, SEN68, SEN69C
//
// Sticky: Yes
//
// Description from the datasheet:
//
// Error related to the PM sensor. The particulate matter values might be unknown
// or wrong if this flag is set, [and] relative humidity and temperature values
// might be out of specs due to compensation algorithms depending on PM sensor state.
// Can occur only in measurement mode.
PMSensorErr bool
// RHTSensorErr is the relative humidity and temperature sensor error status.
//
// Applies to: SEN62, SEN63C, SEN65, SEN66, SEN68, SEN69C
//
// Sticky: Yes
//
// Description from the datasheet:
//
// Error related to the RH&T sensor. The temperature and humidity values might be
// unknown or wrong if this flag is set, and other measured values might be out of
// specs due compensation algorithms depending on RH&T sensor values.
// Can occur only in measurement mode.
RHTSensorErr bool
// CO2SensorErr is the CO2 sensor error status.
//
// Applies to: SEN63C, SEN66, SEN69C
//
// Sticky: Yes
//
// Description from the datasheet:
//
// Error related to the CO2 sensor. The CO2 values might be unknown or wrong if
// this flag is set, [and] relative humidity and temperature values might be out of
// specs due to compensation algorithms depending on CO2 sensor state.
// Can occur only in measurement mode.
CO2SensorErr bool
// GasSensorErr is the gas (VOC and NOx) sensor error status.
//
// Applies to: SEN65, SEN66, SEN68, SEN69C
//
// Sticky: Yes
//
// Description from the datasheet:
//
// Error related to the gas sensor. The VOC index and NOx index might be unknown
// or wrong if this flag is set, [and] relative humidity and temperature values
// might be out of specs due to compensation algorithms depending on gas sensor state.
// Can occur only in measurement mode.
GasSensorErr bool
// HCHOSensorErr is the formaldehyde sensor error status.
//
// Applies to: SEN68, SEN69C
//
// Sticky: Yes
//
// Description from the datasheet:
//
// Error related to the formaldehyde sensor. The formaldehyde values might be
// unknown or wrong if this flag is set, [and] relative humidity and temperature
// values might be out of specs due to compensation algorithms depending on
// formaldehyde sensor state.
// Can occur only in measurement mode.
HCHOSensorErr bool
}
func (s *Status) AnyErr() bool {
return s.FanSpeedErr ||
s.FanErr ||
s.PMSensorErr ||
s.RHTSensorErr ||
s.CO2SensorErr ||
s.GasSensorErr ||
s.HCHOSensorErr
}
// ReadDeviceStatus reads and decodes the current value of the device status register.
func (d *Dev) ReadDeviceStatus() (*Status, error) {
d.mu.Lock()
defer d.mu.Unlock()
data, err := d.writeAndRead(cmdReadDeviceStatus, nil)
if err != nil {
return nil, err
}
return d.statusFromRegister(binary.BigEndian.Uint32(data)), nil
}
// ReadAndClearDeviceStatus reads the current device status (like [Dev.ReadDeviceStatus])
// and afterwards clears all flags.
func (d *Dev) ReadAndClearDeviceStatus() (*Status, error) {
d.mu.Lock()
defer d.mu.Unlock()
data, err := d.writeAndRead(cmdReadAndClearDeviceStatus, nil)
if err != nil {
return nil, err
}
return d.statusFromRegister(binary.BigEndian.Uint32(data)), nil
}
func (d *Dev) statusFromRegister(register uint32) *Status {
status := &Status{
FanSpeedErr: registerBitBool(register, 21),
FanErr: registerBitBool(register, 4),
PMSensorErr: registerBitBool(register, 11),
RHTSensorErr: registerBitBool(register, 6),
}
if d.model.hasCO2() {
if d.model == SEN66 {
status.CO2SensorErr = registerBitBool(register, 9)
} else {
// SEN63C and SEN69C.
status.CO2SensorErr = registerBitBool(register, 12)
}
}
if d.model.hasVOCNOx() {
status.GasSensorErr = registerBitBool(register, 7)
}
if d.model.hasHCHO() {
status.HCHOSensorErr = registerBitBool(register, 10)
}
return status
}
func registerBitBool(register uint32, bit uint) bool {
return uint8((register>>bit)&1) == 1
}

@ -0,0 +1,365 @@
// 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"
"github.com/google/go-cmp/cmp"
)
var sen69CAllErrsSet uint32 = 1<<21 | // FanSpeedErr
1<<4 | // FanErr
1<<11 | // PMSensorErr
1<<6 | // RHTSensorErr
1<<12 | // CO2SensorErr (SEN69C uses bit 12)
1<<7 | // GasSensorErr
1<<10 // HCHOSensorErr
func TestDevReadDeviceStatus(t *testing.T) {
cmd := []byte{0xd2, 0x06}
cases := []writeAndReadTestCase[*Status]{
{
name: "all unset, SEN66",
model: SEN66,
tx: cmd,
rx: []byte{0x00, 0x00, 0x81, 0x00, 0x00, 0x81},
want: &Status{},
},
{
name: "all set, SEN69C",
model: SEN69C,
tx: cmd,
rx: packWordsWithCRC(
[]uint16{uint16(sen69CAllErrsSet >> 16), uint16(sen69CAllErrsSet)}),
want: &Status{
FanSpeedErr: true,
FanErr: true,
PMSensorErr: true,
RHTSensorErr: true,
CO2SensorErr: true,
GasSensorErr: true,
HCHOSensorErr: true,
},
},
{
// writeAndRead will fail because no response is set.
name: "read error",
model: SEN69C,
tx: cmd,
wantErr: true,
dontPanic: true,
},
}
runWriteAndReadTests(t, cases, (*Dev).ReadDeviceStatus)
}
func TestDevReadAndClearDeviceStatus(t *testing.T) {
cmd := []byte{0xd2, 0x10}
cases := []writeAndReadTestCase[*Status]{
{
name: "all unset, SEN66",
model: SEN66,
tx: cmd,
rx: []byte{0x00, 0x00, 0x81, 0x00, 0x00, 0x81},
want: &Status{},
},
{
name: "all set, SEN69C",
model: SEN69C,
tx: cmd,
rx: packWordsWithCRC(
[]uint16{uint16(sen69CAllErrsSet >> 16), uint16(sen69CAllErrsSet)}),
want: &Status{
FanSpeedErr: true,
FanErr: true,
PMSensorErr: true,
RHTSensorErr: true,
CO2SensorErr: true,
GasSensorErr: true,
HCHOSensorErr: true,
},
},
{
// writeAndRead will fail because no response is set.
name: "read error",
model: SEN69C,
tx: cmd,
wantErr: true,
dontPanic: true,
},
}
runWriteAndReadTests(t, cases, (*Dev).ReadAndClearDeviceStatus)
}
func TestRegisterBitBool(t *testing.T) {
cases := []struct {
name string
register uint32
bit uint
want bool
}{
{
name: "bit 0 set",
register: 0x00000001,
bit: 0,
want: true,
},
{
name: "bit 0 not set",
register: 0x00000000,
bit: 0,
want: false,
},
{
name: "bit 31 set",
register: 0x80000000,
bit: 31,
want: true,
},
{
name: "bit 31 not set",
register: 0x7FFFFFFF,
bit: 31,
want: false,
},
{
name: "bit 21 set",
register: 1 << 21,
bit: 21,
want: true,
},
{
name: "bit 21 not set",
register: 0xf3000a7d,
bit: 21,
want: false,
},
{
name: "adjacent bits don't bleed",
register: 0b1101,
bit: 1,
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := registerBitBool(tc.register, tc.bit)
if got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}
func TestStatusFromRegister(t *testing.T) {
cases := []struct {
name string
model Model
register uint32
want *Status
}{
{
name: "no errors, SEN66",
model: SEN66,
register: 0x00000000,
want: &Status{},
},
{
name: "fan speed error, bit 21",
model: SEN66,
register: 1 << 21,
want: &Status{FanSpeedErr: true},
},
{
name: "fan error, bit 4",
model: SEN66,
register: 1 << 4,
want: &Status{FanErr: true},
},
{
name: "PM sensor error, bit 11",
model: SEN66,
register: 1 << 11,
want: &Status{PMSensorErr: true},
},
{
name: "RHT sensor error, bit 6",
model: SEN66,
register: 1 << 6,
want: &Status{RHTSensorErr: true},
},
{
// SEN66 CO2 error is bit 9.
name: "CO2 sensor error SEN66, bit 9",
model: SEN66,
register: 1 << 9,
want: &Status{CO2SensorErr: true},
},
{
// SEN63C/SEN69C CO2 error is bit 12.
name: "CO2 sensor error SEN63C, bit 12",
model: SEN63C,
register: 1 << 12,
want: &Status{CO2SensorErr: true},
},
{
// SEN66 CO2 error is bit 9; bit 12 should not trigger CO2SensorErr.
name: "SEN66 ignores bit 12 for CO2",
model: SEN66,
register: 1 << 12,
want: &Status{},
},
{
// SEN63C CO2 error is bit 12; bit 9 should not trigger CO2SensorErr.
name: "SEN63C ignores bit 9 for CO2",
model: SEN63C,
register: 1 << 9,
want: &Status{},
},
{
name: "gas sensor error SEN66, bit 7",
model: SEN66,
register: 1 << 7,
want: &Status{GasSensorErr: true},
},
{
// SEN62 has no gas sensor; bit 7 should not set GasSensorErr.
name: "SEN62 ignores gas sensor bit",
model: SEN62,
register: 1 << 7,
want: &Status{},
},
{
name: "HCHO sensor error SEN68, bit 10",
model: SEN68,
register: 1 << 10,
want: &Status{HCHOSensorErr: true},
},
{
// SEN66 has no HCHO sensor; bit 10 should not set HCHOSensorErr.
name: "SEN66 ignores HCHO sensor bit",
model: SEN66,
register: 1 << 10,
want: &Status{},
},
{
name: "all errors set, SEN69C",
model: SEN69C,
register: sen69CAllErrsSet,
want: &Status{
FanSpeedErr: true,
FanErr: true,
PMSensorErr: true,
RHTSensorErr: true,
CO2SensorErr: true,
GasSensorErr: true,
HCHOSensorErr: true,
},
},
{
// SEN62 only has fan, PM, and RHT errors.
name: "all applicable errors set, SEN62",
model: SEN62,
register: 1<<21 | // FanSpeedErr
1<<4 | // FanErr
1<<11 | // PMSensorErr
1<<6, // RHTSensorErr
want: &Status{
FanSpeedErr: true,
FanErr: true,
PMSensorErr: true,
RHTSensorErr: true,
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
d := &Dev{model: tc.model}
got := d.statusFromRegister(tc.register)
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestStatusAnyErr(t *testing.T) {
cases := []struct {
name string
status Status
want bool
}{
{
name: "no errors",
status: Status{},
want: false,
},
{
name: "fan speed error only",
status: Status{FanSpeedErr: true},
want: true,
},
{
name: "fan error only",
status: Status{FanErr: true},
want: true,
},
{
name: "PM sensor error only",
status: Status{PMSensorErr: true},
want: true,
},
{
name: "RHT sensor error only",
status: Status{RHTSensorErr: true},
want: true,
},
{
name: "CO2 sensor error only",
status: Status{CO2SensorErr: true},
want: true,
},
{
name: "gas sensor error only",
status: Status{GasSensorErr: true},
want: true,
},
{
name: "HCHO sensor error only",
status: Status{HCHOSensorErr: true},
want: true,
},
{
name: "all errors",
status: Status{
FanSpeedErr: true,
FanErr: true,
PMSensorErr: true,
RHTSensorErr: true,
CO2SensorErr: true,
GasSensorErr: true,
HCHOSensorErr: true,
},
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := tc.status.AnyErr()
if got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}

@ -0,0 +1,191 @@
// 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"
"github.com/google/go-cmp/cmp"
"periph.io/x/conn/v3/i2c"
"periph.io/x/conn/v3/i2c/i2ctest"
)
// newTestDev creates a Dev wired to the given playback bus, for use in tests.
func newTestDev(t *testing.T, bus *i2ctest.Playback, model Model) *Dev {
t.Helper()
return &Dev{
dev: &i2c.Dev{Bus: bus, Addr: i2cAddr},
model: model,
sleep: func(time.Duration) {},
}
}
type writeTestCase struct {
name string
model Model
tx []byte
wantErr bool
}
// runWriteTests runs tests that write to the I2C bus and expect no data in response.
func runWriteTests(t *testing.T, cases []writeTestCase, f func(d *Dev) error) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ops := []i2ctest.IO{}
if tc.tx != nil {
ops = append(ops, i2ctest.IO{Addr: i2cAddr, W: tc.tx})
}
bus := i2ctest.Playback{
Ops: ops,
}
defer func() {
if err := bus.Close(); err != nil {
t.Error(err)
}
}()
d := newTestDev(t, &bus, tc.model)
err := f(d)
if tc.wantErr && err == nil {
t.Fatalf("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
type writeAndReadTestCase[ReturnType any] struct {
name string
model Model
tx []byte
rx []byte
want ReturnType
wantErr bool
dontPanic bool
}
// runWriteAndReadTests runs tests that write to the I2C bus and expect to read data back.
func runWriteAndReadTests[ReturnType any](t *testing.T, cases []writeAndReadTestCase[ReturnType], f func(d *Dev) (ReturnType, error)) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ops := []i2ctest.IO{}
if tc.tx != nil {
ops = append(ops, i2ctest.IO{Addr: i2cAddr, W: tc.tx})
}
if tc.rx != nil {
ops = append(ops, i2ctest.IO{Addr: i2cAddr, R: tc.rx})
}
bus := i2ctest.Playback{
Ops: ops,
DontPanic: tc.dontPanic,
}
defer func() {
if err := bus.Close(); err != nil {
t.Error(err)
}
}()
d := newTestDev(t, &bus, tc.model)
got, err := f(d)
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 diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
}
})
}
}
type senseContinuousTestCase struct {
name string
model Model
interval time.Duration
expectedMeasurementCount int
ops []i2ctest.IO
dontPanic bool
}
// runSenseContinuousTests runs tests that exercise [Dev.SenseContinuous].
func runSenseContinuousTests(t *testing.T, cases []senseContinuousTestCase) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
bus := i2ctest.Playback{
Ops: tc.ops,
DontPanic: tc.dontPanic,
}
defer func() {
if err := bus.Close(); err != nil {
t.Error(err)
}
}()
d := newTestDev(t, &bus, tc.model)
ch, err := d.SenseContinuous(tc.interval)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := d.Halt(); err != nil {
t.Fatal(err)
}
}()
// Verify double start returns error.
if _, err := d.SenseContinuous(tc.interval); err == nil {
t.Error("expected error on second SenseContinuous call")
}
received := 0
loop:
for {
select {
case sv, ok := <-ch:
if !ok {
break loop
}
received++
if sv == nil {
t.Error("received nil SensorValues")
}
if received == tc.expectedMeasurementCount {
break loop
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for measurements")
}
}
if received != tc.expectedMeasurementCount {
t.Errorf("received %d measurements, want %d", received, tc.expectedMeasurementCount)
}
})
}
}

@ -0,0 +1,22 @@
// 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 (
"bytes"
)
// clen returns the index of the first NULL byte in n or len(n) if n contains no NULL byte.
func clen(n []byte) int {
if i := bytes.IndexByte(n, 0); i != -1 {
return i
}
return len(n)
}
func ptr[T uint16 | int16 | float32](n T) *T {
return &n
}

@ -0,0 +1,60 @@
// 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"
func TestClen(t *testing.T) {
cases := []struct {
name string
b []byte
want int
}{
{
name: "nil",
b: nil,
want: 0,
},
{
name: "empty",
b: []byte{},
want: 0,
},
{
name: "null byte at end",
b: []byte{0xaa, 0xbb, 0xcc, 0x0},
want: 3,
},
{
name: "null byte in middle",
b: []byte{0xaa, 0xbb, 0x00, 0xcc},
want: 2,
},
{
name: "null byte at start",
b: []byte{0x00, 0xaa, 0xbb, 0xcc},
want: 0,
},
{
name: "multiple null bytes",
b: []byte{0xaa, 0x00, 0x00, 0xbb},
want: 1,
},
{
name: "no null byte",
b: []byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee},
want: 5,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := clen(tc.b)
if got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}

@ -0,0 +1,233 @@
// 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 (
"encoding/binary"
"errors"
"fmt"
)
// VOCNOxAlgorithmTuningParameters represents parameters
// used to customize the VOC and NOx algorithm.
type VOCNOxAlgorithmTuningParameters struct {
// IndexOffset is the VOC or NOx index representing typical (average) conditions.
// Allowed values are in range 1..250. Device's default: 100 for VOC, 1 for NOx
IndexOffset int16
// LearningTimeOffsetHours is the time constant used to estimate the VOC/NOx
// algorithm offset from measurement history, in hours. Past events will be
// forgotten after about twice the learning time.
// Allowed values are in range 1..1000. Device's default: 12 hours
LearningTimeOffsetHours int16
// LearningTimeGainHours is the time constant used to estimate the VOC
// algorithm gain from measurement history, in hours. Past events will be
// forgotten after about twice the learning time.
// Allowed values are in range 1..1000. Device's default: 12 hours
//
// NOTE: This is only applicable to VOC. It has no impact for NOx. The datasheet
// says that this is included in the NOx parameters to keep it consistent with
// the VOC parameters. For NOx, it must always be set to 12.
LearningTimeGainHours int16
// GatingMaxDurationMinutes is the maximum duration of gating (freeze of
// estimator during high VOC/NOx index signal), in minutes. Zero disables
// gating. Allowed values are in range 0..3000. Device's default: 180 minutes
// for VOC, 720 minutes for NOx
GatingMaxDurationMinutes int16
// InitialStdDevEstimate is the initial VOC standard deviation estimate.
// A lower value boosts events during the initial learning period but may
// result in larger device-to-device variation. Allowed values are in range
// 10..5000. Device's default: 50
//
// NOTE: This is only applicable to VOC. It has no impact for NOx. The datasheet
// says that this is included in the NOx parameters to keep it consistent with
// the VOC parameters. For NOx, it must always be set to 50.
InitialStdDevEstimate int16
// GainFactor amplifies or attenuates the VOC/NOx index output.
// Allowed values are in range 1..1000. Device's default: 230
GainFactor int16
}
func (params VOCNOxAlgorithmTuningParameters) pack() []byte {
return packWordsWithCRC([]uint16{
uint16(params.IndexOffset),
uint16(params.LearningTimeOffsetHours),
uint16(params.LearningTimeGainHours),
uint16(params.GatingMaxDurationMinutes),
uint16(params.InitialStdDevEstimate),
uint16(params.GainFactor),
})
}
// GetVOCAlgorithmTuningParameters gets the parameters used to customize the
// VOC algorithm.
//
// For more information on the VOC index, see ["What is Sensirion's VOC Index?"].
// You may also consult "Sensirion's VOC and NOx Indices for Indoor Air Applications",
// available from Sensirion by request.
//
// ["What is Sensirion's VOC Index?"]: https://sensirion.com/media/documents/02232963/6294E043/Info_Note_VOC_Index.pdf
func (d *Dev) GetVOCAlgorithmTuningParameters() (*VOCNOxAlgorithmTuningParameters, error) {
if !d.model.hasVOCNOx() {
return nil, errors.New("sen6x: GetVOCAlgorithmTuningParameters requires a VOC-equipped model")
}
d.mu.Lock()
defer d.mu.Unlock()
data, err := d.writeAndRead(cmdGetVOCAlgorithmTuningParamsSEN65SEN66SEN68SEN69C, nil)
if err != nil {
return nil, err
}
return &VOCNOxAlgorithmTuningParameters{
IndexOffset: int16(binary.BigEndian.Uint16(data[0:2])),
LearningTimeOffsetHours: int16(binary.BigEndian.Uint16(data[2:4])),
LearningTimeGainHours: int16(binary.BigEndian.Uint16(data[4:6])),
GatingMaxDurationMinutes: int16(binary.BigEndian.Uint16(data[6:8])),
InitialStdDevEstimate: int16(binary.BigEndian.Uint16(data[8:10])),
GainFactor: int16(binary.BigEndian.Uint16(data[10:12])),
}, nil
}
// SetVOCAlgorithmTuningParameters sets the parameters used to customize the
// VOC algorithm.
//
// It has no effect if at least one parameter is outside the specified range.
//
// Note: This configuration is volatile, i.e. the parameters will be reverted
// to their default values after a device reset.
//
// For more information on the VOC index, see ["What is Sensirion's VOC Index?"].
// You may also consult "Sensirion's VOC and NOx Indices for Indoor Air Applications",
// available from Sensirion by request.
//
// ["What is Sensirion's VOC Index?"]: https://sensirion.com/media/documents/02232963/6294E043/Info_Note_VOC_Index.pdf
func (d *Dev) SetVOCAlgorithmTuningParameters(params VOCNOxAlgorithmTuningParameters) error {
if !d.model.hasVOCNOx() {
return errors.New("sen6x: SetVOCAlgorithmTuningParameters requires a VOC-equipped model")
}
d.mu.Lock()
defer d.mu.Unlock()
return d.writeAndWait(cmdSetVOCAlgorithmTuningParamsSEN65SEN66SEN68SEN69C, params.pack())
}
// GetVOCAlgorithmState gets the current VOC algorithm state. This data can be
// used to restore the state with [Dev.SetVOCAlgorithmState] after a power cycle
// or device reset.
//
// This can be used either in measurement mode or in idle mode (which will then
// return the state at the time when the measurement was stopped). In measurement
// mode, the state can be read each measure interval to always have the latest
// state available.
func (d *Dev) GetVOCAlgorithmState() ([8]byte, error) {
if !d.model.hasVOCNOx() {
return [8]byte{}, errors.New("sen6x: GetVOCAlgorithmState requires a VOC-equipped model")
}
d.mu.Lock()
defer d.mu.Unlock()
data, err := d.writeAndRead(cmdGetVOCAlgorithmStateSEN65SEN66SEN68SEN69C, nil)
if err != nil {
return [8]byte{}, err
}
if len(data) != 8 {
return [8]byte{}, fmt.Errorf("sen6x: expected VOC algorithm state to be 8 bytes, received %d", len(data))
}
return [8]byte(data), nil
}
// SetVOCAlgorithmState sets the VOC algorithm state previously received from
// [Dev.GetVOCAlgorithmState]. This command is only available in idle mode and
// the state will be applied when starting the next measurement. In measurement
// mode this command has no effect.
//
// Note: This configuration is volatile, i.e. the parameters will be reverted
// to their default values after a device reset.
func (d *Dev) SetVOCAlgorithmState(state [8]byte) error {
if !d.model.hasVOCNOx() {
return errors.New("sen6x: SetVOCAlgorithmState requires a VOC-equipped model")
}
packed, err := packBytesWithCRC(state[:])
if err != nil {
return err
}
d.mu.Lock()
defer d.mu.Unlock()
return d.writeAndWait(cmdSetVOCAlgorithmStateSEN65SEN66SEN68SEN69C, packed)
}
// GetNOxAlgorithmTuningParameters gets the parameters used to customize the
// NOx algorithm.
//
// For more information on the NOx index, see ["What is Sensirion's NOx Index?"].
// You may also consult "Sensirion's VOC and NOx Indices for Indoor Air Applications",
// available from Sensirion by request.
//
// ["What is Sensirion's NOx Index?"]: https://sensirion.com/media/documents/9F289B95/6294DFFC/Info_Note_NOx_Index.pdf
func (d *Dev) GetNOxAlgorithmTuningParameters() (*VOCNOxAlgorithmTuningParameters, error) {
if !d.model.hasVOCNOx() {
return nil, errors.New("sen6x: GetNOxAlgorithmTuningParameters requires a NOx-equipped model")
}
d.mu.Lock()
defer d.mu.Unlock()
data, err := d.writeAndRead(cmdGetNOxAlgorithmTuningParamsSEN65SEN66SEN68SEN69C, nil)
if err != nil {
return nil, err
}
return &VOCNOxAlgorithmTuningParameters{
IndexOffset: int16(binary.BigEndian.Uint16(data[0:2])),
LearningTimeOffsetHours: int16(binary.BigEndian.Uint16(data[2:4])),
LearningTimeGainHours: int16(binary.BigEndian.Uint16(data[4:6])),
GatingMaxDurationMinutes: int16(binary.BigEndian.Uint16(data[6:8])),
InitialStdDevEstimate: int16(binary.BigEndian.Uint16(data[8:10])),
GainFactor: int16(binary.BigEndian.Uint16(data[10:12])),
}, nil
}
// SetNOxAlgorithmTuningParameters sets the parameters used to customize the
// NOx algorithm.
//
// It has no effect if at least one parameter is outside the specified range.
//
// Note: This configuration is volatile, i.e. the parameters will be reverted
// to their default values after a device reset.
//
// For more information on the NOx index, see ["What is Sensirion's NOx Index?"].
// You may also consult "Sensirion's VOC and NOx Indices for Indoor Air Applications",
// available from Sensirion by request.
//
// ["What is Sensirion's NOx Index?"]: https://sensirion.com/media/documents/9F289B95/6294DFFC/Info_Note_NOx_Index.pdf
func (d *Dev) SetNOxAlgorithmTuningParameters(params VOCNOxAlgorithmTuningParameters) error {
if !d.model.hasVOCNOx() {
return errors.New("sen6x: SetNOxAlgorithmTuningParameters requires a NOx-equipped model")
}
// These two parameters only apply to VOC but are included in the NOx parameters
// for consistency. The datasheet specifies that these parameters must always
// have the values set here.
params.LearningTimeGainHours = 12
params.InitialStdDevEstimate = 50
d.mu.Lock()
defer d.mu.Unlock()
return d.writeAndWait(cmdSetNOxAlgorithmTuningParamsSEN65SEN66SEN68SEN69C, params.pack())
}

@ -0,0 +1,232 @@
// 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"
)
func TestDevGetVOCAlgorithmTuningParameters(t *testing.T) {
cmd := []byte{0x60, 0xd0}
cases := []writeAndReadTestCase[*VOCNOxAlgorithmTuningParameters]{
{
name: "all values set",
model: SEN66,
tx: cmd,
rx: []byte{
0x00, 0x64, 0xfe, // Index offset
0x00, 0x0c, 0xfc, // Learning time offset
0x00, 0x0c, 0xfc, // Learning time gain
0x00, 0xb4, 0xfa, // Gating max duration
0x00, 0x32, 0x26, // Initial std dev estimate
0x00, 0xe6, 0xe6, // Gain factor
},
want: &VOCNOxAlgorithmTuningParameters{
IndexOffset: int16(100),
LearningTimeOffsetHours: int16(12),
LearningTimeGainHours: int16(12),
GatingMaxDurationMinutes: int16(180),
InitialStdDevEstimate: int16(50),
GainFactor: int16(230),
},
},
{
name: "bad crc",
model: SEN66,
tx: cmd,
rx: []byte{
0x00, 0x64, 0xfe, // Index offset
0x00, 0x0c, 0xfc, // Learning time offset
0x00, 0x0c, 0xfc, // Learning time gain
0x00, 0xb4, 0xfa, // Gating max duration
0x00, 0x32, 0x26, // Initial std dev estimate
0x00, 0xe6, 0xff, // Gain factor with incorrect CRC (should be 0xe6)
},
wantErr: true,
},
{
// This fails before sending any data, so no tx or rx set.
name: "model without VOC/NOx capability",
model: SEN62,
wantErr: true,
},
}
runWriteAndReadTests(t, cases, (*Dev).GetVOCAlgorithmTuningParameters)
}
func TestDevSetVOCAlgorithmTuningParameters(t *testing.T) {
cases := []writeTestCase{
{
name: "success",
model: SEN66,
tx: []byte{
0x60, 0xd0, // Command
0x00, 0x64, 0xfe, // Index offset
0x00, 0x0c, 0xfc, // Learning time offset
0x00, 0x0c, 0xfc, // Learning time gain
0x00, 0xb4, 0xfa, // Gating max duration
0x00, 0x32, 0x26, // Initial std dev estimate
0x00, 0xe6, 0xe6, // Gain factor
},
},
{
name: "model without VOC/NOx capability",
model: SEN62,
wantErr: true,
},
}
params := VOCNOxAlgorithmTuningParameters{
IndexOffset: int16(100),
LearningTimeOffsetHours: int16(12),
LearningTimeGainHours: int16(12),
GatingMaxDurationMinutes: int16(180),
InitialStdDevEstimate: int16(50),
GainFactor: int16(230),
}
runWriteTests(t, cases, func(d *Dev) error {
return d.SetVOCAlgorithmTuningParameters(params)
})
}
func TestDevGetVOCAlgorithmState(t *testing.T) {
cmd := []byte{0x61, 0x81}
cases := []writeAndReadTestCase[[8]byte]{
{
name: "success",
model: SEN66,
tx: cmd,
rx: []byte{0x08, 0x43, 0xd8, 0x8b, 0x2b, 0xd4, 0x0f, 0x34, 0x19, 0xa7, 0x72, 0x4a},
want: [8]byte{0x08, 0x43, 0x8b, 0x2b, 0x0f, 0x34, 0xa7, 0x72},
},
{
// writeAndRead will fail because no response is set.
name: "read error",
model: SEN66,
tx: cmd,
dontPanic: true,
wantErr: true,
},
{
name: "model without VOC/NOx capability",
model: SEN62,
wantErr: true,
},
}
runWriteAndReadTests(t, cases, (*Dev).GetVOCAlgorithmState)
}
func TestDevSetVOCAlgorithmState(t *testing.T) {
cases := []writeTestCase{
{
name: "success",
model: SEN66,
tx: []byte{
0x61, 0x81, // Command
0x08, 0x43, 0xd8, 0x8b, 0x2b, 0xd4, 0x0f, 0x34, 0x19, 0xa7, 0x72, 0x4a, // VOC alg state
},
},
{
name: "model without VOC/NOx capability",
model: SEN62,
wantErr: true,
},
}
runWriteTests(t, cases, func(d *Dev) error {
return d.SetVOCAlgorithmState([8]byte{0x08, 0x43, 0x8b, 0x2b, 0x0f, 0x34, 0xa7, 0x72})
})
}
func TestDevGetNOxAlgorithmTuningParameters(t *testing.T) {
cmd := []byte{0x60, 0xe1}
cases := []writeAndReadTestCase[*VOCNOxAlgorithmTuningParameters]{
{
name: "all values set",
model: SEN66,
tx: cmd,
rx: []byte{
0x00, 0x01, 0xb0, // Index offset
0x00, 0x0c, 0xfc, // Learning time offset
0x00, 0x0c, 0xfc, // Learning time gain
0x02, 0xd0, 0x5c, // Gating max duration
0x00, 0x32, 0x26, // Initial std dev estimate
0x00, 0xe6, 0xe6, // Gain factor
},
want: &VOCNOxAlgorithmTuningParameters{
IndexOffset: int16(1),
LearningTimeOffsetHours: int16(12),
LearningTimeGainHours: int16(12),
GatingMaxDurationMinutes: int16(720),
InitialStdDevEstimate: int16(50),
GainFactor: int16(230),
},
},
{
name: "bad crc",
model: SEN66,
tx: cmd,
rx: []byte{
0x00, 0x01, 0xb0, // Index offset
0x00, 0x0c, 0xfc, // Learning time offset
0x00, 0x0c, 0xfc, // Learning time gain
0x02, 0xd0, 0x5c, // Gating max duration
0x00, 0x32, 0x26, // Initial std dev estimate
0x00, 0xe6, 0xff, // Gain factor with incorrect CRC (should be 0xe6)
},
wantErr: true,
},
{
// This fails before sending a command, so no tx or rx set.
name: "model without VOC/NOx capability",
model: SEN62,
wantErr: true,
},
}
runWriteAndReadTests(t, cases, (*Dev).GetNOxAlgorithmTuningParameters)
}
func TestDevSetNOxAlgorithmTuningParameters(t *testing.T) {
cases := []writeTestCase{
{
name: "success",
model: SEN66,
tx: []byte{
0x60, 0xe1, // Command
0x00, 0x01, 0xb0, // Index offset
0x00, 0x0c, 0xfc, // Learning time offset
0x00, 0x0c, 0xfc, // Learning time gain
0x02, 0xd0, 0x5c, // Gating max duration
0x00, 0x32, 0x26, // Initial std dev estimate
0x00, 0xe6, 0xe6, // Gain factor
},
},
{
name: "model without VOC/NOx capability",
model: SEN62,
wantErr: true,
},
}
params := VOCNOxAlgorithmTuningParameters{
IndexOffset: int16(1),
LearningTimeOffsetHours: int16(12),
LearningTimeGainHours: int16(12),
GatingMaxDurationMinutes: int16(720),
InitialStdDevEstimate: int16(50),
GainFactor: int16(230),
}
runWriteTests(t, cases, func(d *Dev) error {
return d.SetNOxAlgorithmTuningParameters(params)
})
}
Loading…
Cancel
Save