Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

devices: Add support for AM2320 Temperature/Humidity Sensor #82

Merged
merged 3 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
//
gsexton marked this conversation as resolved.
Show resolved Hide resolved
// 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.
//
// The datasheet is located at:
gsexton marked this conversation as resolved.
Show resolved Hide resolved
//
// 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 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.
for range 5 {
err := dev.d.Tx([]byte{0}, nil)
if err == nil {
break
}
time.Sleep(100 * time.Millisecond)

Check warning on line 86 in am2320/am2320.go

View check run for this annotation

Codecov / codecov/patch

am2320/am2320.go#L86

Added line #L86 was not covered by tests
}
w := make([]byte, 3)
gsexton marked this conversation as resolved.
Show resolved Hide resolved
w[0] = 0x3 // Read Operation
w[1] = registerAddress
w[2] = 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)

Check warning on line 105 in am2320/am2320.go

View check run for this annotation

Codecov / codecov/patch

am2320/am2320.go#L105

Added line #L105 was not covered by tests
}

return nil, errors.New("am2320: error sending read command")

Check warning on line 108 in am2320/am2320.go

View check run for this annotation

Codecov / codecov/patch

am2320/am2320.go#L108

Added line #L108 was not covered by tests
gsexton marked this conversation as resolved.
Show resolved Hide resolved
}

// 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
}

Check warning on line 125 in am2320/am2320.go

View check run for this annotation

Codecov / codecov/patch

am2320/am2320.go#L124-L125

Added lines #L124 - L125 were not covered by tests

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")
}

Check warning on line 144 in am2320/am2320.go

View check run for this annotation

Codecov / codecov/patch

am2320/am2320.go#L143-L144

Added lines #L143 - L144 were not covered by tests

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