From a45227590e318529dd38597664b6a8885f382448 Mon Sep 17 00:00:00 2001 From: Patricio Whittingslow Date: Fri, 11 Aug 2023 06:58:28 -0300 Subject: [PATCH] add Sandbox Electronics NDIR CO2 sensor driver (2) (#580) add sandbox electronics NDIR CO2 sensor --- examples/ndir/main_ndir.go | 41 ++++++ ndir/ndir.go | 288 +++++++++++++++++++++++++++++++++++++ sensor.go | 2 + smoketest.sh | 6 +- 4 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 examples/ndir/main_ndir.go create mode 100644 ndir/ndir.go diff --git a/examples/ndir/main_ndir.go b/examples/ndir/main_ndir.go new file mode 100644 index 0000000..9cd590d --- /dev/null +++ b/examples/ndir/main_ndir.go @@ -0,0 +1,41 @@ +package main + +import ( + "machine" + "time" + + "tinygo.org/x/drivers" + "tinygo.org/x/drivers/ndir" +) + +var ( + ndirBus = machine.I2C0 +) + +func main() { + err := ndirBus.Configure(machine.I2CConfig{ + Frequency: 100_000, + }) + if err != nil { + panic("i2c config fail:" + err.Error()) + } + // Set the address based on how the resistors are soldered. + // True means the left and middle pads are joined. + ndirAddr := ndir.Addr(true, false) + dev := ndir.NewDevI2C(ndirBus, ndirAddr) + err = dev.Init() + if err != nil { + panic("ndir init fail:" + err.Error()) + } + // Datasheet tells us to wait 12 seconds before reading from the sensor. + time.Sleep(12 * time.Second) + for { + time.Sleep(time.Second) + err := dev.Update(drivers.AllMeasurements) + if err != nil { + println(err.Error()) + continue + } + println("PPM:", dev.PPMCO2()) + } +} diff --git a/ndir/ndir.go b/ndir/ndir.go new file mode 100644 index 0000000..04e3693 --- /dev/null +++ b/ndir/ndir.go @@ -0,0 +1,288 @@ +package ndir + +import ( + "errors" + "fmt" + "runtime" + "time" + + "tinygo.org/x/drivers" +) + +// Addr returns the I2C address given the solder pad configuration on the Sandbox Electronics i2c/uart converter. +// When the resistor is connected between the left and middle pads the bit is said to be set +// and a0 or a1 should be passed in as true. +func Addr(a0, a1 bool) uint8 { + return 0b1001000 | b2u8(a0) | b2u8(a1)<<2 +} + +func b2u8(b bool) uint8 { + if b { + return 1 + } + return 0 +} + +// See https://github.com/SandboxElectronics/NDIR/blob/master/NDIR_I2C/NDIR_I2C.cpp + +// General Registers +const ( + addrRHR = 0x00 + addrTHR = 0x00 + addrIER = 0x01 + addrFCR = 0x02 + addrIIR = 0x02 + addrLCR = 0x03 + addrMCR = 0x04 + addrLSR = 0x05 + addrMSR = 0x06 + addrSPR = 0x07 + addrTCR = 0x06 + addrTLR = 0x07 + addrTXLVL = 0x08 + addrRXLVL = 0x09 + addrIODIR = 0x0A + addrIOSTATE = 0x0B + addrIOINTENA = 0x0C + addrIOCONTROL = 0x0E // This addr fails on write of 0x08? + addrEFCR = 0x0F +) + +// Special registers +const ( + addrDLL = 0x00 + addrDLH = 1 +) + +const ( + shortTxCooldown = time.Millisecond + longTxCooldown = 10 * time.Millisecond + rxTimeout = 100 * time.Millisecond +) + +var ( + cmd_readCO2 = [...]byte{0xFF, 0x01, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00, 0x79} + cmd_measure = [...]byte{0xFF, 0x01, 0x9C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x63} + cmd_calibrateZero = [...]byte{0xFF, 0x01, 0x87, 0x00, 0x00, 0x00, 0x00, 0x00, 0x78} + cmd_enableAutoCalibration = [...]byte{0xFF, 0x01, 0x79, 0xA0, 0x00, 0x00, 0x00, 0x00, 0xE6} + cmd_disableAutoCalibration = [...]byte{0xFF, 0x01, 0x79, 0x00, 0x00, 0x00, 0x00, 0x00, 0x86} +) + +// DevI2C is a handle to a MH-Z16 NDIR CO2 Sensor using the I2C interface. +type DevI2C struct { + bus drivers.I2C + addr uint8 + nextAvail time.Time + initTime time.Time + lastMeasurement int32 +} + +// NewDevI2C returns a new NDIR device ready for use. It performs no I/O. +func NewDevI2C(bus drivers.I2C, addr uint8) *DevI2C { + return &DevI2C{ + bus: bus, + addr: addr, + lastMeasurement: -1, + } +} + +// PPM returns the CO2 parts per million read in the last Update call. +func (d *DevI2C) PPMCO2() int32 { + return d.lastMeasurement +} + +var errInitWait = errors.New("ndir: must wait 12 seconds after init before reading concentration") + +// Update reads the CO2 concentration from the NDIR and stores it ready for the +// PPM() method. +func (d *DevI2C) Update(which drivers.Measurement) (err error) { + if which&drivers.Concentration == 0 { + return nil // NDIR only measures concentration, so nothing to do here. + } + if time.Since(d.initTime) < 12*time.Second { + // Wait 12 seconds before performing first read. + return nil + } + err = d.writeRegister(addrFCR, 0x07) + if err != nil { + return err + } + err = d.send(cmd_measure[:]) + if err != nil { + return fmt.Errorf("sending cmd_measure: %w", err) + } + time.Sleep(11 * time.Millisecond) + var buf [9]byte + buf, err = d.receive() + + if err != nil { + return fmt.Errorf("receiving during measure: %w", err) + } + if buf[0] != 0xff && buf[1] != 0x9c { + return fmt.Errorf("buffer rx bad values: %q", string(buf[:])) + } + var sum uint16 + for i := 0; i < len(buf); i++ { + sum += uint16(buf[i]) + } + mod := sum % 256 + if mod != 0xff { + return fmt.Errorf("ndir checksum modulus got %#x, expected 0xff", mod) + } + ppm := uint32(buf[2])<<24 | uint32(buf[3])<<16 | uint32(buf[4])<<8 | uint32(buf[5]) + d.lastMeasurement = int32(ppm) + return nil +} + +func (d *DevI2C) Init() (err error) { + // AddrIOCONTROL write is always NACKed so ignore + // error here. + d.writeRegister(addrIOCONTROL, 0x08) + + err = d.writeRegister(addrFCR, 0x07) + if err != nil { + return err + } + err = d.writeRegister(addrLCR, 0x83) + if err != nil { + return err + } + err = d.writeRegister(addrDLL, 0x60) + if err != nil { + return err + } + err = d.writeRegister(addrDLH, 0x00) + if err != nil { + return err + } + err = d.writeRegister(addrLCR, 0x03) + if err != nil { + return err + } + d.initTime = time.Now() + return nil +} + +// CalibrateZero calibrates the NDIR to around 412ppm. +func (d *DevI2C) CalibrateZero() error { + return d.enactCommand(cmd_calibrateZero[:]) +} + +// SetAutoCalibration can enable or disable the NDIR's auto calibration mode. +func (d *DevI2C) SetAutoCalibration(enable bool) (err error) { + if enable { + err = d.enactCommand(cmd_enableAutoCalibration[:]) + } else { + err = d.enactCommand(cmd_disableAutoCalibration[:]) + } + return err +} + +func (d *DevI2C) send(cmd []byte) error { + txlvl, err := d.ReadRegister(addrTXLVL) + if err != nil { + return err + } + if int(txlvl) < len(cmd) { + return fmt.Errorf("txlvl=%d less than length of command %d", txlvl, len(cmd)) + } + return d.tx(append([]byte{addrTHR}, cmd...), nil) +} + +func (d *DevI2C) receive() (cmd [9]byte, err error) { + start := time.Now() + n := uint8(9) + for n > 0 { + if time.Since(start) > rxTimeout { + return [9]byte{}, errors.New("NDIR rx timeout") + } + rxlvl, err := d.ReadRegister(addrRXLVL) + if err != nil { + return [9]byte{}, err + } + if rxlvl > n { + rxlvl = n + } + ptr := 9 - n + err = d.tx([]byte{addrRHR << 3}, cmd[ptr:ptr+rxlvl]) + n -= rxlvl + if err != nil { + return [9]byte{}, err + } + } + return cmd, nil +} + +func (d *DevI2C) enactCommand(cmd []byte) error { + if len(cmd) > 31 { + return errors.New("ndir: command too long") + } + // Most commands always start with the same FCR write here. + err := d.writeRegister(addrFCR, 0x07) + if err != nil { + return err + } + time.Sleep(longTxCooldown) + + // C++ send method begins here. + got, err := d.ReadRegister(addrTXLVL) + if err != nil { + return err + } + if got < uint8(len(cmd)) { + return fmt.Errorf("ndir: txlevel=%d too low for command of length %d", got, len(cmd)) + } + var buf [32]byte + buf[0] = addrTHR + n := 1 + copy(buf[1:], cmd) + err = d.tx(buf[:n], nil) + if err != nil { + return err + } + d.nextAvail.Add(longTxCooldown) // add some extra time. + return nil +} + +func (d *DevI2C) writeRegister(addr, val uint8) (err error) { + return d.WriteRegisters(addr, []byte{val}) +} + +func (d *DevI2C) WriteRegisters(addr uint8, vals []byte) (err error) { + var buf [32]byte + if len(vals) > 31 { + return errors.New("can only write up to 31 bytes") + } + buf[0] = addr << 3 + n := copy(buf[1:], vals) + err = d.tx(buf[:n+1], nil) + if err != nil { + err = fmt.Errorf("NDIR write %#x (%d) to %#x: %w", buf[1], len(vals), buf[0], err) + } + return err +} + +func (d *DevI2C) ReadRegister(addr uint8) (uint8, error) { + var buf [2]byte + buf[0] = addr << 3 + err := d.tx(buf[:1], buf[1:2]) + if err != nil { + err = fmt.Errorf("NDIR read from %#x: %w", buf[0], err) + } + return buf[1], err +} + +func (d *DevI2C) tx(w, r []byte) error { + wait := time.Until(d.nextAvail) + if wait > 0 { + // Try yielding process first, maybe there's a short time to wait and a schedule call is enough delay. + runtime.Gosched() + wait = time.Until(d.nextAvail) + if wait > 0 { + // If yielding did not work then perform sleep + time.Sleep(wait) + } + } + err := d.bus.Tx(uint16(d.addr), w, r) + d.nextAvail = time.Now().Add(shortTxCooldown) + return err +} diff --git a/sensor.go b/sensor.go index f660044..db57583 100644 --- a/sensor.go +++ b/sensor.go @@ -16,6 +16,8 @@ const ( MagneticField Luminosity Time + // Gas or liquid concentration, usually measured in ppm (parts per million). + Concentration // Add Measurements above AllMeasurements. // AllMeasurements is the OR of all Measurement values. It ensures all measurements are done. diff --git a/smoketest.sh b/smoketest.sh index d2712e5..9b3ff49 100755 --- a/smoketest.sh +++ b/smoketest.sh @@ -4,6 +4,7 @@ # avoid a race condition between writing the output and reading the result to # get an md5sum). + tinygo build -size short -o ./build/test.hex -target=itsybitsy-m0 ./examples/adt7410/main.go tinygo build -size short -o ./build/test.hex -target=itsybitsy-m0 ./examples/adxl345/main.go tinygo build -size short -o ./build/test.hex -target=pybadge ./examples/amg88xx @@ -127,4 +128,7 @@ tinygo build -size short -o ./build/test.hex -target=arduino-nano33 ./examples/d tinygo build -size short -o ./build/test.hex -target=nucleo-wl55jc ./examples/lora/lorawan/atcmd/ tinygo build -size short -o ./build/test.uf2 -target=pico ./examples/as560x/main.go tinygo build -size short -o ./build/test.uf2 -target=pico ./examples/mpu6886/main.go -tinygo build -size short -o ./build/test.hex -target=arduino-nano33 ./examples/ttp229/main.go \ No newline at end of file +tinygo build -size short -o ./build/test.hex -target=arduino-nano33 ./examples/ttp229/main.go +tinygo build -size short -o ./build/test.hex -target=pico ./examples/ndir/main_ndir.go +tinygo build -size short -o ./build/test.hex -target=microbit ./examples/ndir/main_ndir.go +tinygo build -size short -o ./build/test.hex -target=arduino-nano33 ./examples/ndir/main_ndir.go \ No newline at end of file