From d375a1ab43ec76d4c84b1d0ef4845a24a4a8ef51 Mon Sep 17 00:00:00 2001 From: Michael Traver Date: Sat, 13 Jun 2026 18:56:39 -0700 Subject: [PATCH] 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 --- sen6x/co2.go | 200 ++++++++++ sen6x/co2_test.go | 247 ++++++++++++ sen6x/commands.go | 320 +++++++++++++++ sen6x/crc.go | 86 ++++ sen6x/crc_test.go | 211 ++++++++++ sen6x/doc.go | 56 +++ sen6x/model.go | 68 ++++ sen6x/model_string.go | 29 ++ sen6x/pm.go | 54 +++ sen6x/pm_test.go | 63 +++ sen6x/rht.go | 206 ++++++++++ sen6x/rht_test.go | 117 ++++++ sen6x/sen6x.go | 530 +++++++++++++++++++++++++ sen6x/sen6x_test.go | 808 ++++++++++++++++++++++++++++++++++++++ sen6x/status.go | 190 +++++++++ sen6x/status_test.go | 365 +++++++++++++++++ sen6x/testhelpers_test.go | 191 +++++++++ sen6x/util.go | 22 ++ sen6x/util_test.go | 60 +++ sen6x/vocnox.go | 233 +++++++++++ sen6x/vocnox_test.go | 232 +++++++++++ 21 files changed, 4288 insertions(+) create mode 100644 sen6x/co2.go create mode 100644 sen6x/co2_test.go create mode 100644 sen6x/commands.go create mode 100644 sen6x/crc.go create mode 100644 sen6x/crc_test.go create mode 100644 sen6x/doc.go create mode 100644 sen6x/model.go create mode 100644 sen6x/model_string.go create mode 100644 sen6x/pm.go create mode 100644 sen6x/pm_test.go create mode 100644 sen6x/rht.go create mode 100644 sen6x/rht_test.go create mode 100644 sen6x/sen6x.go create mode 100644 sen6x/sen6x_test.go create mode 100644 sen6x/status.go create mode 100644 sen6x/status_test.go create mode 100644 sen6x/testhelpers_test.go create mode 100644 sen6x/util.go create mode 100644 sen6x/util_test.go create mode 100644 sen6x/vocnox.go create mode 100644 sen6x/vocnox_test.go diff --git a/sen6x/co2.go b/sen6x/co2.go new file mode 100644 index 0000000..17c56dc --- /dev/null +++ b/sen6x/co2.go @@ -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})) +} diff --git a/sen6x/co2_test.go b/sen6x/co2_test.go new file mode 100644 index 0000000..892638c --- /dev/null +++ b/sen6x/co2_test.go @@ -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) + }) +} diff --git a/sen6x/commands.go b/sen6x/commands.go new file mode 100644 index 0000000..e0d93e1 --- /dev/null +++ b/sen6x/commands.go @@ -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, + } +) diff --git a/sen6x/crc.go b/sen6x/crc.go new file mode 100644 index 0000000..3fba5ac --- /dev/null +++ b/sen6x/crc.go @@ -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 +} diff --git a/sen6x/crc_test.go b/sen6x/crc_test.go new file mode 100644 index 0000000..08eb632 --- /dev/null +++ b/sen6x/crc_test.go @@ -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) + } + }) + } +} diff --git a/sen6x/doc.go b/sen6x/doc.go new file mode 100644 index 0000000..09e250d --- /dev/null +++ b/sen6x/doc.go @@ -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 diff --git a/sen6x/model.go b/sen6x/model.go new file mode 100644 index 0000000..4e77df3 --- /dev/null +++ b/sen6x/model.go @@ -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 +} diff --git a/sen6x/model_string.go b/sen6x/model_string.go new file mode 100644 index 0000000..347c0a5 --- /dev/null +++ b/sen6x/model_string.go @@ -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]] +} diff --git a/sen6x/pm.go b/sen6x/pm.go new file mode 100644 index 0000000..24dd554 --- /dev/null +++ b/sen6x/pm.go @@ -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 +} diff --git a/sen6x/pm_test.go b/sen6x/pm_test.go new file mode 100644 index 0000000..1e1fb1a --- /dev/null +++ b/sen6x/pm_test.go @@ -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) +} diff --git a/sen6x/rht.go b/sen6x/rht.go new file mode 100644 index 0000000..a8f64e5 --- /dev/null +++ b/sen6x/rht.go @@ -0,0 +1,206 @@ +// Copyright 2026 The Periph Authors. All rights reserved. +// Use of this source code is governed under the Apache License, Version 2.0 +// that can be found in the LICENSE file. + +package sen6x + +import "encoding/binary" + +type TemperatureOffsetParameters struct { + // Offset is the constant temperature factor in °C. + Offset int16 + + // Slope is the temperature offset slope. + Slope int16 + + // TimeConstant determines how fast the new slope and offset will be applied. + // + // After the specified value in seconds, 63% of the new slope and offset are + // applied. A time constant of zero means the new values will be applied + // immediately (within the next measure interval of 1 second). + TimeConstant uint16 + + // Slot is the temperature offset slot to be modified. Valid values are in [0, 4]. + // If the value is outside this range, the parameters will not be applied. + // + // A total of five slots are available. Each slot represents one temperature + // offset. Usually slot 0 is used to compensate for the base self-heating and + // the other slots allow compensation for additional heating of components like + // screens, Wi-Fi modules, etc. that can be switched on and off independently. + Slot uint16 +} + +func (params TemperatureOffsetParameters) pack() []byte { + return packWordsWithCRC([]uint16{ + uint16(params.Offset * 200), + uint16(params.Slope * 10000), + params.TimeConstant, + params.Slot, + }) +} + +type TemperatureAccelerationParameters struct { + // Filter constant K. + K uint16 + + // Filter constant P. + P uint16 + + // Time constant T1. + T1 uint16 + + // Time constant T2. + T2 uint16 +} + +func (params TemperatureAccelerationParameters) pack() []byte { + return packWordsWithCRC([]uint16{ + params.K * 10, + params.P * 10, + params.T1 * 10, + params.T2 * 10, + }) +} + +type SHTHeaterMeasurements struct { + // If SHT sensor heating is completed, RH indicates the relative humidity + // of the SHT4x sensor. + RH *float32 + + // If SHT sensor heating is completed, Temp indicates the temperature in °C + // of the SHT4x sensor. + Temp *float32 +} + +// SetTemperatureOffsetParameters allows for compensation of temperature effects +// due to the design-in of the sensor by applying custom offsets to the ambient +// temperature. +// +// The compensated ambient temperature is calculated as follows: +// +// T_compensated = T_measured + (Slope * T_measured) + C_offset +// +// where Slope and C_offset are set with this command, smoothed with the given +// time constant. All temperatures (T_compensated, T_measured, and C_offset) +// are represented in °C. +// +// There are 5 temperature offset slots available that all contribute additively +// to T_compensated. The default values for the temperature offset parameters are +// all zero, meaning that T_compensated is equal to T_measured by default. +// +// Note: This configuration is volatile, i.e. the parameters will be reverted to +// their default value of zero after a device reset. +// +// For more details on how to compensate the temperature on the SEN6x platform, +// refer to ["SEN6x – Temperature Acceleration and Compensation Instructions"]. +// +// ["SEN6x – Temperature Acceleration and Compensation Instructions"]: https://sensirion.com/media/documents/C964FCC8/69709EC3/PS_AN_SEN6x_Temperature_Compensation_and_Acceleration_Application_No.pdf +func (d *Dev) SetTemperatureOffsetParameters(params TemperatureOffsetParameters) error { + d.mu.Lock() + defer d.mu.Unlock() + + return d.writeAndWait(cmdSetTemperatureOffsetParams, params.pack()) +} + +// SetTemperatureAccelerationParameters sets custom RH/T engine temperature +// acceleration parameters. +// +// It overwrites the RH/T engine's default temperature acceleration parameters +// with custom values. +// +// Note: This configuration is volatile, i.e. the parameters will be reverted to +// their default values after a device reset. +// +// For more details on how to compensate the temperature on the SEN6x platform, +// refer to ["SEN6x – Temperature Acceleration and Compensation Instructions"]. +// +// ["SEN6x – Temperature Acceleration and Compensation Instructions"]: https://sensirion.com/media/documents/C964FCC8/69709EC3/PS_AN_SEN6x_Temperature_Compensation_and_Acceleration_Application_No.pdf +func (d *Dev) SetTemperatureAccelerationParameters(params TemperatureAccelerationParameters) error { + d.mu.Lock() + defer d.mu.Unlock() + + return d.writeAndWait(cmdSetTemperatureAccelParams, params.pack()) +} + +// ActivateSHTHeater activates the SHT sensor's built-in heater to reverse +// humidity creep at high humidity. It activates the heater at 200 mW for 1 s, +// after which the heater is deactivated. +// +// "SHT" refers to the SHT4x family of relative humidity and temperature sensors. +// See the [SHT4x datasheet]. +// +// For firmware versions listed below, [Dev.GetSHTHeaterMeasurements] may be polled +// to check whether heating is finished in order to trigger another cycle and maximize +// the duty cycle. Older firmware versions do not support [Dev.GetSHTHeaterMeasurements]. +// +// Wait at least 20 s after this command before starting a measurement to get +// coherent temperature values (i.e. heating consequence to disappear). +// +// The following firmware versions have a command execution time of 20 ms and +// support [Dev.GetSHTHeaterMeasurements]. Older firmware versions have an execution +// time of 1300 ms: +// - SEN62 >= 6.0 +// - SEN63C >= 5.0 +// - SEN65 >= 5.0 +// - SEN66 >= 4.0 +// - SEN68 >= 7.0 +// - SEN69C >= 9.0 +// +// In this package the execution time for ActivateSHTHeater is set to 20 ms, so if +// you're using a device with firmware version below those listed above then you +// should wait an additional 1280 ms after calling this method. +// +// For more information on humidity creep, see the application note +// ["Using the Integrated Heater of SHT4x in High-Humidity Environments"]. +// +// ["Using the Integrated Heater of SHT4x in High-Humidity Environments"]: https://sensirion.com/media/documents/A88858C9/629626D4/Application_Note_Creep_Mitigation_SHT4x.pdf +// [SHT4x datasheet]: https://sensirion.com/media/documents/33FD6951/67EB9032/HT_DS_Datasheet_SHT4x_5.pdf +func (d *Dev) ActivateSHTHeater() error { + d.mu.Lock() + defer d.mu.Unlock() + + return d.writeAndWait(cmdActivateSHTHeater, nil) +} + +// GetSHTHeaterMeasurements gets the measurement values when SHT sensor heating is finished. +// +// "SHT" refers to the SHT4x family of relative humidity and temperature sensors. +// See the [SHT4x datasheet]. +// +// This command is available only in these firmware versions: +// - SEN62 >= 6.0 +// - SEN63C >= 5.0 +// - SEN65 >= 5.0 +// - SEN66 >= 4.0 +// - SEN68 >= 7.0 +// - SEN69C >= 9.0 +// +// It must be used after [Dev.ActivateSHTHeater]. It may be polled every 50 ms to +// check if the heating cycle is finished and measurements are available. +// +// For more information on humidity creep, see the application note +// ["Using the Integrated Heater of SHT4x in High-Humidity Environments"]. +// +// ["Using the Integrated Heater of SHT4x in High-Humidity Environments"]: https://sensirion.com/media/documents/A88858C9/629626D4/Application_Note_Creep_Mitigation_SHT4x.pdf +// [SHT4x datasheet]: https://sensirion.com/media/documents/33FD6951/67EB9032/HT_DS_Datasheet_SHT4x_5.pdf +func (d *Dev) GetSHTHeaterMeasurements() (*SHTHeaterMeasurements, error) { + d.mu.Lock() + defer d.mu.Unlock() + + data, err := d.writeAndRead(cmdGetSHTHeaterMeasurements, nil) + if err != nil { + return nil, err + } + + hm := &SHTHeaterMeasurements{} + + if rawRH := binary.BigEndian.Uint16(data[0:2]); rawRH != 0x7fff { + hm.RH = ptr(float32(rawRH) / 100.0) + } + + if rawTemp := int16(binary.BigEndian.Uint16(data[2:4])); rawTemp != 0x7fff { + hm.Temp = ptr(float32(rawTemp) / 200.0) + } + + return hm, nil +} diff --git a/sen6x/rht_test.go b/sen6x/rht_test.go new file mode 100644 index 0000000..1d940ce --- /dev/null +++ b/sen6x/rht_test.go @@ -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) +} diff --git a/sen6x/sen6x.go b/sen6x/sen6x.go new file mode 100644 index 0000000..a6db115 --- /dev/null +++ b/sen6x/sen6x.go @@ -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{} diff --git a/sen6x/sen6x_test.go b/sen6x/sen6x_test.go new file mode 100644 index 0000000..876c876 --- /dev/null +++ b/sen6x/sen6x_test.go @@ -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) +} diff --git a/sen6x/status.go b/sen6x/status.go new file mode 100644 index 0000000..47db7ac --- /dev/null +++ b/sen6x/status.go @@ -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 +} diff --git a/sen6x/status_test.go b/sen6x/status_test.go new file mode 100644 index 0000000..6bd598c --- /dev/null +++ b/sen6x/status_test.go @@ -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) + } + }) + } +} diff --git a/sen6x/testhelpers_test.go b/sen6x/testhelpers_test.go new file mode 100644 index 0000000..95cab08 --- /dev/null +++ b/sen6x/testhelpers_test.go @@ -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) + } + }) + } +} diff --git a/sen6x/util.go b/sen6x/util.go new file mode 100644 index 0000000..42ff02a --- /dev/null +++ b/sen6x/util.go @@ -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 +} diff --git a/sen6x/util_test.go b/sen6x/util_test.go new file mode 100644 index 0000000..38f0ba0 --- /dev/null +++ b/sen6x/util_test.go @@ -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) + } + }) + } +} diff --git a/sen6x/vocnox.go b/sen6x/vocnox.go new file mode 100644 index 0000000..f9fbb10 --- /dev/null +++ b/sen6x/vocnox.go @@ -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()) +} diff --git a/sen6x/vocnox_test.go b/sen6x/vocnox_test.go new file mode 100644 index 0000000..92afd88 --- /dev/null +++ b/sen6x/vocnox_test.go @@ -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) + }) +}