Add general purpose DS3231 driver.

pull/31/head
peterhinch 2023-02-28 13:26:32 +00:00
rodzic 4691354a40
commit 14ebf88797
3 zmienionych plików z 441 dodań i 11 usunięć

Wyświetl plik

@ -5,23 +5,134 @@ is an ideal way rapidly to calibrate the Pyboard's RTC which can then achieve
similar levels of accuracy (+- ~2 mins/year). The chip can also provide
accurate time to platforms lacking a good RTC (notably the ESP8266).
Two drivers are provided:
1. `ds3231_port.py` A multi-platform driver.
2. `ds3231_pb.py` A Pyboard-specific driver with RTC calibration facility. For
Three drivers are provided:
1. `ds3231_gen.py` General purpose portable driver supporting alarms.
2. `ds3231_port.py` Portable driver: main purpose is to test accuracy of a
platform's RTC.
3. `ds3231_pb.py` A Pyboard-specific driver with RTC calibration facility. For
Pyboard 1.x and Pyboard D.
Breakout boards are widely available. The interface is I2C. Pullups to 3.3V
(typically 10KΩ) should be provided on the `SCL` and `SDA` lines if these are
not supplied on the breakout board.
Both divers use edge detection to achieve millisecond-level precision from the
DS3231. This enables relatively rapid accuracy testing of the platform's RTC,
and fast calibration of the Pyboard's RTC. To quantify this, a sufficiently
precise value of calibration may be acquired in 5-10 minutes.
Drivers 2 and 3 use edge detection to achieve millisecond-level precision from
the DS3231. This enables relatively rapid accuracy testing of the platform's
RTC, and fast calibration of the Pyboard's RTC. To quantify this, a
sufficiently precise value of calibration may be acquired in 5-10 minutes.
###### [Main README](../README.md)
# 1. The multi-platform driver
# 1. General purpose driver ds3231_gen.py
This uses datetime tuples to set and read time values. These are of form
(year, month, day, hour, minute, second, weekday, yearday)
as used by [time.localtime](http://docs.micropython.org/en/latest/library/time.html#time.localtime).
## 1.1 The DS3231 class
#### Constructor:
This takes one mandatory argument, an initialised I2C bus.
#### Public methods:
1. `get_time(set_rtc=False)`. If `set_rtc` is `True` it sets the platform's
RTC from the DS3231. Returns the DS3231 time as a datetime tuple with
`yearday=0`.
On ports/platforms which don't support an RTC, if `set_rtc` is `True`, the
system time will be set from the DS3231. If setting the RTC, for accuracy the
method will pause until a seconds transition occurs on the DS3231.
2. `set_time(tt=None)`. Sets the DS3231 time. By default it uses the
platform's syatem time, otherwise the passed `datetime` tuple. If passing a
tuple, see the note below.
3. `__str__()` Returns a dump of the device's registers for debug in a "pretty
print" format.
4. `temperature()` A float, temperature in °C. Datasheet specifies +-3°C
accuracy. It really is that bad.
#### Public bound variables:
1. `alarm1` `Alarm` instances (see below). Can be set to 1s precision.
2. `alarm2` Can be set to 1min precision.
#### Alarm Public methods
1. `set(when, day=0, hr=0, min=0, sec=0)` Arg `when` is one of the module
constants listed below. Alarm operation is started.
2. `clear()` Clears the alarm status and releases the alarm pin. The alarm
will occur again the next time the parameters match.
3. `__call__()` No args. Return `True` if alarm has occurred.
4. `enable(run)` If `run` is `False` the alarm is cleared and will enter a
stopped state; in that state the alarm will not occur again. If `True` a
stopped alarm is restarted and will occur on the next match.
#### Alarm bound variables
1. `alno` Alarm no. (1 or 2).
#### Module constants
These are the allowable options for the alarm's `when` arg, along with the
relevant `Alarm.set()` args:
`EVERY_SECOND` Only supported by alarm1.
`EVERY_MINUTE` `sec`
`EVERY_HOUR` `min`, `sec`
`EVERY_DAY` `hr`, `min`, `sec`
`EVERY_WEEK` `day` (weekday 0..6), `hr`, `min`, `sec`
`EVERY_MONTH` `day` (month day 1..month end), `hr`, `min`, `sec`
In all cases `sec` values are ignored by alarm2: alarms occur on minute
boundaries. This is a hardware restriction.
#### Setting DS3231 time
Where this is to be set using a datetime tuple rather than from system time, it
is necessary to pass the correct value of weekday. This can be acquired with
this function. It can be passed a tuple with `dt[6] == 0` and will return a
corrected tuple:
```python
import time
def dt_tuple(dt):
return time.localtime(time.mktime(dt)) # Populate weekday field
```
#### Alarms
Comments assume that a backup battery is in use.
The battery ensures that alarm settings are stored through a power outage. If
an alarm occurs during an outage the pin will be driven low and will stay low
until power is restored and `clear` or `disable` are issued.
If an alarm is set and a power outage occurs, when power is restored the alarm
will continue to operate at the specified frequency. Setting an alarm:
```python
from machine import SoftI2C, Pin
from ds3231_gen import *
i2c = SoftI2C(scl=Pin(16, Pin.OPEN_DRAIN, value=1), sda=Pin(17, Pin.OPEN_DRAIN, value=1))
d = DS3231(i2c)
dt.alarm1.set(EVERY_MINUTE, sec=30)
```
If a power outage occurs here the following code will ensure alarms continue to
occur at one minute intervals:
```python
from machine import SoftI2C, Pin
from ds3231_gen import *
i2c = SoftI2C(scl=Pin(16, Pin.OPEN_DRAIN, value=1), sda=Pin(17, Pin.OPEN_DRAIN, value=1))
d = DS3231(i2c)
while True:
d.alarm1.clear() # Clear pending alarm
while not d.alarm1(): # Wait for alarm
pass
time.sleep(0.3) # Pin stays low for 300ms
```
Note that the DS3231 alarm2 does not have a seconds register: `sec` values will
be ignored and `EVERY_SECOND` is unsupported.
Re the `INT\` (alarm) pin the datasheet (P9) states "The pullup voltage can be
up to 5.5V, regardless of the voltage on Vcc". Note that some breakout boards
have a pullup resistor between this pin and Vcc.
# 2. Portable driver ds3231_port
This can use soft I2C so any pins may be used.
@ -47,7 +158,7 @@ In my testing the ESP8266 RTC was out by 5%. The ESP32 was out by 6.7ppm or
about 12 minutes/yr. A PiPico was out by 1.7ppm, 3.2mins/yr. Hardware samples
will vary.
## 1.1 The DS3231 class
## 2.1 The DS3231 class
Constructor:
This takes one mandatory argument, an initialised I2C bus.
@ -69,7 +180,7 @@ Public methods:
devices with poor RTC's (e.g. ESP8266).
If `machine.RTC` is unsupported a `RuntimeError` will be thrown.
# 2. The Pyboard driver
# 3. The Pyboard driver
The principal reason to use this driver is to calibrate the Pyboard's RTC. This
supports the Pyboard 1.x and Pyboard D. Note that the RTC on the Pyboard D is
@ -102,7 +213,7 @@ ds3231.calibrate()
Calibration data is stored in battery-backed memory. So if a backup cell is
used the RTC will run accurately in the event of a power outage.
## 2.1 The DS3231 class
## 3.1 The DS3231 class
Constructor:
This takes one mandatory argument, an I2C bus instantiated using the `machine`

Wyświetl plik

@ -0,0 +1,145 @@
# ds3231_gen.py General purpose driver for DS3231 precison real time clock.
# Author: Peter Hinch
# Copyright Peter Hinch 2023 Released under the MIT license.
# Rewritten from datasheet to support alarms. Sources studied:
# WiPy driver at https://github.com/scudderfish/uDS3231
# https://github.com/notUnique/DS3231micro
# Assumes date > Y2K and 24 hour clock.
import time
import machine
_ADDR = const(104)
EVERY_SECOND = 0x0F # Exported flags
EVERY_MINUTE = 0x0E
EVERY_HOUR = 0x0C
EVERY_DAY = 0x80
EVERY_WEEK = 0x40
EVERY_MONTH = 0
try:
rtc = machine.RTC()
except:
print("Warning: machine module does not support the RTC.")
rtc = None
class Alarm:
def __init__(self, device, n):
self._device = device
self._i2c = device.ds3231
self.alno = n # Alarm no.
self.offs = 7 if self.alno == 1 else 0x0B # Offset into address map
self.mask = 0
def _reg(self, offs : int, buf = bytearray(1)) -> int: # Read a register
self._i2c.readfrom_mem_into(_ADDR, offs, buf)
return buf[0]
def enable(self, run):
flags = self._reg(0x0E) | 4 # Disable square wave
flags = (flags | self.alno) if run else (flags & ~self.alno & 0xFF)
self._i2c.writeto_mem(_ADDR, 0x0E, flags.to_bytes(1, "little"))
def __call__(self): # Return True if alarm is set
return bool(self._reg(0x0F) & self.alno)
def clear(self):
flags = (self._reg(0x0F) & ~self.alno) & 0xFF
self._i2c.writeto_mem(_ADDR, 0x0F, flags.to_bytes(1, "little"))
def set(self, when, day=0, hr=0, min=0, sec=0):
if when not in (0x0F, 0x0E, 0x0C, 0x80, 0x40, 0):
raise ValueError("Invalid alarm specifier.")
self.mask = when
if when == EVERY_WEEK:
day += 1 # Setting a day of week
self._device.set_time((0, 0, day, hr, min, sec, 0, 0), self)
self.enable(True)
class DS3231:
def __init__(self, i2c):
self.ds3231 = i2c
self.alarm1 = Alarm(self, 1)
self.alarm2 = Alarm(self, 2)
if _ADDR not in self.ds3231.scan():
raise RuntimeError(f"DS3231 not found on I2C bus at {_ADDR}")
def get_time(self, set_rtc=False, data=bytearray(7)):
def bcd2dec(bcd): # Strip MSB
return ((bcd & 0x70) >> 4) * 10 + (bcd & 0x0F)
self.ds3231.readfrom_mem_into(_ADDR, 0, data)
if set_rtc: # For accuracy set RTC immediately after a seconds transition
ss = data[0]
while ss == data[0]:
self.ds3231.readfrom_mem_into(_ADDR, 0, data)
ss, mm, hh, wday, DD, MM, YY = [bcd2dec(x) for x in data]
MM &= 0x1F # Strip century
YY += 2000
# Time from DS3231 in time.localtime() format (less yday)
result = YY, MM, DD, hh, mm, ss, wday - 1, 0
if set_rtc:
if rtc is None: # Best we can do is to set local time
secs = time.mktime(result)
time.localtime(secs)
else:
rtc.datetime((YY, MM, DD, wday, hh, mm, ss, 0))
return result
# Output time or alarm data to device
# args: tt A datetime tuple. If absent uses localtime.
# alarm: An Alarm instance or None if setting time
def set_time(self, tt=None, alarm=None):
# Given BCD value return a binary byte. Modifier:
# Set MSB if any of bit(1..4) or bit 7 set, set b6 if mod[6]
def gbyte(dec, mod=0):
tens, units = divmod(dec, 10)
n = (tens << 4) + units
n |= 0x80 if mod & 0x0F else mod & 0xC0
return n.to_bytes(1, "little")
YY, MM, mday, hh, mm, ss, wday, yday = time.localtime() if tt is None else tt
mask = 0 if alarm is None else alarm.mask
offs = 0 if alarm is None else alarm.offs
if alarm is None or alarm.alno == 1: # Has a seconds register
self.ds3231.writeto_mem(_ADDR, offs, gbyte(ss, mask & 1))
offs += 1
self.ds3231.writeto_mem(_ADDR, offs, gbyte(mm, mask & 2))
offs += 1
self.ds3231.writeto_mem(_ADDR, offs, gbyte(hh, mask & 4)) # Sets to 24hr mode
offs += 1
if alarm is not None: # Setting an alarm - mask holds MS 2 bits
self.ds3231.writeto_mem(_ADDR, offs, gbyte(mday, mask))
else: # Setting time
self.ds3231.writeto_mem(_ADDR, offs, gbyte(wday + 1)) # 1 == Monday, 7 == Sunday
offs += 1
self.ds3231.writeto_mem(_ADDR, offs, gbyte(mday)) # Day of month
offs += 1
self.ds3231.writeto_mem(_ADDR, offs, gbyte(MM, 0x80)) # Century bit (>Y2K)
offs += 1
self.ds3231.writeto_mem(_ADDR, offs, gbyte(YY - 2000))
def temperature(self):
def twos_complement(input_value: int, num_bits: int) -> int:
mask = 2 ** (num_bits - 1)
return -(input_value & mask) + (input_value & ~mask)
t = self.ds3231.readfrom_mem(_ADDR, 0x11, 2)
i = t[0] << 8 | t[1]
return twos_complement(i >> 6, 10) * 0.25
def __str__(self, buf=bytearray(0x13)): # Debug dump of device registers
self.ds3231.readfrom_mem_into(_ADDR, 0, buf)
s = ""
for n, v in enumerate(buf):
s = f"{s}0x{n:02x} 0x{v:02x} {v >> 4:04b} {v & 0xF :04b}\n"
if not (n + 1) % 4:
s = f"{s}\n"
return s

Wyświetl plik

@ -0,0 +1,174 @@
# ds3231_gen_test.py Test script for ds3231_gen.oy.
# Author: Peter Hinch
# Copyright Peter Hinch 2023 Released under the MIT license.
from machine import SoftI2C, Pin
from ds3231_gen import *
import time
import uasyncio as asyncio
def dt_tuple(dt):
return time.localtime(time.mktime(dt)) # Populate weekday field
i2c = SoftI2C(scl=Pin(16, Pin.OPEN_DRAIN, value=1), sda=Pin(17, Pin.OPEN_DRAIN, value=1))
d = DS3231(i2c)
async def wait_for_alarm(alarm, t, target): # Wait for n seconds for an alarm, check time of occurrence
print(f"Wait {t} secs for alarm...")
if alarm.alno == 2:
target = 0 # Alarm 2 does not support secs
while t:
if alarm():
return target - 1 <= d.get_time()[5] <= target + 1
await asyncio.sleep(1)
t -= 1
return False
async def test_alarm(alarm):
print("Test weekly alarm")
result = True
dt = dt_tuple((2023, 2, 28, 23, 59, 50, 0, 0))
d.set_time(dt) # day is 1
alarm.set(EVERY_WEEK, day=2, sec=5) # Weekday
alarm.clear()
if await wait_for_alarm(alarm, 20, 5): # Should alarm on rollover from day 1 to 2
print("\x1b[32mWeek test 1 pass\x1b[39m")
else:
print("\x1b[91mWeek test 1 fail\x1b[39m")
result = False
dt = dt_tuple((2023, 2, 27, 23, 59, 50, 0, 0))
d.set_time(dt) # day is 0
alarm.set(EVERY_WEEK, day=2, sec=5)
alarm.clear()
if await wait_for_alarm(alarm, 20, 5): # Should not alarm on rollover from day 0 to 1
print("\x1b[91mWeek test 2 fail\x1b[39m")
result = False
else:
print("\x1b[32mWeek test 2 pass\x1b[39m")
print("Test monthly alarm")
dt = dt_tuple((2023, 2, 28, 23, 59, 50, 0, 0))
d.set_time(dt) # day is 1
alarm.set(EVERY_MONTH, day=1, sec=5) # Day of month
alarm.clear()
if await wait_for_alarm(alarm, 20, 5): # Should alarm on rollover from 28th to 1st
print("\x1b[32mMonth test 1 pass\x1b[39m")
else:
print("\x1b[91mMonth test 1 fail\x1b[39m")
result = False
dt = dt_tuple((2023, 2, 27, 23, 59, 50, 0, 0))
d.set_time(dt) # day is 0
alarm.set(EVERY_MONTH, day=1, sec=5)
alarm.clear()
if await wait_for_alarm(alarm, 20, 5): # Should not alarm on rollover from day 27 to 28
print("\x1b[91mMonth test 2 fail\x1b[39m")
result = False
else:
print("\x1b[32mMonth test 2 pass\x1b[39m")
print("Test daily alarm")
dt = dt_tuple((2023, 2, 1, 23, 59, 50, 0, 0))
d.set_time(dt) # 23:59:50
alarm.set(EVERY_DAY, hr=0, sec=5)
alarm.clear()
if await wait_for_alarm(alarm, 20, 5): # Should alarm at 00:00:05
print("\x1b[32mDaily test 1 pass\x1b[39m")
else:
print("\x1b[91mDaily test 1 fail\x1b[39m")
result = False
dt = dt_tuple((2023, 2, 1, 22, 59, 50, 0, 0))
d.set_time(dt) # 22:59:50
alarm.set(EVERY_DAY, hr=0, sec=5)
alarm.clear()
if await wait_for_alarm(alarm, 20, 5): # Should not alarm at 22:00:05
print("\x1b[91mDaily test 2 fail\x1b[39m")
result = False
else:
print("\x1b[32mDaily test 2 pass\x1b[39m")
print("Test hourly alarm")
dt = dt_tuple((2023, 2, 1, 20, 9, 50, 0, 0))
d.set_time(dt) # 20:09:50
alarm.set(EVERY_HOUR, min=10, sec=5)
alarm.clear()
if await wait_for_alarm(alarm, 20, 5): # Should alarm at xx:10:05
print("\x1b[32mDaily test 1 pass\x1b[39m")
else:
print("\x1b[91mDaily test 1 fail\x1b[39m")
result = False
dt = dt_tuple((2023, 2, 1, 20, 29, 50, 0, 0))
d.set_time(dt) # 20:29:50
alarm.set(EVERY_HOUR, min=10, sec=5)
alarm.clear()
if await wait_for_alarm(alarm, 20, 5): # Should not alarm at xx:30:05
print("\x1b[91mDaily test 2 fail\x1b[39m")
result = False
else:
print("\x1b[32mDaily test 2 pass\x1b[39m")
print("Test minute alarm")
dt = dt_tuple((2023, 2, 1, 20, 9, 50, 0, 0))
d.set_time(dt) # 20:09:50
alarm.set(EVERY_MINUTE, sec=5)
alarm.clear()
if await wait_for_alarm(alarm, 20, 5): # Should alarm at xx:xx:05
print("\x1b[32mMinute test 1 pass\x1b[39m")
else:
print("\x1b[91mMinute test 1 fail\x1b[39m")
result = False
if alarm.alno == 2:
print("Skipping minute test 2: requires seconds resolution unsupported by alarm2.")
else:
dt = dt_tuple((2023, 2, 1, 20, 29, 50, 0, 0))
d.set_time(dt) # 20:29:50
alarm.set(EVERY_MINUTE, sec=30)
alarm.clear()
if await wait_for_alarm(alarm, 20, 5): # Should not alarm at xx:xx:05
print("\x1b[91mMinute test 2 fail\x1b[39m")
result = False
else:
print("\x1b[32mMinute test 2 pass\x1b[39m")
if alarm.alno == 2:
print("Skipping seconds test: unsupported by alarm2.")
else:
print("Test seconds alarm (test takes 1 minute)")
dt = dt_tuple((2023, 2, 1, 20, 9, 20, 0, 0))
d.set_time(dt) # 20:09:20
alarm.set(EVERY_SECOND)
alarm_count = 0
t = time.ticks_ms()
while time.ticks_diff(time.ticks_ms(), t) < 60_000:
alarm.clear()
while not d.alarm1():
await asyncio.sleep(0)
alarm_count += 1
if 59 <= alarm_count <= 61:
print("\x1b[32mSeconds test 1 pass\x1b[39m")
else:
print("\x1b[91mSeconds test 2 fail\x1b[39m")
result = False
alarm.enable(False)
return result
async def main():
print("Testing alarm 1")
result = await test_alarm(d.alarm1)
print("Teting alarm 2")
result |= await test_alarm(d.alarm2)
if result:
print("\x1b[32mAll tests passed\x1b[39m")
else:
print("\x1b[91mSome tests failed\x1b[39m")
asyncio.run(main())