From 14ebf8879757642a4e389d7ce51661c022a08094 Mon Sep 17 00:00:00 2001 From: peterhinch Date: Tue, 28 Feb 2023 13:26:32 +0000 Subject: [PATCH] Add general purpose DS3231 driver. --- DS3231/README.md | 133 ++++++++++++++++++++++++++--- DS3231/ds3231_gen.py | 145 +++++++++++++++++++++++++++++++ DS3231/ds3231_gen_test.py | 174 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 441 insertions(+), 11 deletions(-) create mode 100644 DS3231/ds3231_gen.py create mode 100644 DS3231/ds3231_gen_test.py diff --git a/DS3231/README.md b/DS3231/README.md index 92f90e2..4c0730a 100644 --- a/DS3231/README.md +++ b/DS3231/README.md @@ -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` diff --git a/DS3231/ds3231_gen.py b/DS3231/ds3231_gen.py new file mode 100644 index 0000000..1814bc6 --- /dev/null +++ b/DS3231/ds3231_gen.py @@ -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 diff --git a/DS3231/ds3231_gen_test.py b/DS3231/ds3231_gen_test.py new file mode 100644 index 0000000..c592d12 --- /dev/null +++ b/DS3231/ds3231_gen_test.py @@ -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()) + + +