diff --git a/am2320/am2320.go b/am2320/am2320.go new file mode 100644 index 0000000..1211df7 --- /dev/null +++ b/am2320/am2320.go @@ -0,0 +1,181 @@ +// 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 AOSONG AM2320 Temperature/Humidity +// Sensor. This sensor is a basic, inexpensive i2c sensor with reasonably good +// accuracy for both temperature and humidity. +// +// # Datasheet +// +// https://cdn-shop.adafruit.com/product-files/3721/AM2320.pdf +package am2320 + +import ( + "errors" + "fmt" + "sync" + "time" + + "periph.io/x/conn/v3" + "periph.io/x/conn/v3/i2c" + "periph.io/x/conn/v3/physic" +) + +// Dev represents an am2320 temperature/humidity sensor. +type Dev struct { + d *i2c.Dev + mu sync.Mutex + shutdown chan struct{} +} + +const ( + // The address of this device is fixed. Note that the datasheet states + // the value is 0xb8, which is incorrect. + SensorAddress uint16 = 0x5c + + humidityRegisters byte = 0x00 +) + +// Create a new am2320 device and return it. +func NewI2C(b i2c.Bus, addr uint16) (*Dev, error) { + d := &Dev{d: &i2c.Dev{Bus: b, Addr: addr}} + return d, nil +} + +// Halt interrupts a running SenseContinuous() operation. +func (dev *Dev) Halt() error { + dev.mu.Lock() + defer dev.mu.Unlock() + if dev.shutdown != nil { + close(dev.shutdown) + } + return nil +} + +// Algorithm from the datasheet. Returns true if CRC matches check value. +func checkCRC(bytes []byte) bool { + crc := uint16(0xffff) + for ix := range len(bytes) - 2 { + b := uint16(bytes[ix]) + crc ^= b + for range 8 { + if (crc & 0x01) == 0x01 { + crc = crc >> 1 + crc ^= 0xa001 + } else { + crc = crc >> 1 + } + } + } + chk := uint16(bytes[len(bytes)-2]) | uint16(bytes[len(bytes)-1])<<8 + return chk == crc +} + +// readCommand provides the logic of communicating with the sensor. According +// to the datasheet, it tries to stay in low-power as much as possible to +// avoid self-heating the sensors. This makes it finicky to talk to. On success, +// returns a slice of registerCount bytes starting from registerAddress. +func (dev *Dev) readCommand(registerAddress, registerCount byte) ([]byte, error) { + // Send a wake-up call to the device. + var err error + for range 5 { + err = dev.d.Tx([]byte{0}, nil) + if err == nil { + break + } + time.Sleep(100 * time.Millisecond) + } + w := []byte{0x3, registerAddress, registerCount} + // The read return format is: + // + // {operation,registerCount,requested registers...,crc low, crc high} + r := make([]byte, registerCount+4) + + for range 10 { + err = dev.d.Tx(w, r) + if err == nil && + w[0] == r[0] && w[2] == r[1] && + checkCRC(r) { + + return r[2 : 2+registerCount], nil + } + time.Sleep(2 * time.Second) + } + if err == nil { + err = errors.New("invalid return values or crc from sensor") + } + return nil, fmt.Errorf("am2320 error sending read command: %w", err) +} + +// Sense queries the sensor for the current temperature and humidity. Note that +// the sensor reports a sample rate of 1/2 hz. It's recommended to not poll +// the sensor more frequently than once every 3 seconds. +func (dev *Dev) Sense(env *physic.Env) error { + env.Temperature = 0 + env.Pressure = 0 + env.Humidity = 0 + + dev.mu.Lock() + defer dev.mu.Unlock() + + r, err := dev.readCommand(humidityRegisters, 4) + if err != nil { + return err + } + + h := int16(r[0])<<8 | int16(r[1]) + env.Humidity = physic.RelativeHumidity(h) * physic.MilliRH + t := int16(r[2])<<8 | int16(r[3]) + env.Temperature = physic.ZeroCelsius + (physic.Celsius/10)*physic.Temperature(t) + + return nil +} + +// SenseContinuous returns a channel that can be read to return values from +// the sensor. The minimum value for interval is 3 seconds. To end the read, +// call Halt() +func (dev *Dev) SenseContinuous(interval time.Duration) (<-chan physic.Env, error) { + if interval < (3 * time.Second) { + return nil, errors.New("am2320: invalid duration. minimum 3 seconds") + } + if dev.shutdown != nil { + return nil, errors.New("am2320: sense continuous already running") + } + + dev.shutdown = make(chan struct{}) + ch := make(chan physic.Env, 16) + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-dev.shutdown: + close(ch) + dev.shutdown = nil + return + case <-ticker.C: + e := physic.Env{} + err := dev.Sense(&e) + if err == nil { + ch <- e + } + } + } + }() + return ch, nil +} + +func (dev *Dev) String() string { + return fmt.Sprintf("am2320: %s", dev.d) +} + +// Precision returns the resolution of the device for it's measured parameters. +func (dev *Dev) Precision(env *physic.Env) { + env.Temperature = physic.Celsius / 10 + env.Pressure = 0 + env.Humidity = physic.MilliRH +} + +var _ conn.Resource = &Dev{} +var _ physic.SenseEnv = &Dev{} diff --git a/am2320/am2320_test.go b/am2320/am2320_test.go new file mode 100644 index 0000000..c5431dd --- /dev/null +++ b/am2320/am2320_test.go @@ -0,0 +1,190 @@ +// 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 am2320 + +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 + +// Playback values for a single sense operation. +var pbSense = []i2ctest.IO{ + {Addr: SensorAddress, W: []uint8{0x0}}, + {Addr: SensorAddress, W: []uint8{0x3, 0x0, 0x4}, R: []uint8{0x3, 0x4, 0x1, 0x5c, 0x0, 0xef, 0x71, 0x8a}}} + +func init() { + var err error + + liveDevice = os.Getenv("AM2320") != "" + + // Make sure periph is initialized. + 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 a configured device using either an i2c bus, or a playback bus. +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 TestBasic(t *testing.T) { + dev := Dev{} + env := &physic.Env{} + dev.Precision(env) + if env.Pressure != 0 { + t.Error("this device doesn't measure pressure") + } + if 10*env.Temperature != physic.Celsius { + t.Error("incorrect temperature precision value") + } + if env.Humidity != physic.MilliRH { + t.Error("incorrect humidity precision") + } + + s := dev.String() + if len(s) == 0 { + t.Error("invalid value for String()") + } + + // Check the CRC Calculation algorithm using the data supplied by the vendor. + crcTest := []byte{0x03, 0x04, 0x01, 0xf4, 0x00, 0xfa, 0x31, 0xa5} + if !checkCRC(crcTest) { + t.Error("crc error") + } + // ensure a corruption is detected. + crcTest[0] = crcTest[0] ^ 0xff + if checkCRC(crcTest) { + t.Error("crc error") + } +} + +func TestSense(t *testing.T) { + d, err := getDev(t, pbSense) + if err != nil { + t.Fatalf("failed to initialize am2320: %v", err) + } + defer shutdown(t) + + // Read temperature and humidity from the sensor + e := physic.Env{} + + if err := d.Sense(&e); err != nil { + t.Fatal(err) + } + t.Logf("%8s %9s", e.Temperature, e.Humidity) + + if !liveDevice { + // The playback temp is 23.9C Ensure that's what we got. + expected := physic.ZeroCelsius + 23_900*physic.MilliKelvin + if e.Temperature != expected { + t.Errorf("incorrect temperature value read. Expected: %s (%d) Found: %s (%d)", + e.Temperature.String(), + e.Temperature, + expected.String(), + expected) + } + + // 34.8% expected. + expectedRH := 34*physic.PercentRH + 8*physic.MilliRH + if e.Humidity != expectedRH { + t.Errorf("incorrect humidity value read. Expected: %s (%d) Found: %s (%d)", + e.Humidity.String(), + e.Humidity, + expectedRH.String(), + expectedRH) + } + } +} + +func TestSenseContinuous(t *testing.T) { + readCount := 10 + + // make 10 copies of the single reading playback data. + pb := make([]i2ctest.IO, 0, len(pbSense)*10) + for range readCount { + pb = append(pb, pbSense...) + } + + d, err := getDev(t, pb) + if err != nil { + t.Fatalf("failed to initialize am2320: %v", err) + } + defer shutdown(t) + + _, err = d.SenseContinuous(time.Second) + if err == nil { + t.Error("SenseContinuous() accepted invalid reading interval") + } + ch, err := d.SenseContinuous(3 * time.Second) + if err != nil { + t.Fatal(err) + } + + go func() { + time.Sleep(3 * time.Duration(readCount) * time.Second) + err := d.Halt() + if err != nil { + t.Error(err) + } + }() + + count := 0 + for e := range ch { + count += 1 + t.Log(time.Now(), e) + } + if count < (readCount-1) || count > (readCount+1) { + t.Errorf("expected %d readings. received %d", readCount, count) + } +} diff --git a/am2320/example_test.go b/am2320/example_test.go new file mode 100644 index 0000000..abaa26c --- /dev/null +++ b/am2320/example_test.go @@ -0,0 +1,41 @@ +// 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 am2320 + +import ( + "fmt" + "log" + "periph.io/x/conn/v3/i2c/i2creg" + "periph.io/x/conn/v3/physic" + "periph.io/x/host/v3" +) + +func Example() { + // Make sure periph is initialized. + if _, err := host.Init(); err != nil { + log.Fatal(err) + } + + // Open default I²C bus. + bus, err := i2creg.Open("") + if err != nil { + log.Fatalf("failed to open I²C: %v", err) + } + defer bus.Close() + + // Create the Sensor + sensor, err := NewI2C(bus, SensorAddress) + if err != nil { + log.Fatal(err) + } + + // Take a reading + env := physic.Env{} + err = sensor.Sense(&env) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Sensor Output: %s\n", env) +}