air-sensors

Air Quality Sensor library
git clone https://www.brianlane.com/git/air-sensors
Log | Files | Refs | README | LICENSE

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:
AREADME.md | 22++++++++++++++++++++++
Acmd/run-pmsa003i/main.go | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/run-sgp30/main.go | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ago.mod | 9+++++++++
Ago.sum | 6++++++
Apmsa003i/doc.go | 10++++++++++
Apmsa003i/example_test.go | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apmsa003i/pmsa003i.go | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apmsa003i/pmsa003i_test.go | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asgp30/doc.go | 10++++++++++
Asgp30/example_test.go | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asgp30/sgp30.go | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asgp30/sgp30_test.go | 261+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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") + } +}