mirror of https://github.com/periph/devices
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
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,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…
Reference in New Issue