diff --git a/scd4x/README.md b/scd4x/README.md
new file mode 100644
index 0000000..b1b0cf4
--- /dev/null
+++ b/scd4x/README.md
@@ -0,0 +1,56 @@
+# Sensirion SCD4x CO2 Sensors
+
+## Overview
+
+This package provides a driver for the Sensirion SCD4x CO2 sensors. This is a
+compact sensor that provides temperature, humidity, and CO2 concentration
+readings. The datasheet for this device is available at:
+
+https://sensirion.com/media/documents/48C4B7FB/66E05452/CD_DS_SCD4x_Datasheet_D1.pdf
+
+## Testing
+
+The unit tests can function with either a live sensor, or in playback mode. If the
+environment variable SCD4X is set, then the self test code will use a live
+sensor on the default I2C bus. For example:
+
+```bash
+$> SCD4X=1 go test -v
+```
+If the environment variable is not present, then unit tests will be conducted using
+playback values.
+
+## Notes
+
+### Acquisition Time
+
+The minimum acquisition time for the sensor is 5 seconds. If you call Sense() more
+frequently, it will block until a reading is ready.
+
+### Forced Calibration and Self-Test
+
+These functions are not implemented. From examining the datasheet, and
+experimenting, it appears that these two calls require the i2c communication
+driver to wait a specified period before initiating the read. The periph.io
+I2C library doesn't support this functionality. This means that attempts
+to call these functions will always fail so they're not implemented.
+
+### Acquisition Mode
+
+Only certain commands can be issued while the device is running in acquisition
+mode. If you're working on the low-level code, be aware that attempts to send
+a non-allowed command while in acquisition mode will return an i2c remote
+io-error.
+
+### Automatic Self Calibration
+
+When Automatic Self Calibration is enabled, and the sensor has run for the
+required period, it will adjust itself so that the LOWEST recorded reading
+during the period yields the value set for ASC Target. The factory default
+target is 400PPM, but the current PPM is ~425PPM. To get a more accurate
+value for CO2 concentration in Earth's atmosphere, refer to:
+
+https://www.co2.earth/daily-co2
+
+For more details, refer to the datasheet.
+
diff --git a/scd4x/doc.go b/scd4x/doc.go
new file mode 100644
index 0000000..e8409eb
--- /dev/null
+++ b/scd4x/doc.go
@@ -0,0 +1,12 @@
+// Copyright 2024 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.
+
+// This package provides a driver for the Sensiron SCD4x CO2 sensors.
+// The scd4x family provide a compact sensor that can be used to measure
+// Temperature, Humidity, and CO2 concentration.
+//
+// Refer to the datasheet for more information.
+//
+// https://sensirion.com/media/documents/48C4B7FB/66E05452/CD_DS_SCD4x_Datasheet_D1.pdf
+package scd4x
diff --git a/scd4x/example_test.go b/scd4x/example_test.go
new file mode 100644
index 0000000..cad5a3e
--- /dev/null
+++ b/scd4x/example_test.go
@@ -0,0 +1,63 @@
+//go:build examples
+// +build examples
+
+// Copyright 2024 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 scd4x_test
+
+import (
+ "fmt"
+ "log"
+
+ "periph.io/x/conn/v3/i2c/i2creg"
+ "periph.io/x/devices/v3/scd4x"
+ "periph.io/x/host/v3"
+)
+
+// basic example program for scd4x sensors using this library.
+//
+// To execute this as a stand-alone program:
+//
+// Copy the file example_test.go to a new directory.
+// rename the file to main.go
+// rename the Example() function to main, and the package to main
+//
+// execute:
+//
+// go mod init mydomain.com/scd4x
+// go mod tidy
+// go build -o main main.go
+// ./main
+func Example() {
+ fmt.Println("scd4x example program")
+ if _, err := host.Init(); err != nil {
+ fmt.Println(err)
+ }
+ bus, err := i2creg.Open("")
+ if err != nil {
+ log.Fatal(err)
+ }
+ dev, err := scd4x.NewI2C(bus, scd4x.SensorAddress)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ env := scd4x.Env{}
+ err = dev.Sense(&env)
+ if err == nil {
+ fmt.Println(env.String())
+ } else {
+ fmt.Println(err)
+ }
+
+ cfg, err := dev.GetConfiguration()
+ if err == nil {
+ fmt.Printf("Configuration: %#v\n", cfg)
+ } else {
+ fmt.Println(err)
+ }
+ // Output: Temperature: 24.845°C Humidity: 32.3%rH CO2: 581 PPM
+ // Configuration: &scd4x.DevConfig{AmbientPressure:0, ASCEnabled:true, ASCInitialPeriod:158400000000000, ASCStandardPeriod:561600000000000, ASCTarget:400, SensorAltitude:0, SerialNumber:127207989525260, TemperatureOffset:4, SensorType:0}
+}
diff --git a/scd4x/scd4x.go b/scd4x/scd4x.go
new file mode 100644
index 0000000..14128fe
--- /dev/null
+++ b/scd4x/scd4x.go
@@ -0,0 +1,623 @@
+// Copyright 2024 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 scd4x
+
+import (
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+
+ "periph.io/x/conn/v3/i2c"
+ "periph.io/x/conn/v3/physic"
+)
+
+// PPM=Parts Per Million. Units of measure for CO2 concentration.
+type PPM int
+
+// Sensor Variant type
+type Variant int
+
+const (
+ SCD40 Variant = iota
+ SCD41
+)
+
+// Type of reset to perform.
+type ResetMode int
+
+const (
+ ResetFactory ResetMode = iota
+ // Reset to last values stored in EEPROM
+ ResetEEPROM
+)
+
+const (
+ // These devices only support this i2c address.
+ SensorAddress uint16 = 0x62
+)
+
+type cmd uint16
+
+// Structure to simplify sending commands to the device.
+type command struct {
+ // The 16-bit command words.
+ cmdWord cmd
+ // The expected number of bytes returned. 0, 3, or 9.
+ responseSize int
+ // True if this command is permitted while the sensor is running in
+ // acquisition mode.
+ whileSensing bool
+}
+
+// The various implemented commands.
+
+var cmdStartMeasurement = command{
+ cmdWord: 0x21b1,
+}
+
+var cmdReadMeasurement = command{
+ cmdWord: 0xec05,
+ responseSize: 9,
+ whileSensing: true,
+}
+
+var cmdStopMeasurement = command{
+ cmdWord: 0x3f86,
+ whileSensing: true,
+}
+var cmdGetTemperatureOffset = command{
+ cmdWord: 0x2318,
+ responseSize: 3,
+}
+var cmdSetTemperatureOffset = command{
+ cmdWord: 0x241d,
+}
+var cmdGetSensorAltitude = command{
+ cmdWord: 0x2322,
+ responseSize: 3,
+}
+var cmdSetSensorAltitude = command{
+ cmdWord: 0x2427,
+}
+var cmdGetAmbientPressure = command{
+ cmdWord: 0xe000,
+ responseSize: 3,
+ whileSensing: true,
+}
+var cmdSetAmbientPressure = command{
+ cmdWord: 0xe000,
+ whileSensing: true,
+}
+var cmdSetASCEnabled = command{
+ cmdWord: 0x2416,
+}
+var cmdGetASCEnabled = command{
+ cmdWord: 0x2313,
+ responseSize: 3,
+}
+var cmdGetASCTarget = command{
+ cmdWord: 0x233f,
+ responseSize: 3,
+}
+var cmdSetASCTarget = command{
+ cmdWord: 0x243a,
+}
+var cmdGetDataReadyStatus = command{
+ cmdWord: 0xe4b8,
+ responseSize: 3,
+ whileSensing: true,
+}
+var cmdPersistSettings = command{
+ cmdWord: 0x3615,
+}
+var cmdGetSerialNumber = command{
+ cmdWord: 0x3682,
+ responseSize: 9,
+}
+var cmdPerformFactoryReset = command{
+ cmdWord: 0x3632,
+}
+var cmdReinit = command{
+ cmdWord: 0x3646,
+}
+var cmdGetSensorVariant = command{
+ cmdWord: 0x202f,
+ responseSize: 3,
+}
+var cmdGetASCInitialPeriod = command{
+ cmdWord: 0x2340,
+ responseSize: 3,
+}
+var cmdSetASCInitialPeriod = command{
+ cmdWord: 0x2445,
+}
+var cmdGetASCStandardPeriod = command{
+ cmdWord: 0x234b,
+ responseSize: 3,
+}
+var cmdSetASCStandardPeriod = command{
+ cmdWord: 0x244e,
+}
+var cmdWakeUp = command{
+ cmdWord: 0x36f6,
+}
+
+// DevConfig is the current running configuration of the device. Values prefixed
+// with ASC refer to Auto-Self-Calibration. Use Dev.GetConfiguration() to read
+// the value, and Dev.SetConfiguration() to apply changes.
+//
+// Refer to the datasheet for more information on settings.
+type DevConfig struct {
+ // Ambient pressure value. Used to adjust operation of sensor.
+ AmbientPressure physic.Pressure
+ // Automatic-Self-Calibration enabled. True or false.
+ ASCEnabled bool
+ // Refer to datasheet for usage.
+ ASCInitialPeriod time.Duration
+ // Refer to datasheet for usage.
+ ASCStandardPeriod time.Duration
+ // Target CO2 concentration for automatic self calibration. To obtain the
+ // current value, visit:
+ //
+ // https://www.co2.earth/daily-co2
+ ASCTarget PPM
+ // Sensor altitude in metres. Alternative method to adjust ambient pressure
+ // for sensor correction.
+ SensorAltitude physic.Distance
+ // The 48 bit unique serial number of the device. Read-Only
+ SerialNumber int64
+ // Offset temperature added to reading. Refer to the datasheet for usage.
+ TemperatureOffset physic.Temperature
+ // The Type of sensor. SCD40 or SCD41. Read-Only
+ SensorType Variant
+}
+
+// Dev represents an SCD4x device.
+type Dev struct {
+ // The i2c bus device.
+ d *i2c.Dev
+ // channel to halt SenseContinuous
+ chHalt chan bool
+ mu sync.Mutex
+ // True if the device is in continuous sense mode.
+ sensing bool
+}
+
+func (ppm *PPM) String() string {
+ return fmt.Sprintf("%d PPM", *ppm)
+}
+
+// The sensor reading. Returns CO2 PPM, Temperature, and Humidity.
+type Env struct {
+ physic.Env
+ CO2 PPM
+}
+
+// Return the sensor readings in string format.
+func (e *Env) String() string {
+ return fmt.Sprintf("Temperature: %s Humidity: %s CO2: %s", e.Temperature.String(), e.Humidity.String(), e.CO2.String())
+}
+
+// NewI2c creates a new SCD4x sensor using the supplied bus and address.
+// The constant value SensorAddress should be supplied as the value for
+// addr.
+func NewI2C(b i2c.Bus, addr uint16) (*Dev, error) {
+ d := &Dev{d: &i2c.Dev{Bus: b, Addr: addr}, chHalt: nil}
+ return d, d.start()
+}
+
+// GetConfiguration returns a structure containing all of the scd4x configuration
+// variables. You can then alter settings and call SetConfiguration with it.
+//
+// To examine the device use:
+//
+// cfg, _ :=dev.GetConfiguration()
+// fmt.Printf("Configuration=%#v\n", cfg)
+func (d *Dev) GetConfiguration() (*DevConfig, error) {
+
+ cfg := &DevConfig{}
+ var words []uint16
+ var err error
+
+ if words, err = d.sendCommand(cmdGetAmbientPressure, nil); err != nil {
+ return nil, err
+ }
+ cfg.AmbientPressure = physic.Pascal * 100 * physic.Pressure(words[0])
+
+ if words, err = d.sendCommand(cmdGetASCEnabled, nil); err != nil {
+ return nil, err
+ }
+ cfg.ASCEnabled = words[0] != 0
+
+ if words, err = d.sendCommand(cmdGetASCInitialPeriod, nil); err != nil {
+ return nil, err
+ }
+ cfg.ASCInitialPeriod = time.Hour * time.Duration(words[0])
+
+ if words, err = d.sendCommand(cmdGetASCStandardPeriod, nil); err != nil {
+ return nil, err
+ }
+ cfg.ASCStandardPeriod = time.Hour * time.Duration(words[0])
+
+ if words, err = d.sendCommand(cmdGetASCTarget, nil); err != nil {
+ return nil, err
+ }
+ cfg.ASCTarget = PPM(words[0])
+
+ if words, err = d.sendCommand(cmdGetSerialNumber, nil); err != nil {
+ return nil, err
+ }
+ cfg.SerialNumber = int64(words[0])<<32 | int64(words[1])<<16 | int64(words[2])
+
+ if words, err = d.sendCommand(cmdGetSensorVariant, nil); err != nil {
+ return nil, err
+ }
+ if (words[0]>>11)&0x07 == 0 {
+ cfg.SensorType = SCD40
+ } else {
+ cfg.SensorType = SCD41
+ }
+
+ if words, err = d.sendCommand(cmdGetSensorAltitude, nil); err != nil {
+ return nil, err
+ }
+ cfg.SensorAltitude = physic.Distance(words[0]) * physic.Metre
+
+ if words, err = d.sendCommand(cmdGetTemperatureOffset, nil); err != nil {
+ return nil, err
+ }
+ cfg.TemperatureOffset = countToOffset(words[0])
+
+ return cfg, nil
+}
+
+// SetConfiguration alters the configuration of the sensor. Note that this call
+// does not persist the settings to EEPROM. You need to call Persist() to
+// commit the writes to EEPROM. If you do not persist changes, then those settings
+// will be lost when the unit is power-cycled.
+func (d *Dev) SetConfiguration(newCfg *DevConfig) error {
+
+ _ = d.Halt()
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ w := make([]uint16, 1)
+ currentConfig, err := d.GetConfiguration()
+ if err != nil {
+ return fmt.Errorf("scd4x GetConfiguration(): %w", err)
+ }
+
+ if currentConfig.AmbientPressure != newCfg.AmbientPressure {
+ w[0] = uint16(newCfg.AmbientPressure / (100 * physic.Pascal))
+ _, err := d.sendCommand(cmdSetAmbientPressure, w)
+ if err != nil {
+ return err
+ }
+ }
+
+ if currentConfig.ASCEnabled != newCfg.ASCEnabled {
+
+ if newCfg.ASCEnabled {
+ w[0] = 1
+ } else {
+ w[0] = 0
+ }
+ _, err := d.sendCommand(cmdSetASCEnabled, w)
+ if err != nil {
+ return err
+ }
+ }
+
+ if currentConfig.ASCInitialPeriod != newCfg.ASCInitialPeriod {
+ if newCfg.ASCInitialPeriod%4 != 0 {
+ return fmt.Errorf("scd4x: invalid initial period %d. must be a multiple of 4", newCfg.ASCInitialPeriod)
+ }
+ w[0] = uint16(newCfg.ASCInitialPeriod / time.Hour)
+ _, err := d.sendCommand(cmdSetASCInitialPeriod, w)
+ if err != nil {
+ return err
+ }
+ }
+
+ if currentConfig.ASCStandardPeriod != newCfg.ASCStandardPeriod {
+ if newCfg.ASCStandardPeriod%4 != 0 {
+ return fmt.Errorf("scd4x: invalid standard period %d. must be a multiple of 4", newCfg.ASCStandardPeriod)
+ }
+ w[0] = uint16(newCfg.ASCStandardPeriod / time.Hour)
+ _, err := d.sendCommand(cmdSetASCStandardPeriod, w)
+ if err != nil {
+ return err
+ }
+ }
+
+ if currentConfig.ASCTarget != newCfg.ASCTarget {
+ w[0] = uint16(newCfg.ASCTarget)
+ _, err := d.sendCommand(cmdSetASCTarget, w)
+ if err != nil {
+ return err
+ }
+ }
+
+ if currentConfig.SensorAltitude != newCfg.SensorAltitude {
+ w[0] = uint16(newCfg.SensorAltitude / physic.Metre)
+ _, err := d.sendCommand(cmdSetSensorAltitude, w)
+ if err != nil {
+ return err
+ }
+ }
+
+ if currentConfig.TemperatureOffset != newCfg.TemperatureOffset {
+ val := float64(newCfg.TemperatureOffset.Celsius()) * (float64(65535) / float64(175))
+ w[0] = uint16(val)
+ _, err := d.sendCommand(cmdSetTemperatureOffset, w)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// Halt stops continuous sensing if enabled, and if a SenseContinuous operation
+// is in progress, it too is halted.
+func (d *Dev) Halt() error {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ if d.sensing {
+ if d.chHalt != nil {
+ close(d.chHalt)
+ }
+ d.sensing = false
+ _, err := d.sendCommand(cmdStopMeasurement, nil)
+ time.Sleep(550 * time.Millisecond)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// Persist writes the current running configuration to the sensor EEPROM for
+// use on the next power-up.
+func (d *Dev) Persist() error {
+ _, err := d.sendCommand(cmdPersistSettings, nil)
+ return err
+}
+
+// Reset performs either a factory reset, or a re-load of settings from EEPROM
+// depending on the value of mode. During development, it was noticed that
+// ResetFactory DOES NOT reset AmbientPressure to 0.
+func (d *Dev) Reset(mode ResetMode) error {
+ var err error
+ if mode == ResetFactory {
+ _, err = d.sendCommand(cmdPerformFactoryReset, nil)
+ } else if mode == ResetEEPROM {
+ _, err = d.sendCommand(cmdReinit, nil)
+ } else {
+ err = fmt.Errorf("scd4x: invalid reset mode 0x%x", mode)
+ }
+ return err
+}
+
+func calcCRC(bytes []byte) byte {
+ polynomial := byte(0x31)
+ crc := byte(0xff)
+ for ix := range len(bytes) {
+ crc ^= bytes[ix]
+ for crc_bit := byte(8); crc_bit > 0; crc_bit-- {
+ if (crc & 0x80) == 0x80 {
+ crc = (crc << 1) ^ polynomial
+ } else {
+ crc = (crc << 1)
+ }
+ }
+ }
+ return crc
+}
+
+// makeWriteData converts the slice of word values into byte values with the
+// CRC following.
+func makeWriteData(data []uint16) []byte {
+ bytes := make([]byte, len(data)*3)
+ for ix, val := range data {
+ bytes[ix*3] = byte((val >> 8) & 0xff)
+ bytes[ix*3+1] = byte(val & 0xff)
+ bytes[ix*3+2] = calcCRC(bytes[ix*3 : ix*3+2])
+ }
+ return bytes
+}
+
+// All commands to read or write to the sensor go through this function.
+func (d *Dev) sendCommand(cmd command, writeData []uint16) ([]uint16, error) {
+
+ if d.sensing && !cmd.whileSensing {
+ // We're in sense mode and this command isn't compatible. Stop sensing.
+ if err := d.Halt(); err != nil {
+ return nil, err
+ }
+ }
+
+ w := make([]byte, 2)
+ w[0] = byte((cmd.cmdWord >> 8) & 0xff)
+ w[1] = byte(cmd.cmdWord & 0xff)
+ if writeData != nil {
+ writeBytes := makeWriteData(writeData)
+ w = append(w, writeBytes...)
+ }
+ var r []byte
+ if cmd.responseSize > 0 {
+ r = make([]byte, cmd.responseSize)
+ }
+
+ err := d.d.Tx(w, r)
+ if err != nil {
+ return nil, fmt.Errorf("scd4x cmd 0x%x: %w", cmd.cmdWord, err)
+ }
+ if cmd.responseSize == 0 {
+ return nil, nil
+ }
+
+ // OK, we need to convert the bytes into a slice of words and
+ // verify the CRC as we go.
+ result := make([]uint16, cmd.responseSize/3)
+ for ix := range len(result) {
+ crc := calcCRC(r[ix*3 : ix*3+2])
+ if r[ix*3+2] != crc {
+ return nil, fmt.Errorf("scd4x cmd 0x%x: invalid crc", cmd.cmdWord)
+ }
+
+ word := uint16(r[ix*3])<<8 | uint16(r[ix*3+1])
+
+ result[ix] = word
+ }
+
+ return result, nil
+}
+
+// start continuous sensing.
+func (d *Dev) start() error {
+ if d.sensing {
+ return nil
+ }
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ _, err := d.sendCommand(cmdWakeUp, nil)
+ if err != nil {
+ // If an SCD4x is in measurement mode, then any non-measurement mode
+ // command will return an error. In that case, send a stop measurement
+ // command, wait the specified time and try sending a re-init.
+ _, _ = d.sendCommand(cmdStopMeasurement, nil)
+ time.Sleep(550 * time.Millisecond)
+ }
+ time.Sleep(50 * time.Millisecond)
+
+ _, err = d.sendCommand(cmdStartMeasurement, nil)
+ if err == nil {
+ d.sensing = true
+ }
+ return err
+}
+
+// Formula used for temperature offset calculation.
+func countToOffset(count uint16) physic.Temperature {
+ frac := 175.0 / 65535.0
+ return physic.Temperature(frac * float64(count))
+}
+
+// countToTemp converts a device count to Temperature
+func countToTemp(count uint16) physic.Temperature {
+ frac := float64(count) / 65535.0
+ result := -45 + 175*frac
+ return physic.ZeroCelsius + physic.Temperature(float64(physic.Celsius)*result)
+}
+
+func countToHumidity(count uint16) physic.RelativeHumidity {
+ frac := float64(count) / 65535.0
+ return physic.RelativeHumidity(frac * 100.0 * float64(physic.PercentRH))
+}
+
+// Sense returns readings (Temperature, Humidity, and CO2 concentration in PPM)
+// from the device. Note that in normal acquisition mode, the minimum reading
+// period is 5 seconds. If you call this function more frequently than this,
+// it will block until data is ready.
+func (d *Dev) Sense(env *Env) error {
+ env.Temperature = 0
+ env.Humidity = 0
+ env.CO2 = 0
+ env.Pressure = 0
+
+ if !d.sensing {
+ err := d.start()
+ if err != nil {
+ return err
+ }
+ time.Sleep(5 * time.Second)
+ }
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ ready := false
+ mask := uint16(1<<11 - 1)
+ tCutoff := time.Now().Unix() + 6
+ for !ready && time.Now().Unix() < tCutoff {
+ words, err := d.sendCommand(cmdGetDataReadyStatus, nil)
+ ready = err == nil && (words[0]&mask) > 0
+ if !ready {
+ time.Sleep(time.Second)
+ }
+ }
+ if !ready {
+ return errors.New("scd4x: timeout waiting for data ready status")
+ }
+ words, err := d.sendCommand(cmdReadMeasurement, nil)
+ if err != nil {
+ return err
+ }
+ env.CO2 = PPM(words[0])
+ env.Temperature = countToTemp(words[1])
+ env.Humidity = countToHumidity(words[2])
+ return nil
+}
+
+// SenseContinuous continuously reads the sensor on the specified duration, and
+// writes readings to the returned channel. The sense time for the scd4x device
+// is 5 seconds in normal acquisition mode. If you specify a shorter period than
+// that, the routine will spin until the device indicates a reading is ready. To
+// terminate a continuous sense, call Halt().
+func (d *Dev) SenseContinuous(interval time.Duration) (<-chan Env, error) {
+ if d.chHalt != nil {
+ return nil, errors.New("scd4x: SenseContinuous() running already")
+ }
+ if !d.sensing {
+ if err := d.start(); err != nil {
+ return nil, err
+ }
+ }
+ channelSize := 16
+ channel := make(chan Env, channelSize)
+
+ go func() {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ defer close(channel)
+ if d.chHalt == nil {
+ d.chHalt = make(chan bool)
+ }
+
+ defer func() { d.chHalt = nil }()
+
+ for {
+ select {
+ case <-d.chHalt:
+ return
+ case <-ticker.C:
+ // do the reading and write to the channel.
+ e := Env{}
+ err := d.Sense(&e)
+ if err == nil && len(channel) < channelSize {
+ channel <- e
+ }
+ }
+ }
+ }()
+ return channel, nil
+}
+
+// Precision returns the sensor's resolution, or minimum value between steps the
+// device can make. The specified precision is 1 PPM for CO2, 1/65535 for temperature
+// and humidity.
+func (d *Dev) Precision(env *Env) {
+ countIncrement := float64(1.0) / float64((1<<16)-1)
+ env.Temperature = physic.Temperature(countIncrement * float64(physic.Celsius))
+ env.Pressure = 0
+ env.Humidity = physic.RelativeHumidity(float64(physic.PercentRH) * countIncrement)
+ env.CO2 = 1
+}
+
+func (d *Dev) String() string {
+ return fmt.Sprintf("scd4x: %s", d.d.String())
+}
diff --git a/scd4x/scd4x_test.go b/scd4x/scd4x_test.go
new file mode 100644
index 0000000..76bc555
--- /dev/null
+++ b/scd4x/scd4x_test.go
@@ -0,0 +1,439 @@
+// Copyright 2024 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.
+//
+// Unit tests for the package. Note that this supports running on a live
+// sensor, or using playback mode to simulate a live device.
+//
+// To use a live device, define the environment variable SCD4X and run go test.
+
+package scd4x
+
+import (
+ "fmt"
+ "os"
+ "testing"
+ "time"
+
+ "periph.io/x/conn/v3/i2c"
+ "periph.io/x/conn/v3/i2c/i2creg"
+ "periph.io/x/conn/v3/i2c/i2ctest"
+ "periph.io/x/conn/v3/physic"
+ "periph.io/x/host/v3"
+)
+
+var bus i2c.Bus
+var liveDevice bool = false
+
+// playback values for TestSense
+var sensePlayback = []i2ctest.IO{
+ {Addr: SensorAddress, W: []uint8{0x36, 0xf6}},
+ {Addr: SensorAddress, W: []uint8{0x21, 0xb1}},
+ {Addr: SensorAddress, W: []uint8{0xe4, 0xb8}, R: []uint8{0x80, 0x0, 0xa2}},
+ {Addr: SensorAddress, W: []uint8{0xe4, 0xb8}, R: []uint8{0x80, 0x0, 0xa2}},
+ {Addr: SensorAddress, W: []uint8{0xe4, 0xb8}, R: []uint8{0x80, 0x6, 0x4}},
+ {Addr: SensorAddress, W: []uint8{0xec, 0x5}, R: []uint8{0x2, 0x2c, 0xa3, 0x67, 0xd, 0x36, 0x4d, 0x8, 0xf1}}}
+
+var senseContinuousPlayback = []i2ctest.IO{
+ {Addr: SensorAddress, W: []uint8{0x36, 0xf6}},
+ {Addr: SensorAddress, W: []uint8{0x21, 0xb1}},
+ {Addr: SensorAddress, W: []uint8{0xe4, 0xb8}, R: []uint8{0x80, 0x6, 0x4}},
+ {Addr: SensorAddress, W: []uint8{0xec, 0x5}, R: []uint8{0x2, 0x1f, 0x35, 0x65, 0x82, 0xbb, 0x53, 0x5e, 0x2a}},
+ {Addr: SensorAddress, W: []uint8{0xe4, 0xb8}, R: []uint8{0x80, 0x6, 0x4}},
+ {Addr: SensorAddress, W: []uint8{0xec, 0x5}, R: []uint8{0x2, 0x22, 0xbc, 0x65, 0x39, 0xee, 0x55, 0x4b, 0xc6}},
+ {Addr: SensorAddress, W: []uint8{0xe4, 0xb8}, R: []uint8{0x80, 0x6, 0x4}},
+ {Addr: SensorAddress, W: []uint8{0xec, 0x5}, R: []uint8{0x2, 0x1c, 0x66, 0x64, 0xeb, 0x7c, 0x56, 0xd1, 0x9}},
+ {Addr: SensorAddress, W: []uint8{0xe4, 0xb8}, R: []uint8{0x80, 0x6, 0x4}},
+ {Addr: SensorAddress, W: []uint8{0xec, 0x5}, R: []uint8{0x2, 0x1f, 0x35, 0x64, 0xad, 0xe7, 0x58, 0x2f, 0xf9}},
+ {Addr: SensorAddress, W: []uint8{0xe4, 0xb8}, R: []uint8{0x80, 0x6, 0x4}},
+ {Addr: SensorAddress, W: []uint8{0xec, 0x5}, R: []uint8{0x2, 0x1f, 0x35, 0x64, 0x79, 0x27, 0x59, 0x71, 0x6c}},
+ {Addr: SensorAddress, W: []uint8{0xe4, 0xb8}, R: []uint8{0x80, 0x6, 0x4}},
+ {Addr: SensorAddress, W: []uint8{0xec, 0x5}, R: []uint8{0x2, 0x1f, 0x35, 0x64, 0x46, 0xcc, 0x5a, 0x8d, 0xbe}}}
+
+var getSetTestPlayback = []i2ctest.IO{
+ {Addr: SensorAddress, W: []uint8{0x36, 0xf6}},
+ {Addr: SensorAddress, W: []uint8{0x21, 0xb1}},
+ {Addr: SensorAddress, W: []uint8{0x3f, 0x86}},
+ {Addr: SensorAddress, W: []uint8{0x36, 0x46}},
+ {Addr: SensorAddress, W: []uint8{0xe0, 0x0}, R: []uint8{0x0, 0x5, 0x74}},
+ {Addr: SensorAddress, W: []uint8{0x23, 0x13}, R: []uint8{0x0, 0x1, 0xb0}},
+ {Addr: SensorAddress, W: []uint8{0x23, 0x40}, R: []uint8{0x0, 0x2c, 0x7a}},
+ {Addr: SensorAddress, W: []uint8{0x23, 0x4b}, R: []uint8{0x0, 0x9c, 0xc5}},
+ {Addr: SensorAddress, W: []uint8{0x23, 0x3f}, R: []uint8{0x1, 0x90, 0x4c}},
+ {Addr: SensorAddress, W: []uint8{0x36, 0x82}, R: []uint8{0x73, 0xb1, 0x19, 0xeb, 0x7, 0x7a, 0x3b, 0xc, 0x54}},
+ {Addr: SensorAddress, W: []uint8{0x20, 0x2f}, R: []uint8{0x4, 0x41, 0xe}},
+ {Addr: SensorAddress, W: []uint8{0x23, 0x22}, R: []uint8{0x0, 0x0, 0x81}},
+ {Addr: SensorAddress, W: []uint8{0x23, 0x18}, R: []uint8{0x5, 0xda, 0x29}},
+ {Addr: SensorAddress, W: []uint8{0xe0, 0x0}, R: []uint8{0x0, 0x5, 0x74}},
+ {Addr: SensorAddress, W: []uint8{0x23, 0x13}, R: []uint8{0x0, 0x1, 0xb0}},
+ {Addr: SensorAddress, W: []uint8{0x23, 0x40}, R: []uint8{0x0, 0x2c, 0x7a}},
+ {Addr: SensorAddress, W: []uint8{0x23, 0x4b}, R: []uint8{0x0, 0x9c, 0xc5}},
+ {Addr: SensorAddress, W: []uint8{0x23, 0x3f}, R: []uint8{0x1, 0x90, 0x4c}},
+ {Addr: SensorAddress, W: []uint8{0x36, 0x82}, R: []uint8{0x73, 0xb1, 0x19, 0xeb, 0x7, 0x7a, 0x3b, 0xc, 0x54}},
+ {Addr: SensorAddress, W: []uint8{0x20, 0x2f}, R: []uint8{0x4, 0x41, 0xe}},
+ {Addr: SensorAddress, W: []uint8{0x23, 0x22}, R: []uint8{0x0, 0x0, 0x81}},
+ {Addr: SensorAddress, W: []uint8{0x23, 0x18}, R: []uint8{0x5, 0xda, 0x29}},
+ {Addr: SensorAddress, W: []uint8{0xe0, 0x0, 0x0, 0xa, 0x5a}},
+ {Addr: SensorAddress, W: []uint8{0x24, 0x16, 0x0, 0x0, 0x81}},
+ {Addr: SensorAddress, W: []uint8{0x24, 0x45, 0x0, 0x30, 0x44}},
+ {Addr: SensorAddress, W: []uint8{0x24, 0x4e, 0x0, 0xa0, 0x7d}},
+ {Addr: SensorAddress, W: []uint8{0x24, 0x3a, 0x1, 0xa4, 0x4d}},
+ {Addr: SensorAddress, W: []uint8{0x24, 0x27, 0x6, 0x44, 0x22}},
+ {Addr: SensorAddress, W: []uint8{0xe0, 0x0}, R: []uint8{0x0, 0xa, 0x5a}},
+ {Addr: SensorAddress, W: []uint8{0x23, 0x13}, R: []uint8{0x0, 0x0, 0x81}},
+ {Addr: SensorAddress, W: []uint8{0x23, 0x40}, R: []uint8{0x0, 0x30, 0x44}},
+ {Addr: SensorAddress, W: []uint8{0x23, 0x4b}, R: []uint8{0x0, 0xa0, 0x7d}},
+ {Addr: SensorAddress, W: []uint8{0x23, 0x3f}, R: []uint8{0x1, 0xa4, 0x4d}},
+ {Addr: SensorAddress, W: []uint8{0x36, 0x82}, R: []uint8{0x73, 0xb1, 0x19, 0xeb, 0x7, 0x7a, 0x3b, 0xc, 0x54}},
+ {Addr: SensorAddress, W: []uint8{0x20, 0x2f}, R: []uint8{0x4, 0x41, 0xe}},
+ {Addr: SensorAddress, W: []uint8{0x23, 0x22}, R: []uint8{0x6, 0x44, 0x22}},
+ {Addr: SensorAddress, W: []uint8{0x23, 0x18}, R: []uint8{0x5, 0xda, 0x29}},
+ {Addr: SensorAddress, W: []uint8{0x36, 0x46}}}
+
+var basicStartup = []i2ctest.IO{
+ {Addr: SensorAddress, W: []uint8{0x36, 0xf6}},
+ {Addr: SensorAddress, W: []uint8{0x21, 0xb1}}}
+
+func init() {
+ var err error
+ // If the environment variable is set, assume we have a live device on
+ // the default i2c bus and use it for testing. If the variable is not
+ // present, then use the playback/read values.
+ if os.Getenv("SCD4X") != "" {
+ liveDevice = true
+ }
+ if _, err = host.Init(); err != nil {
+ fmt.Println(err)
+ }
+
+ if liveDevice {
+ bus, err = i2creg.Open("")
+ if err != nil {
+ fmt.Println(err)
+ }
+ // Add the recorder to dump the data stream when we're using a live device.
+ bus = &i2ctest.Record{Bus: bus}
+ } else {
+ bus = &i2ctest.Playback{DontPanic: true}
+ }
+
+}
+
+// getDev returns an scd4x device for testing connected to either a live
+// bus, or a playback bus. playbackOps is a slice of i2ctest.IO
+// operations to be used for playback mode. Ignored for live device
+// testing.
+func getDev(t *testing.T, playbackOps ...[]i2ctest.IO) (*Dev, error) {
+ if liveDevice {
+ if recorder, ok := bus.(*i2ctest.Record); ok {
+ // Clear the operations buffer.
+ recorder.Ops = make([]i2ctest.IO, 0, 32)
+ }
+ } else {
+ if len(playbackOps) == 1 {
+ pb := bus.(*i2ctest.Playback)
+ pb.Ops = playbackOps[0]
+ pb.Count = 0
+ }
+ }
+ dev, err := NewI2C(bus, SensorAddress)
+
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ return dev, err
+}
+
+// shutdown dumps the recorder values if we we're running a live device.
+func shutdown(t *testing.T) {
+ if recorder, ok := bus.(*i2ctest.Record); ok {
+ t.Logf("%#v", recorder.Ops)
+ }
+}
+
+func TestCRC(t *testing.T) {
+ tests := []struct {
+ bytes []byte
+ crc byte
+ }{
+ {bytes: []byte{0xbe, 0xef}, crc: 0x92},
+ {bytes: []byte{0x01, 0xa4}, crc: 0x4d},
+ }
+ for _, test := range tests {
+ res := calcCRC(test.bytes)
+ if res != test.crc {
+ t.Error(fmt.Errorf("crc calculation error bytes: %#v, result: 0x%x expected: 0x%x", test.bytes, res, test.crc))
+ }
+ }
+}
+
+func TestCountToTemperature(t *testing.T) {
+ tests := []struct {
+ count uint16
+ expected physic.Temperature
+ }{
+ {count: 0x6667, expected: physic.ZeroCelsius + 25*physic.Celsius},
+ }
+ for _, test := range tests {
+ result := countToTemp(test.count)
+ // round to 2 sig figs for the floating point comparison.
+ result -= result % (10 * physic.MilliKelvin)
+ if result != test.expected {
+ t.Errorf("received: %.8f expected %.8f", result.Celsius(), test.expected.Celsius())
+ }
+ }
+}
+
+func TestCountToHumidity(t *testing.T) {
+ result := countToHumidity(0x5eb9) // from the datasheet
+ // Truncate to 2 decimals for comparison.
+ result -= result % physic.MilliRH
+ expected := physic.RelativeHumidity(37 * physic.PercentRH)
+ if result != expected {
+ t.Errorf("unexpected value: %d expected %d", result, expected)
+ }
+}
+
+// Non-device basic functionality.
+func TestBasic(t *testing.T) {
+ dev, err := getDev(t, basicStartup)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = dev.Halt() }()
+ defer shutdown(t)
+
+ env := Env{}
+ dev.Precision(&env)
+ t.Logf("scd4x.Precision()=%#v\n", env)
+ if env.CO2 != 1 || env.Humidity != physic.TenthMicroRH || env.Temperature != (15259*physic.NanoKelvin) {
+ t.Error(fmt.Errorf("incorrect value for Precision(): %#v", env))
+ }
+
+ s := dev.String()
+ t.Logf("dev.String()=%s", s)
+ if len(s) == 0 {
+ t.Error("Dev.String() returned empty value.")
+ }
+}
+
+func TestSense(t *testing.T) {
+ dev, err := getDev(t, sensePlayback)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = dev.Halt() }()
+ defer shutdown(t)
+ env := Env{}
+ err = dev.Sense(&env)
+ if err != nil {
+ t.Error(err)
+ } else {
+ t.Log(env.String())
+ }
+}
+
+func TestSenseContinuous(t *testing.T) {
+ readings := 6
+ timeBase := time.Second
+ if liveDevice {
+ timeBase *= 10
+ }
+ dev, err := getDev(t, senseContinuousPlayback)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = dev.Halt() }()
+ defer shutdown(t)
+ t.Log("dev.sensing=", dev.sensing)
+ ch, err := dev.SenseContinuous(timeBase)
+ if err != nil {
+ t.Error(err)
+ }
+
+ go func() {
+ time.Sleep(time.Duration(readings) * timeBase)
+ _ = dev.Halt()
+ }()
+ received := 0
+ for env := range ch {
+ t.Log(env.String())
+ received += 1
+ }
+ if received < (readings-1) || received > readings {
+ t.Errorf("SenseContinuous() expected at least %d readings, got %d", readings-1, received)
+ }
+
+}
+
+func TestGetSetConfiguration(t *testing.T) {
+ dev, err := getDev(t, getSetTestPlayback)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = dev.Halt()
+ if err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(time.Second)
+ // Baseline our settings
+ err = dev.Reset(ResetEEPROM)
+ if err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(100 * time.Millisecond)
+ defer shutdown(t)
+ cfg, err := dev.GetConfiguration()
+
+ if err != nil {
+ t.Error(err)
+ }
+ t.Logf("existing configuration: %#v", cfg)
+ cfg.AmbientPressure += 500 * physic.Pascal
+ cfg.ASCEnabled = !cfg.ASCEnabled
+ cfg.ASCInitialPeriod += 4 * time.Hour
+ cfg.ASCStandardPeriod += 4 * time.Hour
+ cfg.ASCTarget += 20
+ cfg.SensorAltitude = 1604 * physic.Metre
+
+ err = dev.SetConfiguration(cfg)
+ if err != nil {
+ t.Error(err)
+ }
+ read, err := dev.GetConfiguration()
+ if err != nil {
+ t.Error(err)
+ }
+ t.Logf("new configuration: %#v", read)
+
+ if read.AmbientPressure != cfg.AmbientPressure {
+ t.Errorf("scd4x: error setting ambient pressure. found: %s (%d) expected: %s (%d)", read.AmbientPressure.String(), read.AmbientPressure, cfg.AmbientPressure.String(), cfg.AmbientPressure)
+ }
+ if read.ASCEnabled != cfg.ASCEnabled {
+ t.Errorf("scd4x: error setting asc enabled. Found %t expected %t", read.ASCEnabled, cfg.ASCEnabled)
+ }
+ if read.ASCInitialPeriod != cfg.ASCInitialPeriod {
+ t.Errorf("scd4x: error setting initial period. found: %d expected %d", read.ASCInitialPeriod, cfg.ASCInitialPeriod)
+ }
+ if read.ASCStandardPeriod != cfg.ASCStandardPeriod {
+ t.Errorf("scd4x: error setting standard period. found: %d expected %d", read.ASCStandardPeriod, cfg.ASCStandardPeriod)
+ }
+ if read.ASCTarget != cfg.ASCTarget {
+ t.Errorf("scd4x: error setting asc target. found %d expected %d", read.ASCTarget, cfg.ASCTarget)
+ }
+ if read.SensorAltitude != cfg.SensorAltitude {
+ t.Errorf("scd4x: error setting sensor altitude. found %d expected %d", read.SensorAltitude/physic.Metre, cfg.SensorAltitude/physic.Metre)
+ }
+
+ _ = dev.Reset(ResetEEPROM) // and go back to our known state.
+}
+
+// Since there are limited read/write cycles, by default DO NOT test persist
+// and reset factory. To perform the tests, define the environment variable
+// SCDRESET. Running this test will destructively clear customized values
+// previously programmed into the device.
+func TestPersistAndResetFactory(t *testing.T) {
+ if !liveDevice || os.Getenv("SCDRESET") == "" {
+ t.Skip("using live device and SCDRESET not defined. skipping")
+ }
+ dev, err := getDev(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = dev.Halt()
+ if err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(time.Second)
+
+ // Read the current running configuration.
+ cfg, err := dev.GetConfiguration()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer shutdown(t)
+
+ // Set the altitude to the current Altitude+1000M and write it to the device.
+ if cfg.SensorAltitude < (2000 * physic.Metre) {
+ cfg.SensorAltitude += 1000 * physic.Metre
+ } else {
+ cfg.SensorAltitude -= (500 * physic.Metre)
+ }
+ t.Logf("updating sensor altitude to %s", cfg.SensorAltitude)
+
+ err = dev.SetConfiguration(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Now, re-read the configuration to verify the write worked.
+ updatedCfg, err := dev.GetConfiguration()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updatedCfg.SensorAltitude != cfg.SensorAltitude {
+ t.Fatalf("scd41x: Change sensor altitude failed. Read: %s Expected: %s", updatedCfg.SensorAltitude.String(), cfg.SensorAltitude)
+ }
+
+ // OK, now Persist()
+ err = dev.Persist()
+ if err != nil {
+ t.Error(err)
+ }
+ _ = dev.Reset(ResetEEPROM)
+ time.Sleep(time.Second)
+
+ // OK, now write 0
+ cfg.SensorAltitude = 0
+ err = dev.SetConfiguration(cfg)
+ if err != nil {
+ t.Error(err)
+ }
+ // Reset Settings to EEPROM
+ err = dev.Reset(ResetEEPROM)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Sometimes you have to wait for it to come to the party...
+ for range 5 {
+ _ = dev.Halt()
+ // Now, re-read the configuration
+ cfg, err = dev.GetConfiguration()
+ if err != nil {
+ t.Logf("GetConfiguration Failed: %s Sleeping before retry.", err)
+ time.Sleep(time.Second)
+ } else {
+ break
+ }
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // The expected value is the original value +1000M
+ if cfg.SensorAltitude != updatedCfg.SensorAltitude {
+ t.Errorf("Error using reset to eeprom. Expected SensorAltitude: %s Found: %s", updatedCfg.SensorAltitude, cfg.SensorAltitude)
+ }
+
+ t.Logf("current configuration: %#v", cfg)
+ // Almost there. Now, reset to factory and read sensor-altitude.
+ t.Logf("calling reset factory")
+ err = dev.Reset(ResetFactory)
+ if err != nil {
+ t.Error(err)
+ }
+ time.Sleep(time.Second)
+
+ cfg, err = dev.GetConfiguration()
+
+ if err != nil {
+ t.Error(err)
+ }
+ t.Logf("Reset to factory configuration is now: %#v", cfg)
+
+ if cfg.SensorAltitude != 0 {
+ t.Errorf("Error resetting to factory. Sensor Altitude: %s expected 0m", cfg.SensorAltitude)
+ }
+}