From 0030a0e7c12a0814c671d6ec1a64f577da8ae293 Mon Sep 17 00:00:00 2001 From: George Sexton Date: Fri, 8 Nov 2024 13:56:28 -0700 Subject: [PATCH 1/2] Initial Add --- scd4x/README.md | 56 ++++ scd4x/doc.go | 12 + scd4x/example_test.go | 63 +++++ scd4x/scd4x.go | 623 ++++++++++++++++++++++++++++++++++++++++++ scd4x/scd4x_test.go | 439 +++++++++++++++++++++++++++++ 5 files changed, 1193 insertions(+) create mode 100644 scd4x/README.md create mode 100644 scd4x/doc.go create mode 100644 scd4x/example_test.go create mode 100644 scd4x/scd4x.go create mode 100644 scd4x/scd4x_test.go 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..2fa435d --- /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 mulitple 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 mulitple 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..0f31f3a --- /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) + } +} From c628dfe9090d89db6afb397876dde03caf902780 Mon Sep 17 00:00:00 2001 From: George Sexton Date: Fri, 8 Nov 2024 14:04:36 -0700 Subject: [PATCH 2/2] Fix lint issue and inversion of skip testing --- scd4x/scd4x.go | 4 ++-- scd4x/scd4x_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scd4x/scd4x.go b/scd4x/scd4x.go index 2fa435d..14128fe 100644 --- a/scd4x/scd4x.go +++ b/scd4x/scd4x.go @@ -313,7 +313,7 @@ func (d *Dev) SetConfiguration(newCfg *DevConfig) error { if currentConfig.ASCInitialPeriod != newCfg.ASCInitialPeriod { if newCfg.ASCInitialPeriod%4 != 0 { - return fmt.Errorf("scd4x: invalid initial period %d. must be a mulitple of 4", newCfg.ASCInitialPeriod) + 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) @@ -324,7 +324,7 @@ func (d *Dev) SetConfiguration(newCfg *DevConfig) error { if currentConfig.ASCStandardPeriod != newCfg.ASCStandardPeriod { if newCfg.ASCStandardPeriod%4 != 0 { - return fmt.Errorf("scd4x: invalid standard period %d. must be a mulitple of 4", newCfg.ASCStandardPeriod) + 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) diff --git a/scd4x/scd4x_test.go b/scd4x/scd4x_test.go index 0f31f3a..76bc555 100644 --- a/scd4x/scd4x_test.go +++ b/scd4x/scd4x_test.go @@ -334,7 +334,7 @@ func TestGetSetConfiguration(t *testing.T) { // SCDRESET. Running this test will destructively clear customized values // previously programmed into the device. func TestPersistAndResetFactory(t *testing.T) { - if liveDevice && os.Getenv("SCDRESET") == "" { + if !liveDevice || os.Getenv("SCDRESET") == "" { t.Skip("using live device and SCDRESET not defined. skipping") } dev, err := getDev(t)