Skip to content

Commit

Permalink
devices: Add support for AM2320 Temperature/Humidity Sensor (#82)
Browse files Browse the repository at this point in the history
* AM2320 Temp/Humidity Sensor - Initial Add
  • Loading branch information
gsexton authored Nov 25, 2024
1 parent 9a938c4 commit 7fd42d5
Show file tree
Hide file tree
Showing 3 changed files with 412 additions and 0 deletions.
181 changes: 181 additions & 0 deletions am2320/am2320.go
Original file line number Diff line number Diff line change
@@ -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{}
190 changes: 190 additions & 0 deletions am2320/am2320_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading

0 comments on commit 7fd42d5

Please sign in to comment.