-
Notifications
You must be signed in to change notification settings - Fork 44
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
devices: Add support for AM2320 Temperature/Humidity Sensor (#82)
* AM2320 Temp/Humidity Sensor - Initial Add
- Loading branch information
Showing
3 changed files
with
412 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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{} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
Oops, something went wrong.