commit 102b6c17f241737ae877acc6c02122decb980ff6
parent e370b960405e17b3b4ece23f794e9d3b43d54e56
Author: Brian C. Lane <bcl@brianlane.com>
Date: Fri, 28 May 2021 15:01:29 -0700
Add library files and tests
Diffstat:
13 files changed, 1035 insertions(+), 0 deletions(-)
diff --git a/README.md b/README.md
@@ -0,0 +1,22 @@
+# Air Quality Sensor library
+
+This library implements support for two air quality sensors, the PMSA003i and
+the SGP30 for use with the periph.io hardware library.
+
+
+## PMSA003i
+
+The PMSA003i is a digital particle concentration sensor which can be used to
+obtain the number of suspended particles in the air. You can purchase the
+sensor from a variety of places, including [AdaFruit](https://www.adafruit.com/product/4632).
+
+The datasheet can be [found here](https://cdn-shop.adafruit.com/product-files/4632/4505_PMSA003I_series_data_manual_English_V2.6.pdf).
+
+
+## SGP30
+
+The SGP30 is a gas sensor that can measure CO<sub>2</sub> and Total Volatile
+Organic Compunds (TVOC) in the air. You can purchase the sensor from the usual
+places, including from [AdaFruit](https://www.adafruit.com/product/3709).
+
+The datasheet can be [found here](https://cdn-learn.adafruit.com/assets/assets/000/050/058/original/Sensirion_Gas_Sensors_SGP30_Datasheet_EN.pdf).
diff --git a/cmd/run-pmsa003i/main.go b/cmd/run-pmsa003i/main.go
@@ -0,0 +1,58 @@
+// Copyright 2020 by Brian C. Lane <bcl@brianlane.com>. 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 main
+
+import (
+ "fmt"
+ "log"
+ "time"
+
+ "periph.io/x/periph/conn/i2c/i2creg"
+ "periph.io/x/periph/host"
+
+ "github.com/bcl/air-sensors/pmsa003i"
+)
+
+func main() {
+ // Make sure periph is initialized.
+ if _, err := host.Init(); err != nil {
+ log.Fatal(err)
+ }
+
+ // Open a handle to the first available I²C bus:
+ bus, err := i2creg.Open("")
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer bus.Close()
+
+ d, err := pmsa003i.New(bus)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for start := time.Now(); time.Since(start) < time.Second*30; {
+ time.Sleep(1 * time.Second)
+
+ // Read the PMSA003i sensor data
+ r, err := d.ReadSensor()
+ if err != nil {
+ // Checksum failures could be transient
+ fmt.Println(err)
+ } else {
+ fmt.Println()
+ fmt.Printf("PM1.0 %3d μg/m3\n", r.EnvPm1)
+ fmt.Printf("PM2.5 %3d μg/m3\n", r.EnvPm2_5)
+ fmt.Printf("PM10 %3d μg/m3\n", r.EnvPm10)
+ fmt.Println("Counters in 0.1L of air")
+ fmt.Printf("%d > 0.3μm\n", r.Cnt0_3)
+ fmt.Printf("%d > 0.5μm\n", r.Cnt0_5)
+ fmt.Printf("%d > 1.0μm\n", r.Cnt1)
+ fmt.Printf("%d > 2.5μm\n", r.Cnt2_5)
+ fmt.Printf("%d > 5.0μm\n", r.Cnt5)
+ fmt.Printf("%d > 10μm\n", r.Cnt10)
+ }
+ }
+}
diff --git a/cmd/run-sgp30/main.go b/cmd/run-sgp30/main.go
@@ -0,0 +1,65 @@
+// Copyright 2020 by Brian C. Lane <bcl@brianlane.com>. 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 main
+
+import (
+ "fmt"
+ "log"
+ "time"
+
+ "periph.io/x/periph/conn/i2c/i2creg"
+ "periph.io/x/periph/host"
+
+ "github.com/bcl/air-sensors/sgp30"
+)
+
+func main() {
+ // Make sure periph is initialized.
+ if _, err := host.Init(); err != nil {
+ log.Fatal(err)
+ }
+
+ // Open a handle to the first available I²C bus:
+ bus, err := i2creg.Open("")
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer bus.Close()
+
+ d, err := sgp30.New(bus, ".sgp30_baseline", 30*time.Second)
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer d.Halt()
+
+ sn, err := d.GetSerialNumber()
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("Serial Number: %X\n", sn)
+
+ // Start measuring air quality
+ if err = d.StartMeasurements(); err != nil {
+ log.Fatal(err)
+ }
+
+ // The SGP30 returns 400ppm, 0ppb for 15 seconds at startup
+ // This exits with a positive result if non-default values are read
+ // But it cannot detect an error from just the readings since 400,0
+ // may be normal for the environment.
+ for start := time.Now(); time.Since(start) < time.Second*30; {
+ time.Sleep(1 * time.Second)
+ if co2, tvoc, err := d.ReadAirQuality(); err != nil {
+ log.Fatal(err)
+ } else {
+ fmt.Printf("CO2 : %d ppm\nTVOC: %d ppb\n", co2, tvoc)
+
+ if co2 > 400 && tvoc > 0 {
+ fmt.Printf("SGP30: Good readings detected\n")
+ break
+ }
+ }
+ }
+}
diff --git a/go.mod b/go.mod
@@ -0,0 +1,9 @@
+module github.com/bcl/air-sensors
+
+go 1.16
+
+require (
+ github.com/sigurn/crc8 v0.0.0-20160107002456-e55481d6f45c
+ github.com/sigurn/utils v0.0.0-20190728110027-e1fefb11a144 // indirect
+ periph.io/x/periph v3.6.8+incompatible
+)
diff --git a/go.sum b/go.sum
@@ -0,0 +1,6 @@
+github.com/sigurn/crc8 v0.0.0-20160107002456-e55481d6f45c h1:hk0Jigjfq59yDMgd6bzi22Das5tyxU0CtOkh7a9io84=
+github.com/sigurn/crc8 v0.0.0-20160107002456-e55481d6f45c/go.mod h1:cyrWuItcOVIGX6fBZ/G00z4ykprWM7hH58fSavNkjRg=
+github.com/sigurn/utils v0.0.0-20190728110027-e1fefb11a144 h1:ccb8W1+mYuZvlpn/mJUMAbsFHTMCpcJBS78AsBQxNcY=
+github.com/sigurn/utils v0.0.0-20190728110027-e1fefb11a144/go.mod h1:VRI4lXkrUH5Cygl6mbG1BRUfMMoT2o8BkrtBDUAm+GU=
+periph.io/x/periph v3.6.8+incompatible h1:lki0ie6wHtvlilXhIkabdCUQMpb5QN4Fx33yNQdqnaA=
+periph.io/x/periph v3.6.8+incompatible/go.mod h1:EWr+FCIU2dBWz5/wSWeiIUJTriYv9v2j2ENBmgYyy7Y=
diff --git a/pmsa003i/doc.go b/pmsa003i/doc.go
@@ -0,0 +1,10 @@
+// Copyright 2020 by Brian C. Lane <bcl@brianlane.com>. 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 pmsa003i 30 controls a PMSA003i particle sensor over I²C.
+//
+// Datasheet
+//
+// https://cdn-shop.adafruit.com/product-files/4632/4505_PMSA003I_series_data_manual_English_V2.6.pdf
+package pmsa003i
diff --git a/pmsa003i/example_test.go b/pmsa003i/example_test.go
@@ -0,0 +1,59 @@
+// Copyright 2020 by Brian C. Lane <bcl@brianlane.com>. 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 pmsa003i_test
+
+import (
+ "fmt"
+ "log"
+ "time"
+
+ "periph.io/x/periph/conn/i2c/i2creg"
+ "periph.io/x/periph/host"
+
+ "github.com/bcl/air-sensors/pmsa003i"
+)
+
+func Example() {
+ // Make sure periph is initialized.
+ if _, err := host.Init(); err != nil {
+ log.Fatal(err)
+ }
+
+ // Open a handle to the first available I²C bus:
+ bus, err := i2creg.Open("")
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer bus.Close()
+
+ d, err := pmsa003i.New(bus)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for {
+ time.Sleep(1 * time.Second)
+
+ // Read the PMSA003i sensor data
+ r, err := d.ReadSensor()
+ if err != nil {
+ // Checksum failures could be transient
+ fmt.Println(err)
+ } else {
+ fmt.Println()
+ fmt.Printf("PM1.0 %3d μg/m3\n", r.EnvPm1)
+ fmt.Printf("PM2.5 %3d μg/m3\n", r.EnvPm2_5)
+ fmt.Printf("PM10 %3d μg/m3\n", r.EnvPm10)
+ fmt.Println("Counters in 0.1L of air")
+ fmt.Printf("%d > 0.3μm\n", r.Cnt0_3)
+ fmt.Printf("%d > 0.5μm\n", r.Cnt0_5)
+ fmt.Printf("%d > 1.0μm\n", r.Cnt1)
+ fmt.Printf("%d > 2.5μm\n", r.Cnt2_5)
+ fmt.Printf("%d > 5.0μm\n", r.Cnt5)
+ fmt.Printf("%d > 10μm\n", r.Cnt10)
+ fmt.Println()
+ }
+ }
+}
diff --git a/pmsa003i/pmsa003i.go b/pmsa003i/pmsa003i.go
@@ -0,0 +1,102 @@
+// Copyright 2020 by Brian C. Lane <bcl@brianlane.com>. 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 pmsa003i
+
+import (
+ "fmt"
+
+ "periph.io/x/periph/conn"
+ "periph.io/x/periph/conn/i2c"
+)
+
+const (
+ PMSA003I_ADDR = 0x12
+)
+
+func checksum(data []byte) bool {
+ var cksum uint16
+ for i := 0; i < len(data)-2; i++ {
+ cksum = cksum + uint16(data[i])
+ }
+ return word(data, 0x1e) == cksum
+}
+
+type Results struct {
+ CfPm1 uint16 // PM1.0 in μg/m3 standard particle
+ CfPm2_5 uint16 // PM2.5 in μg/m3 standard particle
+ CfPm10 uint16 // PM10 in μg/m3 standard particle
+ EnvPm1 uint16 // PM1.0 in μg/m3 atmospheric environment
+ EnvPm2_5 uint16 // PM2.5 in μg/m3 atmospheric environment
+ EnvPm10 uint16 // PM10 in μg/m3 atmospheric environment
+ Cnt0_3 uint16 // Count of particles > 0.3μm in 0.1L of air
+ Cnt0_5 uint16 // Count of particles > 0.5μm in 0.1L of air
+ Cnt1 uint16 // Count of particles > 1.0μm in 0.1L of air
+ Cnt2_5 uint16 // Count of particles > 2.5μm in 0.1L of air
+ Cnt5 uint16 // Count of particles > 5.0μm in 0.1L of air
+ Cnt10 uint16 // Count of particles > 10.0μm in 0.1L of air
+ Version uint8
+}
+
+// New returns a PMSA003I device struct for communicating with the device
+//
+func New(i i2c.Bus) (*Dev, error) {
+ d := &Dev{i2c: &i2c.Dev{Bus: i, Addr: PMSA003I_ADDR}}
+
+ _, err := d.ReadSensor()
+ if err != nil {
+ return nil, err
+ }
+ return d, nil
+}
+
+type Dev struct {
+ i2c conn.Conn // i2c device handle for the sgp30
+ err error
+}
+
+// Halt implements conn.Resource.
+func (d *Dev) Halt() error {
+ return nil
+}
+
+// ReadSensor returns particle measurement results
+func (d *Dev) ReadSensor() (Results, error) {
+ // Receive 32 bytes
+ var data [32]byte
+ if err := d.i2c.Tx(nil, data[:]); err != nil {
+ return Results{}, fmt.Errorf("pmsa003i: Error while reading the sensor: %w", err)
+ }
+
+ if word(data[:], 0) != 0x424d {
+ return Results{}, fmt.Errorf("pmsa003i: Bad start word")
+ }
+ if !checksum(data[:]) {
+ return Results{}, fmt.Errorf("pmsa003i: Bad checksum")
+ }
+ if data[0x1d] != 0x00 {
+ return Results{}, fmt.Errorf("pmsa0031: Error code %x", data[0x1d])
+ }
+
+ return Results{
+ CfPm1: word(data[:], 0x04),
+ CfPm2_5: word(data[:], 0x06),
+ CfPm10: word(data[:], 0x08),
+ EnvPm1: word(data[:], 0x0a),
+ EnvPm2_5: word(data[:], 0x0c),
+ EnvPm10: word(data[:], 0x0e),
+ Cnt0_3: word(data[:], 0x10),
+ Cnt0_5: word(data[:], 0x12),
+ Cnt1: word(data[:], 0x14),
+ Cnt2_5: word(data[:], 0x16),
+ Cnt5: word(data[:], 0x18),
+ Cnt10: word(data[:], 0x1a),
+ Version: data[0x1c],
+ }, nil
+}
+
+// word returns 16 bits from the byte stream, starting at index i
+func word(data []byte, i int) uint16 {
+ return uint16(data[i])<<8 + uint16(data[i+1])
+}
diff --git a/pmsa003i/pmsa003i_test.go b/pmsa003i/pmsa003i_test.go
@@ -0,0 +1,160 @@
+// Copyright 2020 by Brian C. Lane <bcl@brianlane.com>. 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 pmsa003i
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "periph.io/x/periph/conn/i2c/i2ctest"
+)
+
+var (
+ GoodSensorData = []byte{
+ 0x42, 0x4d, 0x00, 0x1c, 0x00, 0x00, 0x00, 0x01,
+ 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, 0x05,
+ 0x00, 0x7e, 0x00, 0x2a, 0x00, 0x0f, 0x00, 0x09,
+ 0x00, 0x03, 0x00, 0x03, 0x97, 0x00, 0x02, 0x14}
+ BadStartSensorData = []byte{
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
+ BadChecksumSensorData = []byte{
+ 0x42, 0x4d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
+)
+
+func TestWord(t *testing.T) {
+ data := []byte{0x00, 0x01, 0x80, 0x0A, 0x55, 0xAA, 0xFF, 0x7F}
+ result := []uint16{0x0001, 0x800A, 0x55AA, 0xFF7F}
+ for i := 0; i < len(result); i += 1 {
+ if word(data, i*2) != result[i] {
+ t.Errorf("word error: i == %d", i)
+ }
+ }
+}
+
+func TestChecksum(t *testing.T) {
+ if !checksum(GoodSensorData) {
+ t.Fatal("Checksum Error")
+ }
+}
+
+func TestFailReadChipID(t *testing.T) {
+ bus := i2ctest.Playback{
+ // Chip ID detection read fail.
+ Ops: []i2ctest.IO{},
+ DontPanic: true,
+ }
+ if _, err := New(&bus); err == nil {
+ t.Fatal("can't read chip ID")
+ }
+}
+
+func TestBadSensorData(t *testing.T) {
+ bus := i2ctest.Playback{
+ Ops: []i2ctest.IO{
+ // Bad sensor data
+ {Addr: 0x12, W: []byte{}, R: BadStartSensorData},
+ },
+ }
+ if _, err := New(&bus); err == nil {
+ t.Fatal("Bad sensor data Error")
+ }
+}
+
+func TestGoodSensorData(t *testing.T) {
+ bus := i2ctest.Playback{
+ Ops: []i2ctest.IO{
+ // Bad sensor data
+ {Addr: 0x12, W: []byte{}, R: GoodSensorData},
+ },
+ }
+ if _, err := New(&bus); err != nil {
+ t.Fatalf("Good sensor data Error: %s", err)
+ }
+}
+
+func TestReadSensorBadStart(t *testing.T) {
+ bus := i2ctest.Playback{
+ Ops: []i2ctest.IO{
+ // Bad start word sensor data
+ {Addr: 0x12, W: []byte{}, R: GoodSensorData},
+ {Addr: 0x12, W: []byte{}, R: BadStartSensorData},
+ },
+ }
+ d, err := New(&bus)
+ if err != nil {
+ t.Fatalf("Good sensor data Error: %s", err)
+ }
+ _, err = d.ReadSensor()
+ if err == nil {
+ t.Fatal("Read Sensor bad start Error")
+ }
+ if !strings.Contains(fmt.Sprintf("%s", err), "Bad start word") {
+ t.Fatalf("Not bad start Error: %s", err)
+ }
+}
+
+func TestReadSensorBadChecksum(t *testing.T) {
+ bus := i2ctest.Playback{
+ Ops: []i2ctest.IO{
+ // Bad checksum sensor data
+ {Addr: 0x12, W: []byte{}, R: GoodSensorData},
+ {Addr: 0x12, W: []byte{}, R: BadChecksumSensorData},
+ },
+ }
+ d, err := New(&bus)
+ if err != nil {
+ t.Fatalf("Good sensor data Error: %s", err)
+ }
+ _, err = d.ReadSensor()
+ if err == nil {
+ t.Fatal("Read Sensor bad checksum Error")
+ }
+ if !strings.Contains(fmt.Sprintf("%s", err), "Bad checksum") {
+ t.Fatalf("Not bad checksum Error: %s", err)
+ }
+}
+
+func TestReadSensor(t *testing.T) {
+ bus := i2ctest.Playback{
+ Ops: []i2ctest.IO{
+ // Bad checksum sensor data
+ {Addr: 0x12, W: []byte{}, R: GoodSensorData},
+ {Addr: 0x12, W: []byte{}, R: GoodSensorData},
+ },
+ }
+ d, err := New(&bus)
+ if err != nil {
+ t.Fatalf("Good sensor data Error: %s", err)
+ }
+ r, err := d.ReadSensor()
+ if err != nil {
+ t.Fatalf("Read Sensor Error: %s", err)
+ }
+ expected := Results{
+ CfPm1: 0,
+ CfPm2_5: 1,
+ CfPm10: 5,
+ EnvPm1: 0,
+ EnvPm2_5: 1,
+ EnvPm10: 5,
+ Cnt0_3: 126,
+ Cnt0_5: 42,
+ Cnt1: 15,
+ Cnt2_5: 9,
+ Cnt5: 3,
+ Cnt10: 3,
+ Version: 151,
+ }
+ if r != expected {
+ t.Fatalf("Read Sensor Data Error: %v", r)
+ }
+}
diff --git a/sgp30/doc.go b/sgp30/doc.go
@@ -0,0 +1,10 @@
+// Copyright 2020 by Brian C. Lane <bcl@brianlane.com>. 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 sgp30 controls a Sensiron SGP30 Gas Sensor over I²C.
+//
+// Datasheet
+//
+// https://cdn.sparkfun.com/assets/c/0/a/2/e/Sensirion_Gas_Sensors_SGP30_Datasheet.pdf
+package sgp30
diff --git a/sgp30/example_test.go b/sgp30/example_test.go
@@ -0,0 +1,56 @@
+// Copyright 2020 by Brian C. Lane <bcl@brianlane.com>. 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 sgp30_test
+
+import (
+ "fmt"
+ "log"
+ "time"
+
+ "periph.io/x/periph/conn/i2c/i2creg"
+ "periph.io/x/periph/host"
+
+ "github.com/bcl/air-sensors/sgp30"
+)
+
+func Example() {
+ // Make sure periph is initialized.
+ if _, err := host.Init(); err != nil {
+ log.Fatal(err)
+ }
+
+ // Open a handle to the first available I²C bus:
+ bus, err := i2creg.Open("")
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer bus.Close()
+
+ d, err := sgp30.New(bus, ".sgp30_baseline", 30*time.Second)
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer d.Halt()
+
+ sn, err := d.GetSerialNumber()
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("Serial Number: %X\n", sn)
+
+ // Start measuring air quality
+ if err = d.StartMeasurements(); err != nil {
+ log.Fatal(err)
+ }
+
+ for {
+ time.Sleep(1 * time.Second)
+ if co2, tvoc, err := d.ReadAirQuality(); err != nil {
+ log.Fatal(err)
+ } else {
+ fmt.Printf("CO2 : %d ppm\nTVOC: %d ppb\n", co2, tvoc)
+ }
+ }
+}
diff --git a/sgp30/sgp30.go b/sgp30/sgp30.go
@@ -0,0 +1,217 @@
+// Copyright 2020 by Brian C. Lane <bcl@brianlane.com>. 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 sgp30
+
+import (
+ "fmt"
+ "io/ioutil"
+ "time"
+
+ "github.com/sigurn/crc8"
+ "periph.io/x/periph/conn"
+ "periph.io/x/periph/conn/i2c"
+)
+
+const (
+ SGP30_ADDR = 0x58
+)
+
+var (
+ CRC8_SGP30 = crc8.MakeTable(crc8.Params{
+ Poly: 0x31,
+ Init: 0xFF,
+ RefIn: false,
+ RefOut: false,
+ XorOut: 0x00,
+ Check: 0xA1,
+ Name: "CRC-8/SGP30",
+ })
+)
+
+func checkCRC8(data []byte) bool {
+ return crc8.Checksum(data[:], CRC8_SGP30) == 0x00
+}
+
+// New returns a SGP30 device struct for communicating with the device
+//
+// If baselineFile is passed the baseline calibration data will be read from the file
+// at startup, and new data will be saved at baselineInterval interval when ReadAirQuality
+// is called.
+//
+// eg. pass 30 * time.Second to save the baseline data every 30 seconds
+func New(i i2c.Bus, baselineFile string, baselineInterval time.Duration) (*Dev, error) {
+ d := &Dev{i2c: &i2c.Dev{Bus: i, Addr: SGP30_ADDR}}
+ if _, err := d.GetSerialNumber(); err != nil {
+ return nil, err
+ }
+
+ // Restore the baseline from the saved data if it exists
+ if len(baselineFile) > 0 {
+ d.baselineFile = baselineFile
+ d.baselineInterval = baselineInterval
+ d.lastSave = time.Now()
+ // Restore the baseline data if it exists, ignore missing file
+ if baseline, err := ioutil.ReadFile(baselineFile); err == nil {
+ err = d.SetBaseline(baseline)
+ if err != nil {
+ return nil, err
+ }
+ }
+ }
+ return d, nil
+}
+
+type Dev struct {
+ i2c conn.Conn // i2c device handle for the sgp30
+ baselineFile string // Path and filename for storing baseline values
+ baselineInterval time.Duration // How often to save the baseline data
+ lastSave time.Time // Last time baseline was saved
+ err error
+}
+
+// Halt implements conn.Resource.
+func (d *Dev) Halt() error {
+ return nil
+}
+
+// GetSerialNumber returns the 48 bit serial number of the device
+func (d *Dev) GetSerialNumber() (uint64, error) {
+ // Send a 0x3682
+ // Receive 3 words + 8 bit CRC on each
+ var data [9]byte
+ if err := d.i2c.Tx([]byte{0x36, 0x82}, data[:]); err != nil {
+ return 0, fmt.Errorf("sgp30: Error while reading serial number: %w", err)
+ }
+
+ if !checkCRC8(data[0:3]) {
+ return 0, fmt.Errorf("sgp30: serial number word 1 CRC8 failed on: %v", data[0:3])
+ }
+ if !checkCRC8(data[3:6]) {
+ return 0, fmt.Errorf("sgp30: serial number word 2 CRC8 failed on: %v", data[3:6])
+ }
+ if !checkCRC8(data[6:9]) {
+ return 0, fmt.Errorf("sgp30: serial number word 3 CRC8 failed on: %v", data[6:9])
+ }
+
+ return uint64(word(data[:], 0))<<24 + uint64(word(data[:], 3))<<16 + uint64(word(data[:], 6)), nil
+}
+
+// GetFeatures returns the 8 bit product type, and 8 bit product version
+func (d *Dev) GetFeatures() (uint8, uint8, error) {
+ // Send a 0x202f
+ // Receive 1 word + 8 bit CRC
+ var data [3]byte
+ if err := d.i2c.Tx([]byte{0x20, 0x2f}, data[:]); err != nil {
+ return 0, 0, fmt.Errorf("sgp30: Error while reading features: %w", err)
+ }
+
+ if !checkCRC8(data[0:3]) {
+ return 0, 0, fmt.Errorf("sgp30: features CRC8 failed on: %v", data[0:3])
+ }
+
+ return data[0], data[1], nil
+}
+
+// StartMeasurements sends the Inlet Air Quality command to start measuring
+// ReadAirQuality needs to be called every second after this has been sent
+//
+// Note that for 15s after the measurements have started the readings will return
+// 400ppm CO2 and 0ppb TVOC
+func (d *Dev) StartMeasurements() error {
+ // Send a 0x2003
+ if err := d.i2c.Tx([]byte{0x20, 0x03}, nil); err != nil {
+ return fmt.Errorf("sgp30: Error starting air quality measurements: %w", err)
+ }
+
+ return nil
+}
+
+// ReadAirQuality returns the CO2 and TVOC readings as 16 bit values
+// CO2 is in ppm and TVOC is in ppb
+//
+// If a baselineFile was passed to New the baseline data will be saved to disk every
+// baselineInterval
+func (d *Dev) ReadAirQuality() (uint16, uint16, error) {
+ // Send a 0x2008
+ // Receive 2 words with + 8 bit CRC on each
+ if err := d.i2c.Tx([]byte{0x20, 0x08}, nil); err != nil {
+ return 0, 0, fmt.Errorf("sgp30: Error while requesting air quality: %w", err)
+ }
+
+ // Requires a short delay before reading results
+ time.Sleep(10 * time.Millisecond)
+ var data [6]byte
+ if err := d.i2c.Tx(nil, data[:]); err != nil {
+ return 0, 0, fmt.Errorf("sgp30: Error while reading air quality: %w", err)
+ }
+
+ if !checkCRC8(data[0:3]) {
+ return 0, 0, fmt.Errorf("sgp30: read air quality word 1 CRC8 failed on: %v", data[0:3])
+ }
+ if !checkCRC8(data[3:6]) {
+ return 0, 0, fmt.Errorf("sgp30: read air quality word 2 CRC8 failed on: %v", data[3:6])
+ }
+
+ if len(d.baselineFile) > 0 && time.Since(d.lastSave) >= d.baselineInterval {
+ d.lastSave = time.Now()
+ baseline, err := d.ReadBaseline()
+ if err != nil {
+ return 0, 0, fmt.Errorf("sgp30: Error while reading baseline: %w", err)
+ }
+ ioutil.WriteFile(d.baselineFile, baseline[:], 0644)
+ }
+
+ return word(data[:], 0), word(data[:], 3), nil
+}
+
+// ReadBaseline returns the 6 data bytes for the measurement baseline
+// These values should be saved to disk and restore using SetBaseline when the program
+// restarts.
+func (d *Dev) ReadBaseline() ([6]byte, error) {
+ // Send a 0x2015
+ // Receive 2 words + 8 bit CRC on each
+ var data [6]byte
+ if err := d.i2c.Tx([]byte{0x20, 0x15}, data[:]); err != nil {
+ return [6]byte{}, fmt.Errorf("sgp30: Error while reading baseline: %w", err)
+ }
+
+ if !checkCRC8(data[0:3]) {
+ return [6]byte{}, fmt.Errorf("sgp30: baseline word 1 CRC8 failed on: %v", data[0:3])
+ }
+ if !checkCRC8(data[3:6]) {
+ return [6]byte{}, fmt.Errorf("sgp30: baseline word 2 CRC8 failed on: %v", data[3:6])
+ }
+
+ return data, nil
+}
+
+// SetBaseline sets the measurement baseline data bytes
+// The values should have been previously read from the device using ReadBaseline
+//
+// NOTE: The data order for setting it is TVOC, CO2 even though the order when
+// reading is CO2, TVOC. This assumes that the baseline data passed in is CO2, TVOC
+func (d *Dev) SetBaseline(baseline []byte) error {
+ if !checkCRC8(baseline[0:3]) {
+ return fmt.Errorf("sgp30: set baseline word 1 CRC8 failed on: %v", baseline[0:3])
+ }
+ if !checkCRC8(baseline[3:6]) {
+ return fmt.Errorf("sgp30: set baseline word 2 CRC8 failed on: %v", baseline[3:6])
+ }
+
+ // Send InitAirQuality
+ d.StartMeasurements()
+
+ // Send a 0x201e + TVOC, CO2 baseline data (2 words + CRCs)
+ data := append(append([]byte{0x20, 0x1e}, baseline[3:6]...), baseline[0:3]...)
+ if err := d.i2c.Tx(data, nil); err != nil {
+ return fmt.Errorf("sgp30: Error while setting baseline: %w", err)
+ }
+ return nil
+}
+
+// word returns 16 bits from the byte stream, starting at index i
+func word(data []byte, i int) uint16 {
+ return uint16(data[i])<<8 + uint16(data[i+1])
+}
diff --git a/sgp30/sgp30_test.go b/sgp30/sgp30_test.go
@@ -0,0 +1,261 @@
+// Copyright 2020 by Brian C. Lane <bcl@brianlane.com>. 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 sgp30
+
+import (
+ "io/ioutil"
+ "os"
+ "testing"
+ "time"
+
+ "periph.io/x/periph/conn/i2c/i2ctest"
+)
+
+var (
+ BadSerialNumber = []byte{0, 0, 0, 0, 0, 0, 0, 0, 0}
+ GoodSerialNumber = []byte{0x00, 0x00, 0x81, 0x01, 0x57, 0x9C, 0xAC, 0xA2, 0x54}
+ BadBaselineData = []byte{0, 0, 0, 0, 0, 0}
+ GoodBaselineData = []byte{0x88, 0xa1, 0x58, 0x8d, 0xc4, 0x61}
+ BadFeaturesData = []byte{0, 0, 0}
+ GoodFeaturesData = []byte{0x00, 0x22, 0x65}
+ BadAirQualityData = []byte{0, 0, 0, 0, 0, 0}
+ GoodAirQualityData = []byte{0x01, 0x9e, 0x53, 0x00, 0x0d, 0xcd}
+)
+
+func TestWord(t *testing.T) {
+ data := []byte{0x00, 0x01, 0x80, 0x0A, 0x55, 0xAA, 0xFF, 0x7F}
+ result := []uint16{0x0001, 0x800A, 0x55AA, 0xFF7F}
+ for i := 0; i < len(result); i += 1 {
+ if word(data, i*2) != result[i] {
+ t.Errorf("word error: i == %d", i)
+ }
+ }
+}
+
+func TestChecksum(t *testing.T) {
+ if !checkCRC8(GoodSerialNumber[0:3]) {
+ t.Fatal("serial number word 1 CRC8 error")
+ }
+ if !checkCRC8(GoodSerialNumber[3:6]) {
+ t.Fatal("serial number word 2 CRC8 error")
+ }
+ if !checkCRC8(GoodSerialNumber[6:9]) {
+ t.Fatal("serial number word 3 CRC8 error")
+ }
+}
+
+func TestFailReadChipID(t *testing.T) {
+ bus := i2ctest.Playback{
+ // Chip ID detection read fail.
+ Ops: []i2ctest.IO{},
+ DontPanic: true,
+ }
+ if _, err := New(&bus, "", time.Second); err == nil {
+ t.Fatal("can't read chip ID")
+ }
+}
+
+func TestBadSerialNumber(t *testing.T) {
+ bus := i2ctest.Playback{
+ Ops: []i2ctest.IO{
+ // Bad serial number
+ {Addr: 0x58, W: []byte{0x36, 0x82}, R: BadSerialNumber},
+ },
+ }
+ if _, err := New(&bus, "", time.Second); err == nil {
+ t.Fatal("Bad serial number Error")
+ }
+}
+
+func TestGoodSerialNumber(t *testing.T) {
+ bus := i2ctest.Playback{
+ Ops: []i2ctest.IO{
+ // Good serial number
+ {Addr: 0x58, W: []byte{0x36, 0x82}, R: GoodSerialNumber},
+ },
+ }
+ if _, err := New(&bus, "", time.Second); err != nil {
+ t.Fatalf("Good serial number Error: %s", err)
+ }
+}
+
+func TestBadBaselineData(t *testing.T) {
+ // Temporary baseline file, defer removal
+ bf, err := ioutil.TempFile("", "sgp30.")
+ if err != nil {
+ t.Fatalf("TempFile Error: %s", err)
+ }
+ defer os.Remove(bf.Name())
+
+ _, err = bf.Write(BadBaselineData)
+ if err != nil {
+ t.Fatalf("TempFile Write Error: %s", err)
+ }
+
+ // Calling New with a baseline reads the serial number, starts measurements,
+ // and then writes the baseline data
+ bus := i2ctest.Playback{
+ Ops: []i2ctest.IO{
+ // Good serial number
+ {Addr: 0x58, W: []byte{0x36, 0x82}, R: GoodSerialNumber},
+ {Addr: 0x58, W: []byte{0x20, 0x03}, R: []byte{}},
+ {Addr: 0x58, W: []byte{0x20, 0x15}, R: BadBaselineData},
+ },
+ }
+ if _, err := New(&bus, bf.Name(), time.Second); err == nil {
+ t.Fatal("Bad baseline data")
+ }
+}
+
+func TestGoodBaselineData(t *testing.T) {
+ // Temporary baseline file, defer removal
+ bf, err := ioutil.TempFile("", "sgp30.")
+ if err != nil {
+ t.Fatalf("TempFile Error: %s", err)
+ }
+ defer os.Remove(bf.Name())
+
+ _, err = bf.Write(GoodBaselineData)
+ if err != nil {
+ t.Fatalf("TempFile Write Error: %s", err)
+ }
+
+ // The CO2 and TVOC data is swapped when writing it back to the SGP30
+ BaselineWrite := append(append([]byte{0x20, 0x1e}, GoodBaselineData[3:6]...), GoodBaselineData[0:3]...)
+
+ // Calling New with a baseline reads the serial number, starts measurements,
+ // and then writes the baseline data
+ bus := i2ctest.Playback{
+ Ops: []i2ctest.IO{
+ // Good serial number
+ {Addr: 0x58, W: []byte{0x36, 0x82}, R: GoodSerialNumber},
+ {Addr: 0x58, W: []byte{0x20, 0x03}, R: []byte{}},
+ {Addr: 0x58, W: BaselineWrite, R: []byte{}},
+ },
+ }
+ if _, err := New(&bus, bf.Name(), time.Second); err != nil {
+ t.Fatalf("Good Baseline Error: %s", err)
+ }
+}
+
+func TestBadFeatures(t *testing.T) {
+ // Calling New with a baseline reads the serial number
+ bus := i2ctest.Playback{
+ Ops: []i2ctest.IO{
+ // Good serial number
+ {Addr: 0x58, W: []byte{0x36, 0x82}, R: GoodSerialNumber},
+ {Addr: 0x58, W: []byte{0x20, 0x2f}, R: BadFeaturesData},
+ },
+ }
+ d, err := New(&bus, "", time.Second)
+ if err != nil {
+ t.Fatalf("Bad Features: %s", err)
+ }
+ if _, _, err := d.GetFeatures(); err == nil {
+ t.Fatal("Bad Features Error")
+ }
+}
+
+func TestGoodFeatures(t *testing.T) {
+ // Calling New with a baseline reads the serial number
+ bus := i2ctest.Playback{
+ Ops: []i2ctest.IO{
+ // Good serial number
+ {Addr: 0x58, W: []byte{0x36, 0x82}, R: GoodSerialNumber},
+ {Addr: 0x58, W: []byte{0x20, 0x2f}, R: GoodFeaturesData},
+ },
+ }
+ d, err := New(&bus, "", time.Second)
+ if err != nil {
+ t.Fatalf("Good Features: %s", err)
+ }
+ ProdType, ProdVersion, err := d.GetFeatures()
+ if err != nil {
+ t.Fatalf("Good Features Error: %s", err)
+ }
+ if ProdType != 0x00 {
+ t.Error("Wrong Product type")
+ }
+ if ProdVersion != 0x22 {
+ t.Error("Wrong Product version")
+ }
+}
+
+func TestReadBadBaseline(t *testing.T) {
+ bus := i2ctest.Playback{
+ Ops: []i2ctest.IO{
+ // Good serial number
+ {Addr: 0x58, W: []byte{0x36, 0x82}, R: GoodSerialNumber},
+ {Addr: 0x58, W: []byte{0x20, 0x15}, R: BadBaselineData},
+ },
+ }
+ d, err := New(&bus, "", time.Second)
+ if err != nil {
+ t.Fatalf("Good serial number Error: %s", err)
+ }
+ if _, err := d.ReadBaseline(); err == nil {
+ t.Fatal("Read Bad Baseline Error")
+ }
+}
+
+func TestReadGoodBaseline(t *testing.T) {
+ bus := i2ctest.Playback{
+ Ops: []i2ctest.IO{
+ // Good serial number
+ {Addr: 0x58, W: []byte{0x36, 0x82}, R: GoodSerialNumber},
+ {Addr: 0x58, W: []byte{0x20, 0x15}, R: GoodBaselineData},
+ },
+ }
+ d, err := New(&bus, "", time.Second)
+ if err != nil {
+ t.Fatalf("Good serial number Error: %s", err)
+ }
+ if _, err := d.ReadBaseline(); err != nil {
+ t.Fatalf("Read Good Baseline Error: %s", err)
+ }
+}
+
+func TestBadAirQuality(t *testing.T) {
+ bus := i2ctest.Playback{
+ Ops: []i2ctest.IO{
+ // Good serial number
+ {Addr: 0x58, W: []byte{0x36, 0x82}, R: GoodSerialNumber},
+ {Addr: 0x58, W: []byte{0x20, 0x08}, R: []byte{}},
+ {Addr: 0x58, W: []byte{}, R: BadAirQualityData},
+ },
+ }
+ d, err := New(&bus, "", time.Second)
+ if err != nil {
+ t.Fatalf("Good serial number Error: %s", err)
+ }
+ if _, _, err := d.ReadAirQuality(); err == nil {
+ t.Fatalf("Read Bad AirQuality Error")
+ }
+}
+
+func TestGoodAirQuality(t *testing.T) {
+ bus := i2ctest.Playback{
+ Ops: []i2ctest.IO{
+ // Good serial number
+ {Addr: 0x58, W: []byte{0x36, 0x82}, R: GoodSerialNumber},
+ {Addr: 0x58, W: []byte{0x20, 0x08}, R: []byte{}},
+ {Addr: 0x58, W: []byte{}, R: GoodAirQualityData},
+ },
+ }
+ d, err := New(&bus, "", time.Second)
+ if err != nil {
+ t.Fatalf("Good serial number Error: %s", err)
+ }
+ co2, tvoc, err := d.ReadAirQuality()
+ if err != nil {
+ t.Fatalf("Read Good AirQuality Error: %s", err)
+ }
+ if co2 != 414 {
+ t.Error("CO2 reading is wrong")
+ }
+ if tvoc != 13 {
+ t.Error("TVOC reading is wrong")
+ }
+}