From 8eb8bf344477981c502faa6baffa54455006823a Mon Sep 17 00:00:00 2001 From: Andre Courchesne Date: Tue, 4 Aug 2026 13:26:31 -0700 Subject: [PATCH] scd30: add CO2 sensor driver --- examples/scd30/main.go | 55 +++++++ scd30/registers.go | 20 +++ scd30/scd30.go | 221 ++++++++++++++++++++++++++ scd30/scd30_test.go | 353 +++++++++++++++++++++++++++++++++++++++++ smoketest.sh | 1 + 5 files changed, 650 insertions(+) create mode 100644 examples/scd30/main.go create mode 100644 scd30/registers.go create mode 100644 scd30/scd30.go create mode 100644 scd30/scd30_test.go diff --git a/examples/scd30/main.go b/examples/scd30/main.go new file mode 100644 index 000000000..7591d755c --- /dev/null +++ b/examples/scd30/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "machine" + "time" + + "tinygo.org/x/drivers" + "tinygo.org/x/drivers/scd30" +) + +var sensor = scd30.New(machine.I2C0) + +func main() { + // The SCD30 requires clock stretching and supports I2C speeds up to 100kHz. + if err := machine.I2C0.Configure(machine.I2CConfig{Frequency: 50 * machine.KHz}); err != nil { + println("could not configure I2C:", err.Error()) + return + } + + if !sensor.Connected() { + println("SCD30 not detected") + return + } + if err := sensor.Configure(scd30.DefaultConfig); err != nil { + println("could not configure SCD30:", err.Error()) + return + } + if err := sensor.StartContinuousMeasurement(0); err != nil { + println("could not start SCD30:", err.Error()) + return + } + + for { + ready, err := sensor.DataReady() + if err != nil { + println("could not read SCD30 status:", err.Error()) + time.Sleep(time.Second) + continue + } + if !ready { + time.Sleep(250 * time.Millisecond) + continue + } + + if err := sensor.Update(drivers.AllMeasurements); err != nil { + println("could not read SCD30 measurement:", err.Error()) + time.Sleep(time.Second) + continue + } + + println("CO2 (ppm):", sensor.CO2()) + println("temperature (mC):", sensor.Temperature()) + println("humidity (0.01%):", sensor.Humidity()) + } +} diff --git a/scd30/registers.go b/scd30/registers.go new file mode 100644 index 000000000..ac1ce124a --- /dev/null +++ b/scd30/registers.go @@ -0,0 +1,20 @@ +package scd30 + +const ( + // Address is the default and only I2C address of the SCD30. + Address uint16 = 0x61 + + commandStartContinuousMeasurement = 0x0010 + commandStopContinuousMeasurement = 0x0104 + commandDataReady = 0x0202 + commandReadMeasurement = 0x0300 + commandSetMeasurementInterval = 0x4600 + commandSetAutoCalibration = 0x5306 +) + +const ( + minimumMeasurementInterval = 2 + maximumMeasurementInterval = 1800 + minimumAmbientPressure = 700 + maximumAmbientPressure = 1400 +) diff --git a/scd30/scd30.go b/scd30/scd30.go new file mode 100644 index 000000000..c1f99bc78 --- /dev/null +++ b/scd30/scd30.go @@ -0,0 +1,221 @@ +// Package scd30 provides a driver for the Sensirion SCD30 CO2, temperature, +// and humidity sensor. +// +// Datasheet: https://sensirion.com/media/documents/D7CEEF4A/6165372F/Sensirion_CO2_Sensors_SCD30_Interface_Description.pdf +package scd30 // import "tinygo.org/x/drivers/scd30" + +import ( + "encoding/binary" + "errors" + "math" + "time" + + "tinygo.org/x/drivers" +) + +const readDelay = 4 * time.Millisecond + +var ( + ErrCRC = errors.New("scd30: invalid CRC") + + ErrInvalidInterval = errors.New("scd30: measurement interval must be between 2 and 1800 seconds") + + ErrInvalidAmbientPressure = errors.New("scd30: ambient pressure must be zero or between 700 and 1400 mbar") +) + +// Config contains the SCD30 continuous measurement configuration. +type Config struct { + // MeasurementInterval is the interval between measurements in seconds and + // must be between 2 and 1800. + MeasurementInterval uint16 + + // AutomaticSelfCalibration enables or disables automatic self-calibration. + AutomaticSelfCalibration bool +} + +// DefaultConfig contains the power-on defaults documented for the SCD30. +var DefaultConfig = Config{ + MeasurementInterval: 2, + AutomaticSelfCalibration: false, +} + +// Device is a Sensirion SCD30 sensor connected over I2C. +type Device struct { + bus drivers.I2C + tx [5]byte + rx [18]byte + + co2 int32 + temperature int32 + humidity int32 +} + +var _ drivers.Sensor = (*Device)(nil) + +// New returns a new SCD30 driver. It performs no I/O. +func New(bus drivers.I2C) *Device { + return &Device{bus: bus} +} + +// Configure applies the continuous measurement interval and automatic +// self-calibration settings. It does not start continuous measurement. +func (d *Device) Configure(config Config) error { + if err := d.SetMeasurementInterval(config.MeasurementInterval); err != nil { + return err + } + return d.SetAutomaticSelfCalibration(config.AutomaticSelfCalibration) +} + +// Connected reports whether an SCD30 responds with a valid data-ready status. +func (d *Device) Connected() bool { + _, err := d.DataReady() + return err == nil +} + +// SetMeasurementInterval sets the continuous measurement interval in seconds. +func (d *Device) SetMeasurementInterval(seconds uint16) error { + if seconds < minimumMeasurementInterval || seconds > maximumMeasurementInterval { + return ErrInvalidInterval + } + return d.writeCommandWithArgument(commandSetMeasurementInterval, seconds) +} + +// SetAutomaticSelfCalibration enables or disables automatic self-calibration. +func (d *Device) SetAutomaticSelfCalibration(enabled bool) error { + var value uint16 + if enabled { + value = 1 + } + return d.writeCommandWithArgument(commandSetAutoCalibration, value) +} + +// StartContinuousMeasurement begins periodic measurements. Ambient pressure +// must be zero to disable pressure compensation, or between 700 and 1400 mbar. +func (d *Device) StartContinuousMeasurement(ambientPressure uint16) error { + if ambientPressure != 0 && (ambientPressure < minimumAmbientPressure || ambientPressure > maximumAmbientPressure) { + return ErrInvalidAmbientPressure + } + return d.writeCommandWithArgument(commandStartContinuousMeasurement, ambientPressure) +} + +// StopContinuousMeasurement stops periodic measurements. +func (d *Device) StopContinuousMeasurement() error { + return d.writeCommand(commandStopContinuousMeasurement) +} + +// DataReady reports whether a new measurement can be read. +func (d *Device) DataReady() (bool, error) { + if err := d.readCommand(commandDataReady, d.rx[:3]); err != nil { + return false, err + } + value, err := decodeWord(d.rx[:3]) + if err != nil { + return false, err + } + return value != 0, nil +} + +// ReadMeasurement reads and caches the latest CO2, temperature, and humidity +// measurement. Use DataReady before calling ReadMeasurement. +func (d *Device) ReadMeasurement() error { + if err := d.readCommand(commandReadMeasurement, d.rx[:18]); err != nil { + return err + } + + var data [12]byte + for source, destination := 0, 0; source < 18; source, destination = source+3, destination+2 { + value, err := decodeWord(d.rx[source : source+3]) + if err != nil { + return err + } + binary.BigEndian.PutUint16(data[destination:destination+2], value) + } + + co2 := decodeFloat32(data[0:4]) + temperature := decodeFloat32(data[4:8]) + humidity := decodeFloat32(data[8:12]) + + d.co2 = roundFixed(co2, 1) + d.temperature = roundFixed(temperature, 1000) + d.humidity = roundFixed(humidity, 100) + return nil +} + +// Update reads and caches all measurements if any supported measurement was +// requested. The SCD30 provides all three values in a single transaction. +func (d *Device) Update(which drivers.Measurement) error { + if which&(drivers.Concentration|drivers.Temperature|drivers.Humidity) == 0 { + return nil + } + return d.ReadMeasurement() +} + +// CO2 returns the last read CO2 concentration in parts per million. +func (d *Device) CO2() int32 { + return d.co2 +} + +// Temperature returns the last read temperature in millidegrees Celsius. +func (d *Device) Temperature() int32 { + return d.temperature +} + +// Humidity returns the last read relative humidity in hundredths of a percent. +func (d *Device) Humidity() int32 { + return d.humidity +} + +func (d *Device) readCommand(command uint16, response []byte) error { + if err := d.writeCommand(command); err != nil { + return err + } + // The datasheet requires a delay greater than 3ms before reading. + time.Sleep(readDelay) + return d.bus.Tx(Address, nil, response) +} + +func (d *Device) writeCommand(command uint16) error { + binary.BigEndian.PutUint16(d.tx[:2], command) + return d.bus.Tx(Address, d.tx[:2], nil) +} + +func (d *Device) writeCommandWithArgument(command, argument uint16) error { + binary.BigEndian.PutUint16(d.tx[:2], command) + binary.BigEndian.PutUint16(d.tx[2:4], argument) + d.tx[4] = crc8(d.tx[2:4]) + return d.bus.Tx(Address, d.tx[:5], nil) +} + +func decodeWord(data []byte) (uint16, error) { + if len(data) != 3 || crc8(data[:2]) != data[2] { + return 0, ErrCRC + } + return binary.BigEndian.Uint16(data[:2]), nil +} + +func decodeFloat32(data []byte) float32 { + return math.Float32frombits(binary.BigEndian.Uint32(data)) +} + +func roundFixed(value float32, scale int32) int32 { + scaled := value * float32(scale) + if scaled < 0 { + return int32(scaled - 0.5) + } + return int32(scaled + 0.5) +} + +func crc8(data []byte) byte { + value := byte(0xff) + for _, current := range data { + value ^= current + for bit := 0; bit < 8; bit++ { + if value&0x80 != 0 { + value = value<<1 ^ 0x31 + } else { + value <<= 1 + } + } + } + return value +} diff --git a/scd30/scd30_test.go b/scd30/scd30_test.go new file mode 100644 index 000000000..e61e027a1 --- /dev/null +++ b/scd30/scd30_test.go @@ -0,0 +1,353 @@ +package scd30 + +import ( + "bytes" + "errors" + "math" + "testing" + + "tinygo.org/x/drivers" +) + +type transaction struct { + write []byte + response []byte + err error +} + +type fakeBus struct { + t *testing.T + transactions []transaction + next int + wrongAddress bool + extraTransfer bool +} + +func (b *fakeBus) Tx(address uint16, write, read []byte) error { + b.t.Helper() + if address != Address { + b.wrongAddress = true + b.t.Errorf("address = %#x, want %#x", address, Address) + return nil + } + if b.next >= len(b.transactions) { + b.extraTransfer = true + b.t.Errorf("unexpected transaction: write=%#v read-len=%d", write, len(read)) + return nil + } + + want := b.transactions[b.next] + b.next++ + if !bytes.Equal(write, want.write) { + b.t.Errorf("write = %#v, want %#v", write, want.write) + } + if len(read) != len(want.response) { + b.t.Errorf("read length = %d, want %d", len(read), len(want.response)) + } + if want.err != nil { + return want.err + } + copy(read, want.response) + return nil +} + +func (b *fakeBus) verify(t *testing.T) { + t.Helper() + if b.next != len(b.transactions) { + t.Errorf("completed %d transactions, want %d", b.next, len(b.transactions)) + } + if b.wrongAddress { + t.Error("driver used an unexpected address") + } + if b.extraTransfer { + t.Error("driver performed an unexpected transfer") + } +} + +func newFakeBus(t *testing.T, transactions ...transaction) *fakeBus { + t.Helper() + bus := &fakeBus{t: t, transactions: transactions} + t.Cleanup(func() { bus.verify(t) }) + return bus +} + +func TestConfigure(t *testing.T) { + bus := newFakeBus(t, + transaction{write: []byte{0x46, 0x00, 0x00, 0x0a, 0x5a}}, + transaction{write: []byte{0x53, 0x06, 0x00, 0x01, 0xb0}}, + ) + err := New(bus).Configure(Config{ + MeasurementInterval: 10, + AutomaticSelfCalibration: true, + }) + if err != nil { + t.Fatal(err) + } +} + +func TestDefaultConfig(t *testing.T) { + if DefaultConfig.MeasurementInterval != 2 { + t.Errorf("default interval = %d, want 2", DefaultConfig.MeasurementInterval) + } + if DefaultConfig.AutomaticSelfCalibration { + t.Error("automatic self-calibration should be disabled by default") + } +} + +func TestMeasurementIntervalBounds(t *testing.T) { + tests := []struct { + name string + seconds uint16 + valid bool + }{ + {name: "below minimum", seconds: 1}, + {name: "minimum", seconds: 2, valid: true}, + {name: "maximum", seconds: 1800, valid: true}, + {name: "above maximum", seconds: 1801}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var transactions []transaction + if test.valid { + argument := []byte{byte(test.seconds >> 8), byte(test.seconds)} + transactions = append(transactions, transaction{write: []byte{ + 0x46, 0x00, argument[0], argument[1], crc8(argument), + }}) + } + bus := newFakeBus(t, transactions...) + err := New(bus).SetMeasurementInterval(test.seconds) + if test.valid && err != nil { + t.Fatal(err) + } + if !test.valid && !errors.Is(err, ErrInvalidInterval) { + t.Fatalf("error = %v, want %v", err, ErrInvalidInterval) + } + }) + } +} + +func TestAmbientPressureBounds(t *testing.T) { + tests := []struct { + pressure uint16 + valid bool + }{ + {pressure: 0, valid: true}, + {pressure: 699}, + {pressure: 700, valid: true}, + {pressure: 1400, valid: true}, + {pressure: 1401}, + } + + for _, test := range tests { + t.Run(stringName(test.pressure), func(t *testing.T) { + var transactions []transaction + if test.valid { + argument := []byte{byte(test.pressure >> 8), byte(test.pressure)} + transactions = append(transactions, transaction{write: []byte{ + 0x00, 0x10, argument[0], argument[1], crc8(argument), + }}) + } + bus := newFakeBus(t, transactions...) + err := New(bus).StartContinuousMeasurement(test.pressure) + if test.valid && err != nil { + t.Fatal(err) + } + if !test.valid && !errors.Is(err, ErrInvalidAmbientPressure) { + t.Fatalf("error = %v, want %v", err, ErrInvalidAmbientPressure) + } + }) + } +} + +func TestStopContinuousMeasurement(t *testing.T) { + bus := newFakeBus(t, transaction{write: []byte{0x01, 0x04}}) + if err := New(bus).StopContinuousMeasurement(); err != nil { + t.Fatal(err) + } +} + +func TestDataReady(t *testing.T) { + for _, test := range []struct { + name string + value uint16 + ready bool + }{ + {name: "not ready", value: 0}, + {name: "ready", value: 1, ready: true}, + } { + t.Run(test.name, func(t *testing.T) { + bus := newFakeBus(t, + transaction{write: []byte{0x02, 0x02}}, + transaction{response: encodeWord(test.value)}, + ) + ready, err := New(bus).DataReady() + if err != nil { + t.Fatal(err) + } + if ready != test.ready { + t.Errorf("ready = %v, want %v", ready, test.ready) + } + }) + } +} + +func TestDataReadyRejectsBadCRC(t *testing.T) { + response := encodeWord(1) + response[2]++ + bus := newFakeBus(t, + transaction{write: []byte{0x02, 0x02}}, + transaction{response: response}, + ) + _, err := New(bus).DataReady() + if !errors.Is(err, ErrCRC) { + t.Fatalf("error = %v, want %v", err, ErrCRC) + } +} + +func TestReadMeasurement(t *testing.T) { + response := appendFloat(nil, 800.5) + response = appendFloat(response, 23.25) + response = appendFloat(response, 48.75) + bus := newFakeBus(t, + transaction{write: []byte{0x03, 0x00}}, + transaction{response: response}, + ) + + device := New(bus) + if err := device.ReadMeasurement(); err != nil { + t.Fatal(err) + } + if got := device.CO2(); got != 801 { + t.Errorf("CO2 = %d, want 801 ppm", got) + } + if got := device.Temperature(); got != 23250 { + t.Errorf("temperature = %d, want 23250 mC", got) + } + if got := device.Humidity(); got != 4875 { + t.Errorf("humidity = %d, want 4875 hundredths of a percent", got) + } +} + +func TestReadMeasurementRoundsNegativeTemperature(t *testing.T) { + response := appendFloat(nil, 400) + response = appendFloat(response, -10.1236) + response = appendFloat(response, 50) + bus := newFakeBus(t, + transaction{write: []byte{0x03, 0x00}}, + transaction{response: response}, + ) + + device := New(bus) + if err := device.ReadMeasurement(); err != nil { + t.Fatal(err) + } + if got := device.Temperature(); got != -10124 { + t.Errorf("temperature = %d, want -10124 mC", got) + } +} + +func TestReadMeasurementRejectsBadCRCWithoutChangingCache(t *testing.T) { + for corruptWord := 0; corruptWord < 6; corruptWord++ { + t.Run(stringName(uint16(corruptWord)), func(t *testing.T) { + response := appendFloat(nil, 800.5) + response = appendFloat(response, 23.25) + response = appendFloat(response, 48.75) + response[corruptWord*3+2]++ + bus := newFakeBus(t, + transaction{write: []byte{0x03, 0x00}}, + transaction{response: response}, + ) + + device := New(bus) + device.co2 = 500 + device.temperature = 21000 + device.humidity = 4000 + err := device.ReadMeasurement() + if !errors.Is(err, ErrCRC) { + t.Fatalf("error = %v, want %v", err, ErrCRC) + } + if device.CO2() != 500 || device.Temperature() != 21000 || device.Humidity() != 4000 { + t.Errorf("cache changed after CRC error: CO2=%d temperature=%d humidity=%d", + device.CO2(), device.Temperature(), device.Humidity()) + } + }) + } +} + +func TestUpdateIgnoresUnsupportedMeasurements(t *testing.T) { + bus := newFakeBus(t) + if err := New(bus).Update(drivers.Pressure); err != nil { + t.Fatal(err) + } +} + +func TestBusErrorsAreReturned(t *testing.T) { + wantErr := errors.New("I2C failure") + for _, test := range []struct { + name string + transactions []transaction + action func(*Device) error + }{ + { + name: "command write", + transactions: []transaction{{write: []byte{0x02, 0x02}, err: wantErr}}, + action: func(device *Device) error { + _, err := device.DataReady() + return err + }, + }, + { + name: "response read", + transactions: []transaction{ + {write: []byte{0x02, 0x02}}, + {response: make([]byte, 3), err: wantErr}, + }, + action: func(device *Device) error { + _, err := device.DataReady() + return err + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + bus := newFakeBus(t, test.transactions...) + if err := test.action(New(bus)); !errors.Is(err, wantErr) { + t.Fatalf("error = %v, want %v", err, wantErr) + } + }) + } +} + +func TestCRC8(t *testing.T) { + if got := crc8([]byte{0x00, 0x02}); got != 0xe3 { + t.Fatalf("crc = %#x, want 0xe3", got) + } +} + +func appendFloat(destination []byte, value float32) []byte { + bits := math.Float32bits(value) + destination = append(destination, encodeWord(uint16(bits>>16))...) + destination = append(destination, encodeWord(uint16(bits))...) + return destination +} + +func encodeWord(value uint16) []byte { + result := []byte{byte(value >> 8), byte(value), 0} + result[2] = crc8(result[:2]) + return result +} + +func stringName(value uint16) string { + const digits = "0123456789" + if value == 0 { + return "0" + } + var buffer [5]byte + position := len(buffer) + for value > 0 { + position-- + buffer[position] = digits[value%10] + value /= 10 + } + return string(buffer[position:]) +} diff --git a/smoketest.sh b/smoketest.sh index 2990485cf..d357d729a 100755 --- a/smoketest.sh +++ b/smoketest.sh @@ -130,6 +130,7 @@ tinygo build -size short -o ./build/test.uf2 -target=pico ./examples/ssd1289/mai tinygo build -size short -o ./build/test.hex -target=pico ./examples/irremote/main.go tinygo build -size short -o ./build/test.hex -target=badger2040 ./examples/uc8151/main.go tinygo build -size short -o ./build/test.hex -target=badger2040 ./examples/waveshare-epd/epd2in9v2/main.go +tinygo build -size short -o ./build/test.uf2 -target=pico ./examples/scd30/main.go tinygo build -size short -o ./build/test.uf2 -target=pico ./examples/scd4x/main.go tinygo build -size short -o ./build/test.uf2 -target=circuitplay-express ./examples/makeybutton/main.go tinygo build -size short -o ./build/test.hex -target=arduino-nano33 ./examples/ds18b20/main.go