scd4x: SCD4x CO2 Sensor - Initial Add (#81)

* Initial Add

* Fix lint issue and inversion of skip testing
pull/86/head
gsexton 2 years ago committed by GitHub
parent 5bc0352f3a
commit 9a938c42a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,56 @@
# Sensirion SCD4x CO<sub>2</sub> Sensors
## Overview
This package provides a driver for the Sensirion SCD4x CO<sub>2</sub> sensors. This is a
compact sensor that provides temperature, humidity, and CO<sub>2</sub> 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 I<sup>2</sup>C 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
I<sup>2</sup>C 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.

@ -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

@ -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}
}

@ -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())
}

@ -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)
}
}
Loading…
Cancel
Save