micropython/micropython

RFC: Additional DHT sensor support, including new I2C versions

mcauser opened this issue · 6 comments

I would like to improve the DHT driver and add support for an additional 14 sensors.

I have been experimenting with a number of DHT sensors (Aosong AM23xx series) and have discovered there are 4 main types:

Type 1: low-resolution digital (unique to DHT11)

  • interface: 1-wire
  • humidity resolution: 1 %RH
  • temperature resolution: 1 °C
    Sensors: DHT11

Type 2: digital (AM230x)

  • interface: 1-wire
  • humidity resolution: 0.1 %RH
  • temperature resolution: 0.1 °C
    Sensors: AM2301, DHT22 (AM2302), AM2302 (Wired), DHT33 (RHT04), AM2303, AM2305 (DHT44), AM2306

Type 3: digital and I2C (AM232x)

  • interface: 1-wire, I2C
  • humidity resolution: 0.1 %RH
  • temperature resolution: 0.1 °C
    Sensors: DHT12, AM2320, AM2321, AM2322, AM2325

Type 4: I2C (AM231x)

  • interface: I2C
  • humidity resolution: 0.1 %RH
  • temperature resolution: 0.1 °C
    Sensors: AM2311, AM2312, AM2313, AM2315

With the exception of the DHT11 sensor, all of them share the same custom 1-wire protocol (unrelated to Dallas).
All of the newer sensors which have an I2C interface, have the same device and register addresses.
They are all interchangeable, with range, accuracy, stability, current draw, speed and cost being the only differences.

All of the dual digital + I2C sensors can be put in digital mode by grounding the 4th pin (SCL).

I have compiled specifications for each sensor from various English and Chinese datasheets:
https://docs.google.com/spreadsheets/d/1jbSpU-rLXuba9aubcYI15_6hI2wM7-vmQ4iPcAV7MI4/edit

I would like to update the MicroPython ESP8266 DHT driver to support all of these sensors.
I have prototyped the I2C version and it works with my DHT12.
I can test the rest of the I2C sensors once they arrive in the mail.

I added this to /scripts/dht.py and flashed to my device:

class DHTBaseI2C:
    def __init__(self, i2c, addr):
        self.i2c = i2c
        self.addr = addr
        self.buf = bytearray(5)
    def measure(self):
        buf = self.buf
        self.i2c.readfrom_mem_into(self.addr, 0, buf)
        if (buf[0] + buf[1] + buf[2] + buf[3]) & 0xff != buf[4]:
            raise Exception("checksum error")

class DHT12_I2C(DHTBaseI2C):
    def humidity(self):
        return self.buf[0] + self.buf[1] * 0.1
    def temperature(self):
        t = self.buf[2] + (self.buf[3] & 0x7F) * 0.1
        if self.buf[3] & 0x80:
            t = -t
        return t

It works, but it can be done better.

Here is it working with a DHT12 and a WeMos D1 Mini.

Connections:

WeMos D1 Mini    DHT12
3V3 ------------ 1 (VDD)
D2 (GPIO4) ----- 2 (SDA)
GND ------------ 3 (GND)
D1 (GPIO5) ----- 4 (SCL)
import ustruct
import time, dht, machine
from machine import I2C, Pin
i2c = I2C(scl=Pin(5), sda=Pin(4), freq=20000)
d = DHT12_I2C(i2c, 92)
while True:
    d.measure()
    print(d.temperature(), d.humidity())
    time.sleep_ms(1000)

I am not sure of the most pythonic way to structure this as each new dual interface sensor can potentially have 2x base classes, 1-wire and I2C.
They are constructed with either a single digital pin, or an I2C object + device address.
Constructor overloading?

There is also 16 DHT sensors, with probably more on the way.
They all fall into the type categories I have detailed above.
Should we have a class per type of sensor? eg. DHT11, DHT_OneWire, DHT_I2C?
A class for each of the product ranges? eg. DHT11, DHT12, AM230x, AM231x, AM232x?

Hoping someone with a bit more Python experience can help with the structure.

One approach is to pass a constructor argument which may either be a Pin or an I2C instance. The address could default to None, so the caller either passes a Pin or an I2C and an address. The constructor then does something along these lines:

self.i2c = None
self.pin = None
if type(arg) is pyb.I2C:
    if type(address) is not int:
        raise ValueError('Must pass I2C address')
    else:
        self.i2c = arg
        self.address = address
elif type(arg) is pyb.Pin:
    self.pin = arg
else:
    raise ValueError('Must supply a Pin or I2C instance')

One approach is to pass a constructor argument which may either be a Pin or an I2C instance.
I think this is a bad practice in Python. At the least use instanceof(), not type(), as the users may pass something that behaves like an int or Pin, but which type is not directly "int". For instance, I recently added a DummyPin class to my display drivers, for when you don't want to specify a rst or cs pin -- with your code, that would fail miserably.

It's much better to have an optional keyword argument that specifiAM2320es what it is, something like:

def __init__(self, i2c=None, pin=None, address=0x5c):
        self.i2c = i2c
        self.address = address
        self.pin = pin
        if pin is None and i2c is None:
            raise ValueError("Either pin or i2c interface have to be specified.")
        ...
        if self.pin is None:
            # do I2C stuff
        else:
            # do 1-wire stuff

This way you can still pass anything that had the right methods and attributes, and it will work, no matter what its type is. This is especially useful for injecting a debugging object, or for writing unit tests where you don't want it to use real hardware.

Another approach is to have the sensor's logic in the base class, and have two classes, say WireDHT22 and I2CDHT22 inheriting from it, but providing different communication methods. That's how the SSD1306 driver in MicroPython's tree is done: https://github.com/micropython/micropython/blob/master/drivers/display/ssd1306.py

Then you can still have a factory function that instantiates the right class based on the arguments passed, as above.

My AM2320s arrived today. They have an I2C interface that works differently to the DHT12 described above. We may need separate libraries after all.

Here is a AM2320 version working on my WeMos D1 mini:

import ustruct
import time, dht, machine
from machine import I2C, Pin
i2c = I2C(scl=Pin(5), sda=Pin(4), freq=20000)

class AM2320:
    def __init__(self, i2c=None, address=0x5c):
        self.i2c = i2c
        self.address = address
        self.buf = bytearray(8)
    def measure(self):
        buf = self.buf
        address = self.address
        # wake sensor
        self.i2c.writeto(address, b'')
        # read 4 registers starting at offset 0x00
        self.i2c.writeto(address, b'\x03\x00\x04')
        # wait at least 1.5ms
        time.sleep_ms(2)
        # read data
        self.i2c.readfrom_mem_into(address, 0, buf)
        # debug print
        print(ustruct.unpack('BBBBBBBB', buf))
        crc = ustruct.unpack('<H', bytearray(buf[-2:]))[0]
        if (crc != self.crc16(buf[:-2])):
            raise Exception("checksum error")
    def crc16(self, buf):
        crc = 0xFFFF
        for c in buf:
            crc ^= c
            for i in range(8):
                if crc & 0x01:
                    crc >>= 1
                    crc ^= 0xA001
                else:
                    crc >>= 1
        return crc
    def humidity(self):
        return (self.buf[2] << 8 | self.buf[3]) * 0.1
    def temperature(self):
        t = ((self.buf[4] & 0x7f) << 8 | self.buf[5]) * 0.1
        if self.buf[4] & 0x80:
            t = -t
        return t
am2320 = AM2320(i2c, 92)
am2320.measure()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 10, in measure
OSError: I2C bus error

am2320.measure()
(3, 4, 0, 251, 0, 254, 1, 153)

print(am2320.temperature(), am2320.humidity())
25.39999 25.1

I am seeing a lot of OSError: I2C bus error errors. The wake sensor step seems to be the problem. The datasheet says the sensor does not respond to ACK, so I may need a try/catch here. I'll see what my logic analyser can reveal.

AM2320 and DHT12 libraries added here:
https://github.com/mcauser/micropython-dht12
https://github.com/mcauser/micropython-am2320
.. based on:
https://github.com/mcauser/MicroPython-ESP8266-DHT-Nokia-5110
Will make them pip packages once I figure out how :)

My dream of a universal DHT driver is fading. With lots of subtle differences between the devices, perhaps it's best to have separate drivers.