From 70c4437e524e786811035f0989a537b4ac0390e8 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sun, 5 Jan 2025 19:12:11 -0500 Subject: [PATCH 01/45] Add initial support for I2C realtime clocks. Allow for future expansion to support additional clocks, including NTP servers (in theory) --- software/CONFIGURATION.md | 11 + .../experimental/clocks/clock_source.py | 48 +++ .../firmware/experimental/clocks/ds1307.py | 106 +++++++ .../firmware/experimental/clocks/ds3231.py | 291 ++++++++++++++++++ .../experimental/clocks/null_clock.py | 45 +++ .../experimental/experimental_config.py | 17 + software/firmware/experimental/rtc.py | 77 +++++ 7 files changed, 595 insertions(+) create mode 100644 software/firmware/experimental/clocks/clock_source.py create mode 100644 software/firmware/experimental/clocks/ds1307.py create mode 100644 software/firmware/experimental/clocks/ds3231.py create mode 100644 software/firmware/experimental/clocks/null_clock.py create mode 100644 software/firmware/experimental/rtc.py diff --git a/software/CONFIGURATION.md b/software/CONFIGURATION.md index 0f5f1328d..0cde7cabe 100644 --- a/software/CONFIGURATION.md +++ b/software/CONFIGURATION.md @@ -70,11 +70,22 @@ shows the default configuration: ```json { "VOLTS_PER_OCTAVE": 1.0, + + "RTC_IMPLEMENTATION": "", + "UTC_OFFSET_HOURS": 0, + "UTC_OFFSET_MINUTES": 0, } ``` +Quantization options: - `VOLTS_PER_OCTAVE` must be one of `1.0` (Eurorack standard) or `1.2` (Buchla standard). Default: `1.0` +RTC options: +- `RTC_IMPLEMENTATION` is one of the following, representing the realtime clock enabled on your module: + - `""`: there is no RTC present. (default) + - `"ds3231"`: use a DS3231 module connected to the external I2C interface + - `"ds1307"`: use a DS1307 module connected to the external I2C interface (THIS IS UNTESTED! USE AT YOUR OWN RISK) + # Accessing config members in Python code diff --git a/software/firmware/experimental/clocks/clock_source.py b/software/firmware/experimental/clocks/clock_source.py new file mode 100644 index 000000000..24a7c7deb --- /dev/null +++ b/software/firmware/experimental/clocks/clock_source.py @@ -0,0 +1,48 @@ +""" +Interface for interacting with realtime clock hardware. + +For simplicity, we always assume that the external clock source is synchronized with UTC. This means +your I2C clocks should be set to UTC time, not local time. To configure the time zone offset, use +experimental_config to set the desired offset hours and minutes. For regions using Daylight Savings +time (most of North America, western Europe, Australia, and New Zealand, among others) you will +need to manually adjust your config file to keep local time properly adjusted. + +The Raspberry Pi Pico (and official variants like the Pico W, Pico 2, etc...) does NOT +include a realtime clock. All RTC implementations rely on some external reference time, e.g. +- external hardware (e.g. an I2C-supported external clock module) +- an wireless connection and an accessible NTP server + +The external clock source must implement the ExternalClockSource class +""" + + +class ExternalClockSource: + """ + A generic class representing any external, canonical clock source. + + Any network- or I2C-based external time source should inherit from this class and + implement the relevant functions. + + The implemented clock source must provide the time and date in UTC, 24-hour time. + """ + def __init__(self): + pass + + def datetime(self): + """ + Get the current UTC time as a tuple. + + @return a tuple of the form (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) + """ + raise NotImplementedError() + + def set_datetime(self, datetime): + """ + Set the clock's current UTC time. + + If the clock does not support setting (e.g. it's an NTP source we can only read from) + your sub-class should implement this method anyway and simply pass. + + @param datetime A tuple of the form (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) + """ + raise NotImplementedError() diff --git a/software/firmware/experimental/clocks/ds1307.py b/software/firmware/experimental/clocks/ds1307.py new file mode 100644 index 000000000..a68a2b675 --- /dev/null +++ b/software/firmware/experimental/clocks/ds1307.py @@ -0,0 +1,106 @@ +""" +Interface class for the DS1307 Realtime Clock + +This class is designed to work with a DS1307 chip mounted on an I2C carrier board +that can be connected to EuroPi's external I2C interface. The user us required to +1) provide their own RTC module +2) create/source an appropriate adapter to connect the GND, VCC, SDA, and SCL pins on EuroPi + to the RTC module +3) Mount the RTC module securely in such a way that it won't come loose nor accidentally short out + any other components. + +Based on work by Mike Causer released under the MIT license (c) 2018: +https://github.com/mcauser/micropython-tinyrtc-i2c/blob/master/ds1307.py + + +NOTE: the author of this module does NOT have access to a DS1307 module; this class is thoroughly +untested. USE AT YOUR OWN RISK. + +Hopefully I, or another EuroPi contributor, will get access to the required module to validate the code. +But at present this class is provided as-is, based wholly on Mike Causer's work with the necessary +changes to support EuroPi's RTC interface. +""" + +from micropython import const +from experimental.clocks.clock_source import ExternalClockSource + +DATETIME_REG = const(0) # 0x00-0x06 +CHIP_HALT = const(128) +CONTROL_REG = const(7) # 0x07 +RAM_REG = const(8) # 0x08-0x3F + + +class DS1307(ExternalClockSource): + """Driver for the DS1307 RTC.""" + def __init__(self, i2c, addr=0x68): + super().__init__() + self.i2c = i2c + self.addr = addr + self.weekday_start = 1 + self._halt = False + + def _dec2bcd(self, value): + """Convert decimal to binary coded decimal (BCD) format""" + return (value // 10) << 4 | (value % 10) + + def _bcd2dec(self, value): + """Convert binary coded decimal (BCD) format to decimal""" + return ((value >> 4) * 10) + (value & 0x0F) + + def datetime(self): + """ + Get the current time. + + @return datetime : tuple, (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) + """ + buf = self.i2c.readfrom_mem(self.addr, DATETIME_REG, 7) + return ( + self._bcd2dec(buf[6]) + 2000, # year + self._bcd2dec(buf[5]), # month + self._bcd2dec(buf[4]), # day + self._bcd2dec(buf[3] - self.weekday_start), # weekday + self._bcd2dec(buf[2]), # hour + self._bcd2dec(buf[1]), # minute + self._bcd2dec(buf[0] & 0x7F), # second + 0 # subseconds + ) + + def set_datetime(self, datetime): + """ + Set the current time. + + @param datetime : tuple, (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) + """ + buf = bytearray(7) + buf[0] = self._dec2bcd(datetime[6]) & 0x7F # second, msb = CH, 1=halt, 0=go + buf[1] = self._dec2bcd(datetime[5]) # minute + buf[2] = self._dec2bcd(datetime[4]) # hour + buf[3] = self._dec2bcd(datetime[3] + self.weekday_start) # weekday + buf[4] = self._dec2bcd(datetime[2]) # day + buf[5] = self._dec2bcd(datetime[1]) # month + buf[6] = self._dec2bcd(datetime[0] - 2000) # year + if (self._halt): + buf[0] |= (1 << 7) + self.i2c.writeto_mem(self.addr, DATETIME_REG, buf) + + def halt(self, val=None): + """Power up, power down or check status""" + if val is None: + return self._halt + reg = self.i2c.readfrom_mem(self.addr, DATETIME_REG, 1)[0] + if val: + reg |= CHIP_HALT + else: + reg &= ~CHIP_HALT + self._halt = bool(val) + self.i2c.writeto_mem(self.addr, DATETIME_REG, bytearray([reg])) + + def square_wave(self, sqw=0, out=0): + """Output square wave on pin SQ at 1Hz, 4.096kHz, 8.192kHz or 32.768kHz, + or disable the oscillator and output logic level high/low.""" + rs0 = 1 if sqw == 4 or sqw == 32 else 0 + rs1 = 1 if sqw == 8 or sqw == 32 else 0 + out = 1 if out > 0 else 0 + sqw = 1 if sqw > 0 else 0 + reg = rs0 | rs1 << 1 | sqw << 4 | out << 7 + self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([reg])) diff --git a/software/firmware/experimental/clocks/ds3231.py b/software/firmware/experimental/clocks/ds3231.py new file mode 100644 index 000000000..c1da06096 --- /dev/null +++ b/software/firmware/experimental/clocks/ds3231.py @@ -0,0 +1,291 @@ +""" +Interface class for the DS3231 Realtime Clock + +This class is designed to work with a DS3231 chip mounted on an I2C carrier board +that can be connected to EuroPi's external I2C interface. The user us required to +1) provide their own RTC module +2) create/source an appropriate adapter to connect the GND, VCC, SDA, and SCL pins on EuroPi + to the RTC module +3) Mount the RTC module securely in such a way that it won't come loose nor accidentally short out + any other components. + +Compatible RTC modules can be purchased relatively cheaply online. e.g.: +- https://www.amazon.ca/DS3231-Precision-AT24C32-Arduino-Raspberry/dp/B07V68443F (not an afficliate link) + +Based on code by Willem Peterse released under the MIT license (c) 2020: +https://github.com/pangopi/micropython-DS3231-AT24C32/blob/main/ds3231.py + +in-turn based on work by Mike Causer for the DS1307: +https://github.com/mcauser/micropython-tinyrtc-i2c/blob/master/ds1307.py + +""" + +from experimental.clocks.clock_source import ExternalClockSource +from micropython import const + +DATETIME_REG = const(0) # 7 bytes +ALARM1_REG = const(7) # 5 bytes +ALARM2_REG = const(11) # 4 bytes +CONTROL_REG = const(14) +STATUS_REG = const(15) +AGING_REG = const(16) +TEMPERATURE_REG = const(17) # 2 bytes + + +def dectobcd(decimal): + """Convert decimal to binary coded decimal (BCD) format""" + return (decimal // 10) << 4 | (decimal % 10) + +def bcdtodec(bcd): + """Convert binary coded decimal to decimal""" + return ((bcd >> 4) * 10) + (bcd & 0x0F) + + +class DS3231(ExternalClockSource): + """ DS3231 RTC driver. + + Hard coded to work with year 2000-2099.""" + FREQ_1 = const(1) + FREQ_1024 = const(2) + FREQ_4096 = const(3) + FREQ_8192 = const(4) + SQW_32K = const(1) + + AL1_EVERY_S = const(15) # Alarm every second + AL1_MATCH_S = const(14) # Alarm when seconds match (every minute) + AL1_MATCH_MS = const(12) # Alarm when minutes, seconds match (every hour) + AL1_MATCH_HMS = const(8) # Alarm when hours, minutes, seconds match (every day) + AL1_MATCH_DHMS = const(0) # Alarm when day|wday, hour, min, sec match (specific wday / mday) (once per month/week) + + AL2_EVERY_M = const(7) # Alarm every minute on 00 seconds + AL2_MATCH_M = const(6) # Alarm when minutes match (every hour) + AL2_MATCH_HM = const(4) # Alarm when hours and minutes match (every day) + AL2_MATCH_DHM = const(0) # Alarm when day|wday match (once per month/week) + + def __init__(self, i2c, addr=0x68): + super().__init__() + self.i2c = i2c + self.addr = addr + self._timebuf = bytearray(7) # Pre-allocate a buffer for the time data + self._buf = bytearray(1) # Pre-allocate a single bytearray for re-use + self._al1_buf = bytearray(4) + self._al2buf = bytearray(3) + + def datetime(self): + """ + Get the current time. + + Returns in 24h format, converts to 24h if clock is set to 12h format + @return a tuple, (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) + """ + self.i2c.readfrom_mem_into(self.addr, DATETIME_REG, self._timebuf) + # 0x00 - Seconds BCD + # 0x01 - Minutes BCD + # 0x02 - Hour 0 12/24 AM/PM/20s BCD + # 0x03 - WDay 1-7 0000 0 BCD + # 0x04 - Day 1-31 00 BCD + # 0x05 - Month 1-12 Century 00 BCD + # 0x06 - Year 0-99 BCD (2000-2099) + seconds = bcdtodec(self._timebuf[0]) + minutes = bcdtodec(self._timebuf[1]) + + if (self._timebuf[2] & 0x40) >> 6: # Check for 12 hour mode bit + hour = bcdtodec(self._timebuf[2] & 0x9f) # Mask out bit 6(12/24) and 5(AM/PM) + if (self._timebuf[2] & 0x20) >> 5: # bit 5(AM/PM) + # PM + hour += 12 + else: + # 24h mode + hour = bcdtodec(self._timebuf[2] & 0xbf) # Mask bit 6 (12/24 format) + + weekday = bcdtodec(self._timebuf[3]) # Can be set arbitrarily by user (1,7) + day = bcdtodec(self._timebuf[4]) + month = bcdtodec(self._timebuf[5] & 0x7f) # Mask out the century bit + year = bcdtodec(self._timebuf[6]) + 2000 + + if self.OSF(): + print("WARNING: Oscillator stop flag set. Time may not be accurate.") + + return (year, month, day, weekday, hour, minutes, seconds, 0) # Conforms to the ESP8266 RTC (v1.13) + + def set_datetime(self, datetime): + """ + Set the current time. + + @param datetime : tuple, (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) + """ + try: + self._timebuf[3] = dectobcd(datetime[6]) # Day of week + except IndexError: + self._timebuf[3] = 0 + try: + self._timebuf[0] = dectobcd(datetime[5]) # Seconds + except IndexError: + self._timebuf[0] = 0 + self._timebuf[1] = dectobcd(datetime[4]) # Minutes + self._timebuf[2] = dectobcd(datetime[3]) # Hour + the 24h format flag + self._timebuf[4] = dectobcd(datetime[2]) # Day + self._timebuf[5] = dectobcd(datetime[1]) & 0xff # Month + mask the century flag + self._timebuf[6] = dectobcd(int(str(datetime[0])[-2:])) # Year can be yyyy, or yy + self.i2c.writeto_mem(self.addr, DATETIME_REG, self._timebuf) + self._OSF_reset() + return True + + def square_wave(self, freq=None): + """Outputs Square Wave Signal + + The alarm interrupts are disabled when enabling a square wave output. Disabling SWQ out does + not enable the alarm interrupts. Set them manually with the alarm_int() method. + freq : int, + Not given: returns current setting + False = disable SQW output, + 1 = 1 Hz, + 2 = 1.024 kHz, + 3 = 4.096 kHz, + 4 = 8.192 kHz""" + if freq is None: + return self.i2c.readfrom_mem(self.addr, CONTROL_REG, 1)[0] + + if not freq: + # Set INTCN (bit 2) to 1 and both ALIE (bits 1 & 0) to 0 + self.i2c.readfrom_mem_into(self.addr, CONTROL_REG, self._buf) + self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([(self._buf[0] & 0xf8) | 0x04])) + else: + # Set the frequency in the control reg and at the same time set the INTCN to 0 + freq -= 1 + self.i2c.readfrom_mem_into(self.addr, CONTROL_REG, self._buf) + self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([(self._buf[0] & 0xe3) | (freq << 3)])) + return True + + def alarm1(self, time=None, match=AL1_MATCH_DHMS, int_en=True, weekday=False): + """Set alarm1, can match mday, wday, hour, minute, second + + time : tuple, (second,[ minute[, hour[, day]]]) + weekday : bool, select mday (False) or wday (True) + match : int, match const + int_en : bool, enable interrupt on alarm match on SQW/INT pin (disables SQW output)""" + if time is None: + # TODO Return readable string + self.i2c.readfrom_mem_into(self.addr, ALARM1_REG, self._al1_buf) + return self._al1_buf + + if isinstance(time, int): + time = (time,) + + a1m4 = (match & 0x08) << 4 + a1m3 = (match & 0x04) << 5 + a1m2 = (match & 0x02) << 6 + a1m1 = (match & 0x01) << 7 + + dydt = (1 << 6) if weekday else 0 # day / date bit + + self._al1_buf[0] = dectobcd(time[0]) | a1m1 # second + self._al1_buf[1] = (dectobcd(time[1]) | a1m2) if len(time) > 1 else a1m2 # minute + self._al1_buf[2] = (dectobcd(time[2]) | a1m3) if len(time) > 2 else a1m3 # hour + self._al1_buf[3] = (dectobcd(time[3]) | a1m4 | dydt) if len(time) > 3 else a1m4 | dydt # day (wday|mday) + + self.i2c.writeto_mem(self.addr, ALARM1_REG, self._al1_buf) + + # Set the interrupt bit + self.alarm_int(enable=int_en, alarm=1) + + # Check the alarm (will reset the alarm flag) + self.check_alarm(1) + + return self._al1_buf + + def alarm2(self, time=None, match=AL2_MATCH_DHM, int_en=True, weekday=False): + """Get/set alarm 2 (can match minute, hour, day) + + time : tuple, (minute[, hour[, day]]) + weekday : bool, select mday (False) or wday (True) + match : int, match const + int_en : bool, enable interrupt on alarm match on SQW/INT pin (disables SQW output) + Returns : bytearray(3), the alarm settings register""" + if time is None: + # TODO Return readable string + self.i2c.readfrom_mem_into(self.addr, ALARM2_REG, self._al2buf) + return self._al2buf + + if isinstance(time, int): + time = (time,) + + a2m4 = (match & 0x04) << 5 # masks + a2m3 = (match & 0x02) << 6 + a2m2 = (match & 0x01) << 7 + + dydt = (1 << 6) if weekday else 0 # day / date bit + + self._al2buf[0] = dectobcd(time[0]) | a2m2 if len(time) > 1 else a2m2 # minute + self._al2buf[1] = dectobcd(time[1]) | a2m3 if len(time) > 2 else a2m3 # hour + self._al2buf[2] = dectobcd(time[2]) | a2m4 | dydt if len(time) > 3 else a2m4 | dydt # day + + self.i2c.writeto_mem(self.addr, ALARM2_REG, self._al2buf) + + # Set the interrupt bits + self.alarm_int(enable=int_en, alarm=2) + + # Check the alarm (will reset the alarm flag) + self.check_alarm(2) + + return self._al2buf + + def alarm_int(self, enable=True, alarm=0): + """Enable/disable interrupt for alarm1, alarm2 or both. + + Enabling the interrupts disables the SQW output + enable : bool, enable/disable interrupts + alarm : int, alarm nr (0 to set both interrupts) + returns: the control register""" + if alarm in (0, 1): + self.i2c.readfrom_mem_into(self.addr, CONTROL_REG, self._buf) + if enable: + self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([(self._buf[0] & 0xfa) | 0x05])) + else: + self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([self._buf[0] & 0xfe])) + + if alarm in (0, 2): + self.i2c.readfrom_mem_into(self.addr, CONTROL_REG, self._buf) + if enable: + self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([(self._buf[0] & 0xf9) | 0x06])) + else: + self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([self._buf[0] & 0xfd])) + + return self.i2c.readfrom_mem(self.addr, CONTROL_REG, 1) + + def check_alarm(self, alarm): + """Check if the alarm flag is set and clear the alarm flag""" + self.i2c.readfrom_mem_into(self.addr, STATUS_REG, self._buf) + if (self._buf[0] & alarm) == 0: + # Alarm flag not set + return False + + # Clear alarm flag bit + self.i2c.writeto_mem(self.addr, STATUS_REG, bytearray([self._buf[0] & ~alarm])) + return True + + def output_32kHz(self, enable=True): + """Enable or disable the 32.768 kHz square wave output""" + status = self.i2c.readfrom_mem(self.addr, STATUS_REG, 1)[0] + if enable: + self.i2c.writeto_mem(self.addr, STATUS_REG, bytearray([status | (1 << 3)])) + else: + self.i2c.writeto_mem(self.addr, STATUS_REG, bytearray([status & (~(1 << 3))])) + + def OSF(self): + """Returns the oscillator stop flag (OSF). + + 1 indicates that the oscillator is stopped or was stopped for some + period in the past and may be used to judge the validity of + the time data. + returns : bool""" + return bool(self.i2c.readfrom_mem(self.addr, STATUS_REG, 1)[0] >> 7) + + def _OSF_reset(self): + """Clear the oscillator stop flag (OSF)""" + self.i2c.readfrom_mem_into(self.addr, STATUS_REG, self._buf) + self.i2c.writeto_mem(self.addr, STATUS_REG, bytearray([self._buf[0] & 0x7f])) + + def _is_busy(self): + """Returns True when device is busy doing TCXO management""" + return bool(self.i2c.readfrom_mem(self.addr, STATUS_REG, 1)[0] & (1 << 2)) diff --git a/software/firmware/experimental/clocks/null_clock.py b/software/firmware/experimental/clocks/null_clock.py new file mode 100644 index 000000000..2faf23aab --- /dev/null +++ b/software/firmware/experimental/clocks/null_clock.py @@ -0,0 +1,45 @@ +from experimental.clocks.clock_source import ExternalClockSource + +from utime import ticks_ms, ticks_diff + +class NullClock(ExternalClockSource): + """ + A placeholder replacement for a realtime clock. + + This class sets the base time to 1 January, 1970, 00:00:00.0 and adds the current value of + ticks_ms() to that time. This allows the RTC to tick up, but won't actually be indicitave of the real time. + + Because ticks_ms() rolls over ever 2**30 ms, the datetime returned by this clock will also roll over + at the same interval. BUT, 2**30 ms ~= 298 hours, so unless your EuroPi is powered-on 12 days continuously + this won't ever cause problems. + """ + + def __init__(self): + super().__init__() + self.last_check_at = ticks_ms() + + def set_datetime(self, datetime): + # we don't allow configuring the time here + pass + + def datetime(self): + t = ticks_ms() + + ms = t % 1000 + s = (t // 1000) % 60 + m = (t // (1000 * 60)) % 60 + h = (t // (1000 * 60 * 60)) % 24 + dd = (t // (1000 * 60 * 60 * 24)) % 31 + mm = 1 + yy = 1970 + wd = (4 + dd) % 7 # 1 jan 1970 was a thursday + + return ( + yy, + mm, + dd, + h, + m, + s + ms / 1000.0, + wd + ) diff --git a/software/firmware/experimental/experimental_config.py b/software/firmware/experimental/experimental_config.py index 2658c21a3..4c9c149b5 100644 --- a/software/firmware/experimental/experimental_config.py +++ b/software/firmware/experimental/experimental_config.py @@ -13,6 +13,11 @@ # Buchla standard is 1.2 volts per octave (0.1 volts per semitone) BUCHLA_VOLTS_PER_OCTAVE = 1.2 +# RTC implementations +RTC_NONE = "" +RTC_DS3231 = "ds3231" +RTC_DS1307 = "ds1307" + class ExperimentalConfig: """This class provides global config points for experimental features. @@ -38,6 +43,18 @@ def config_points(cls): choices=[MOOG_VOLTS_PER_OCTAVE, BUCHLA_VOLTS_PER_OCTAVE], default=MOOG_VOLTS_PER_OCTAVE, ), + + # RTC implementation + # by default there is no RTC + configuration.choice( + name="RTC_IMPLEMENTATION", + choices=[ + RTC_NONE, + RTC_DS3231, + RTC_DS1307, + ], + default=RTC_NONE, + ), ] # fmt: on diff --git a/software/firmware/experimental/rtc.py b/software/firmware/experimental/rtc.py new file mode 100644 index 000000000..f4c6d3792 --- /dev/null +++ b/software/firmware/experimental/rtc.py @@ -0,0 +1,77 @@ +""" +Interface for realtime clock support. + +EuroPi can have 0-1 realtime clocks at a time. The implementation is specified via experimental_config. +Underlying implementations to handle network and/or I2C hardware is contained with the experimental.clocks +namespace. + +This module reads the desired implementation from experimental_config and instantiates a clock object +that can be used externally. +""" + +import europi +from experimental.experimental_config import RTC_NONE, RTC_DS1307, RTC_DS3231 + +class RealtimeClock: + YEAR = 0 + MONTH = 1 + DAY = 2 + HOUR = 3 + MINUTE = 4 + SECOND = 5 + WEEKDAY = 6 + + _month_lengths = [ + 31, + 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31 + ] + + def is_leap_year(self, datetime): + # a year is a leap year if it is divisible by 4 + # but NOT a multple of 100, unless it's also a multiple of 400 + year = datetime[self.YEAR] + return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) + + def month_length(self, datetime): + if datetime[self.MONTH] == 2 and self.is_leap_year(datetime): + return 29 + else: + return self._month_lengths[datetime[self.MONTH] - 1] + + def __init__(self, source): + """ + Create a new realtime clock. + + @param source An ExternalClock implementation we read the time from + """ + self.source = source + + def now(self): + """ + Get the current UTC time. + + @return a tuple, (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) + """ + return self.source.datetime() + +if europi.experimental_config.RTC_IMPLEMENTATION == RTC_DS1307: + from experimental.clocks.ds1307 import DS1307 + source = DS1307(europi.external_i2c) +elif europi.experimental_config.RTC_IMPLEMENTATION == RTC_DS3231: + from experimental.clocks.ds3231 import DS3231 + source = DS3231(europi.external_i2c) +else: + from experimental.clocks.null_clock import NullClock + source = NullClock() + +clock = RealtimeClock(source) From eb0ceb63279617e5b73a576036cafa7c68956ffa Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sun, 5 Jan 2025 19:16:53 -0500 Subject: [PATCH 02/45] Remove the UTC offsets from the documentation; these aren't implemented --- software/CONFIGURATION.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/software/CONFIGURATION.md b/software/CONFIGURATION.md index 0cde7cabe..d3fead7ce 100644 --- a/software/CONFIGURATION.md +++ b/software/CONFIGURATION.md @@ -70,10 +70,7 @@ shows the default configuration: ```json { "VOLTS_PER_OCTAVE": 1.0, - "RTC_IMPLEMENTATION": "", - "UTC_OFFSET_HOURS": 0, - "UTC_OFFSET_MINUTES": 0, } ``` From 766a90ec1ef93d340b5f4d3884b773f55d11ff71 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sun, 5 Jan 2025 19:35:55 -0500 Subject: [PATCH 03/45] Linting, ignore formatting in some blocks where the formatting improves legibility --- .../firmware/experimental/clocks/ds1307.py | 43 +++++---- .../firmware/experimental/clocks/ds3231.py | 90 ++++++++++++------- .../experimental/clocks/null_clock.py | 10 +-- 3 files changed, 82 insertions(+), 61 deletions(-) diff --git a/software/firmware/experimental/clocks/ds1307.py b/software/firmware/experimental/clocks/ds1307.py index a68a2b675..626b901a9 100644 --- a/software/firmware/experimental/clocks/ds1307.py +++ b/software/firmware/experimental/clocks/ds1307.py @@ -24,14 +24,17 @@ from micropython import const from experimental.clocks.clock_source import ExternalClockSource -DATETIME_REG = const(0) # 0x00-0x06 -CHIP_HALT = const(128) -CONTROL_REG = const(7) # 0x07 -RAM_REG = const(8) # 0x08-0x3F +# fmt: off +DATETIME_REG = const(0) # 0x00-0x06 +CHIP_HALT = const(128) # 0x80, 0b10000000 +CONTROL_REG = const(7) # 0x07 +RAM_REG = const(8) # 0x08-0x3F +# fmt: on class DS1307(ExternalClockSource): """Driver for the DS1307 RTC.""" + def __init__(self, i2c, addr=0x68): super().__init__() self.i2c = i2c @@ -54,16 +57,18 @@ def datetime(self): @return datetime : tuple, (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) """ buf = self.i2c.readfrom_mem(self.addr, DATETIME_REG, 7) + # fmt: off return ( - self._bcd2dec(buf[6]) + 2000, # year - self._bcd2dec(buf[5]), # month - self._bcd2dec(buf[4]), # day - self._bcd2dec(buf[3] - self.weekday_start), # weekday - self._bcd2dec(buf[2]), # hour - self._bcd2dec(buf[1]), # minute - self._bcd2dec(buf[0] & 0x7F), # second + self._bcd2dec(buf[6]) + 2000, # year + self._bcd2dec(buf[5]), # month + self._bcd2dec(buf[4]), # day + self._bcd2dec(buf[3] - self.weekday_start), # weekday + self._bcd2dec(buf[2]), # hour + self._bcd2dec(buf[1]), # minute + self._bcd2dec(buf[0] & 0x7F), # second 0 # subseconds ) + # fmt: on def set_datetime(self, datetime): """ @@ -71,17 +76,19 @@ def set_datetime(self, datetime): @param datetime : tuple, (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) """ + # fmt: off buf = bytearray(7) - buf[0] = self._dec2bcd(datetime[6]) & 0x7F # second, msb = CH, 1=halt, 0=go - buf[1] = self._dec2bcd(datetime[5]) # minute - buf[2] = self._dec2bcd(datetime[4]) # hour - buf[3] = self._dec2bcd(datetime[3] + self.weekday_start) # weekday - buf[4] = self._dec2bcd(datetime[2]) # day - buf[5] = self._dec2bcd(datetime[1]) # month - buf[6] = self._dec2bcd(datetime[0] - 2000) # year + buf[0] = self._dec2bcd(datetime[6]) & 0x7F # second, msb = CH, 1=halt, 0=go + buf[1] = self._dec2bcd(datetime[5]) # minute + buf[2] = self._dec2bcd(datetime[4]) # hour + buf[3] = self._dec2bcd(datetime[3] + self.weekday_start) # weekday + buf[4] = self._dec2bcd(datetime[2]) # day + buf[5] = self._dec2bcd(datetime[1]) # month + buf[6] = self._dec2bcd(datetime[0] - 2000) # year if (self._halt): buf[0] |= (1 << 7) self.i2c.writeto_mem(self.addr, DATETIME_REG, buf) + # fmt: on def halt(self, val=None): """Power up, power down or check status""" diff --git a/software/firmware/experimental/clocks/ds3231.py b/software/firmware/experimental/clocks/ds3231.py index c1da06096..12c0d96b7 100644 --- a/software/firmware/experimental/clocks/ds3231.py +++ b/software/firmware/experimental/clocks/ds3231.py @@ -23,6 +23,7 @@ from experimental.clocks.clock_source import ExternalClockSource from micropython import const +# fmt: off DATETIME_REG = const(0) # 7 bytes ALARM1_REG = const(7) # 5 bytes ALARM2_REG = const(11) # 4 bytes @@ -30,12 +31,14 @@ STATUS_REG = const(15) AGING_REG = const(16) TEMPERATURE_REG = const(17) # 2 bytes +# fmt: on def dectobcd(decimal): """Convert decimal to binary coded decimal (BCD) format""" return (decimal // 10) << 4 | (decimal % 10) + def bcdtodec(bcd): """Convert binary coded decimal to decimal""" return ((bcd >> 4) * 10) + (bcd & 0x0F) @@ -45,29 +48,31 @@ class DS3231(ExternalClockSource): """ DS3231 RTC driver. Hard coded to work with year 2000-2099.""" + # fmt: off FREQ_1 = const(1) FREQ_1024 = const(2) FREQ_4096 = const(3) FREQ_8192 = const(4) SQW_32K = const(1) - AL1_EVERY_S = const(15) # Alarm every second - AL1_MATCH_S = const(14) # Alarm when seconds match (every minute) - AL1_MATCH_MS = const(12) # Alarm when minutes, seconds match (every hour) - AL1_MATCH_HMS = const(8) # Alarm when hours, minutes, seconds match (every day) - AL1_MATCH_DHMS = const(0) # Alarm when day|wday, hour, min, sec match (specific wday / mday) (once per month/week) + AL1_EVERY_S = const(15) # Alarm every second + AL1_MATCH_S = const(14) # Alarm when seconds match (every minute) + AL1_MATCH_MS = const(12) # Alarm when minutes, seconds match (every hour) + AL1_MATCH_HMS = const(8) # Alarm when hours, minutes, seconds match (every day) + AL1_MATCH_DHMS = const(0) # Alarm when day|wday, hour, min, sec match (specific wday / mday) (once per month/week) - AL2_EVERY_M = const(7) # Alarm every minute on 00 seconds - AL2_MATCH_M = const(6) # Alarm when minutes match (every hour) - AL2_MATCH_HM = const(4) # Alarm when hours and minutes match (every day) - AL2_MATCH_DHM = const(0) # Alarm when day|wday match (once per month/week) + AL2_EVERY_M = const(7) # Alarm every minute on 00 seconds + AL2_MATCH_M = const(6) # Alarm when minutes match (every hour) + AL2_MATCH_HM = const(4) # Alarm when hours and minutes match (every day) + AL2_MATCH_DHM = const(0) # Alarm when day|wday match (once per month/week) + # fmt: on def __init__(self, i2c, addr=0x68): super().__init__() self.i2c = i2c self.addr = addr - self._timebuf = bytearray(7) # Pre-allocate a buffer for the time data - self._buf = bytearray(1) # Pre-allocate a single bytearray for re-use + self._timebuf = bytearray(7) # Pre-allocate a buffer for the time data + self._buf = bytearray(1) # Pre-allocate a single bytearray for re-use self._al1_buf = bytearray(4) self._al2buf = bytearray(3) @@ -90,23 +95,32 @@ def datetime(self): minutes = bcdtodec(self._timebuf[1]) if (self._timebuf[2] & 0x40) >> 6: # Check for 12 hour mode bit - hour = bcdtodec(self._timebuf[2] & 0x9f) # Mask out bit 6(12/24) and 5(AM/PM) + hour = bcdtodec(self._timebuf[2] & 0x9F) # Mask out bit 6(12/24) and 5(AM/PM) if (self._timebuf[2] & 0x20) >> 5: # bit 5(AM/PM) # PM hour += 12 else: # 24h mode - hour = bcdtodec(self._timebuf[2] & 0xbf) # Mask bit 6 (12/24 format) + hour = bcdtodec(self._timebuf[2] & 0xBF) # Mask bit 6 (12/24 format) weekday = bcdtodec(self._timebuf[3]) # Can be set arbitrarily by user (1,7) day = bcdtodec(self._timebuf[4]) - month = bcdtodec(self._timebuf[5] & 0x7f) # Mask out the century bit + month = bcdtodec(self._timebuf[5] & 0x7F) # Mask out the century bit year = bcdtodec(self._timebuf[6]) + 2000 if self.OSF(): print("WARNING: Oscillator stop flag set. Time may not be accurate.") - return (year, month, day, weekday, hour, minutes, seconds, 0) # Conforms to the ESP8266 RTC (v1.13) + return ( + year, + month, + day, + weekday, + hour, + minutes, + seconds, + 0, + ) # Conforms to the ESP8266 RTC (v1.13) def set_datetime(self, datetime): """ @@ -114,22 +128,24 @@ def set_datetime(self, datetime): @param datetime : tuple, (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) """ + # fmt: off try: - self._timebuf[3] = dectobcd(datetime[6]) # Day of week + self._timebuf[3] = dectobcd(datetime[6]) # Day of week except IndexError: self._timebuf[3] = 0 try: - self._timebuf[0] = dectobcd(datetime[5]) # Seconds + self._timebuf[0] = dectobcd(datetime[5]) # Seconds except IndexError: self._timebuf[0] = 0 - self._timebuf[1] = dectobcd(datetime[4]) # Minutes - self._timebuf[2] = dectobcd(datetime[3]) # Hour + the 24h format flag - self._timebuf[4] = dectobcd(datetime[2]) # Day - self._timebuf[5] = dectobcd(datetime[1]) & 0xff # Month + mask the century flag - self._timebuf[6] = dectobcd(int(str(datetime[0])[-2:])) # Year can be yyyy, or yy + self._timebuf[1] = dectobcd(datetime[4]) # Minutes + self._timebuf[2] = dectobcd(datetime[3]) # Hour + the 24h format flag + self._timebuf[4] = dectobcd(datetime[2]) # Day + self._timebuf[5] = dectobcd(datetime[1]) & 0xFF # Month + mask the century flag + self._timebuf[6] = dectobcd(int(str(datetime[0])[-2:])) # Year can be yyyy, or yy self.i2c.writeto_mem(self.addr, DATETIME_REG, self._timebuf) self._OSF_reset() return True + #fmt: on def square_wave(self, freq=None): """Outputs Square Wave Signal @@ -143,19 +159,21 @@ def square_wave(self, freq=None): 2 = 1.024 kHz, 3 = 4.096 kHz, 4 = 8.192 kHz""" + # fmt: off if freq is None: return self.i2c.readfrom_mem(self.addr, CONTROL_REG, 1)[0] if not freq: # Set INTCN (bit 2) to 1 and both ALIE (bits 1 & 0) to 0 self.i2c.readfrom_mem_into(self.addr, CONTROL_REG, self._buf) - self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([(self._buf[0] & 0xf8) | 0x04])) + self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([(self._buf[0] & 0xF8) | 0x04])) else: # Set the frequency in the control reg and at the same time set the INTCN to 0 freq -= 1 self.i2c.readfrom_mem_into(self.addr, CONTROL_REG, self._buf) - self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([(self._buf[0] & 0xe3) | (freq << 3)])) + self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([(self._buf[0] & 0xE3) | (freq << 3)])) return True + # fmt: on def alarm1(self, time=None, match=AL1_MATCH_DHMS, int_en=True, weekday=False): """Set alarm1, can match mday, wday, hour, minute, second @@ -177,12 +195,14 @@ def alarm1(self, time=None, match=AL1_MATCH_DHMS, int_en=True, weekday=False): a1m2 = (match & 0x02) << 6 a1m1 = (match & 0x01) << 7 - dydt = (1 << 6) if weekday else 0 # day / date bit + dydt = (1 << 6) if weekday else 0 # day / date bit - self._al1_buf[0] = dectobcd(time[0]) | a1m1 # second - self._al1_buf[1] = (dectobcd(time[1]) | a1m2) if len(time) > 1 else a1m2 # minute - self._al1_buf[2] = (dectobcd(time[2]) | a1m3) if len(time) > 2 else a1m3 # hour - self._al1_buf[3] = (dectobcd(time[3]) | a1m4 | dydt) if len(time) > 3 else a1m4 | dydt # day (wday|mday) + # fmt: off + self._al1_buf[0] = dectobcd(time[0]) | a1m1 # second + self._al1_buf[1] = (dectobcd(time[1]) | a1m2) if len(time) > 1 else a1m2 # minute + self._al1_buf[2] = (dectobcd(time[2]) | a1m3) if len(time) > 2 else a1m3 # hour + self._al1_buf[3] = (dectobcd(time[3]) | a1m4 | dydt) if len(time) > 3 else a1m4 | dydt # day (wday|mday) + # fmt: on self.i2c.writeto_mem(self.addr, ALARM1_REG, self._al1_buf) @@ -237,21 +257,23 @@ def alarm_int(self, enable=True, alarm=0): enable : bool, enable/disable interrupts alarm : int, alarm nr (0 to set both interrupts) returns: the control register""" + # fmt: off if alarm in (0, 1): self.i2c.readfrom_mem_into(self.addr, CONTROL_REG, self._buf) if enable: - self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([(self._buf[0] & 0xfa) | 0x05])) + self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([(self._buf[0] & 0xFA) | 0x05])) else: - self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([self._buf[0] & 0xfe])) + self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([self._buf[0] & 0xFE])) if alarm in (0, 2): self.i2c.readfrom_mem_into(self.addr, CONTROL_REG, self._buf) if enable: - self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([(self._buf[0] & 0xf9) | 0x06])) + self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([(self._buf[0] & 0xF9) | 0x06])) else: - self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([self._buf[0] & 0xfd])) + self.i2c.writeto_mem(self.addr, CONTROL_REG, bytearray([self._buf[0] & 0xFD])) return self.i2c.readfrom_mem(self.addr, CONTROL_REG, 1) + # fmt: on def check_alarm(self, alarm): """Check if the alarm flag is set and clear the alarm flag""" @@ -284,7 +306,7 @@ def OSF(self): def _OSF_reset(self): """Clear the oscillator stop flag (OSF)""" self.i2c.readfrom_mem_into(self.addr, STATUS_REG, self._buf) - self.i2c.writeto_mem(self.addr, STATUS_REG, bytearray([self._buf[0] & 0x7f])) + self.i2c.writeto_mem(self.addr, STATUS_REG, bytearray([self._buf[0] & 0x7F])) def _is_busy(self): """Returns True when device is busy doing TCXO management""" diff --git a/software/firmware/experimental/clocks/null_clock.py b/software/firmware/experimental/clocks/null_clock.py index 2faf23aab..32523dc56 100644 --- a/software/firmware/experimental/clocks/null_clock.py +++ b/software/firmware/experimental/clocks/null_clock.py @@ -34,12 +34,4 @@ def datetime(self): yy = 1970 wd = (4 + dd) % 7 # 1 jan 1970 was a thursday - return ( - yy, - mm, - dd, - h, - m, - s + ms / 1000.0, - wd - ) + return (yy, mm, dd, h, m, s + ms / 1000.0, wd) From dbf352e4405a7ef75853c558682faa09b6df8cad Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sun, 5 Jan 2025 19:38:36 -0500 Subject: [PATCH 04/45] Linting --- software/firmware/experimental/clocks/ds3231.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/software/firmware/experimental/clocks/ds3231.py b/software/firmware/experimental/clocks/ds3231.py index 12c0d96b7..958e5aec2 100644 --- a/software/firmware/experimental/clocks/ds3231.py +++ b/software/firmware/experimental/clocks/ds3231.py @@ -145,7 +145,7 @@ def set_datetime(self, datetime): self.i2c.writeto_mem(self.addr, DATETIME_REG, self._timebuf) self._OSF_reset() return True - #fmt: on + # fmt: on def square_wave(self, freq=None): """Outputs Square Wave Signal @@ -230,7 +230,7 @@ def alarm2(self, time=None, match=AL2_MATCH_DHM, int_en=True, weekday=False): if isinstance(time, int): time = (time,) - a2m4 = (match & 0x04) << 5 # masks + a2m4 = (match & 0x04) << 5 # masks a2m3 = (match & 0x02) << 6 a2m2 = (match & 0x01) << 7 From 80da8832642fed96b4f9090a8eb196884ff39eb3 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sun, 5 Jan 2025 19:48:38 -0500 Subject: [PATCH 05/45] Ignore more blocks GitHub's linter is whinging about (but my local linter is fine with). Re-order methods inside the RealtimeClock class --- .../experimental/clocks/clock_source.py | 1 + .../firmware/experimental/clocks/ds3231.py | 33 ++++++---- .../experimental/clocks/null_clock.py | 2 +- software/firmware/experimental/rtc.py | 62 +++++++++++++++---- 4 files changed, 71 insertions(+), 27 deletions(-) diff --git a/software/firmware/experimental/clocks/clock_source.py b/software/firmware/experimental/clocks/clock_source.py index 24a7c7deb..e81f81fca 100644 --- a/software/firmware/experimental/clocks/clock_source.py +++ b/software/firmware/experimental/clocks/clock_source.py @@ -25,6 +25,7 @@ class ExternalClockSource: The implemented clock source must provide the time and date in UTC, 24-hour time. """ + def __init__(self): pass diff --git a/software/firmware/experimental/clocks/ds3231.py b/software/firmware/experimental/clocks/ds3231.py index 958e5aec2..e3aec2516 100644 --- a/software/firmware/experimental/clocks/ds3231.py +++ b/software/firmware/experimental/clocks/ds3231.py @@ -45,9 +45,12 @@ def bcdtodec(bcd): class DS3231(ExternalClockSource): - """ DS3231 RTC driver. + """ + DS3231 RTC driver. + + Hard coded to work with year 2000-2099. + """ - Hard coded to work with year 2000-2099.""" # fmt: off FREQ_1 = const(1) FREQ_1024 = const(2) @@ -68,6 +71,7 @@ class DS3231(ExternalClockSource): # fmt: on def __init__(self, i2c, addr=0x68): + # fmt: off super().__init__() self.i2c = i2c self.addr = addr @@ -75,6 +79,7 @@ def __init__(self, i2c, addr=0x68): self._buf = bytearray(1) # Pre-allocate a single bytearray for re-use self._al1_buf = bytearray(4) self._al2buf = bytearray(3) + # fmt: on def datetime(self): """ @@ -94,18 +99,19 @@ def datetime(self): seconds = bcdtodec(self._timebuf[0]) minutes = bcdtodec(self._timebuf[1]) - if (self._timebuf[2] & 0x40) >> 6: # Check for 12 hour mode bit - hour = bcdtodec(self._timebuf[2] & 0x9F) # Mask out bit 6(12/24) and 5(AM/PM) - if (self._timebuf[2] & 0x20) >> 5: # bit 5(AM/PM) + # Check for 12 hour mode bit + if (self._timebuf[2] & 0x40) >> 6: + hour = bcdtodec(self._timebuf[2] & 0x9F) # Mask out bit 6(12/24) and 5(AM/PM) + if (self._timebuf[2] & 0x20) >> 5: # bit 5(AM/PM) # PM hour += 12 else: # 24h mode - hour = bcdtodec(self._timebuf[2] & 0xBF) # Mask bit 6 (12/24 format) + hour = bcdtodec(self._timebuf[2] & 0xBF) # Mask bit 6 (12/24 format) - weekday = bcdtodec(self._timebuf[3]) # Can be set arbitrarily by user (1,7) + weekday = bcdtodec(self._timebuf[3]) # Can be set arbitrarily by user (1,7) day = bcdtodec(self._timebuf[4]) - month = bcdtodec(self._timebuf[5] & 0x7F) # Mask out the century bit + month = bcdtodec(self._timebuf[5] & 0x7F) # Mask out the century bit year = bcdtodec(self._timebuf[6]) + 2000 if self.OSF(): @@ -234,11 +240,12 @@ def alarm2(self, time=None, match=AL2_MATCH_DHM, int_en=True, weekday=False): a2m3 = (match & 0x02) << 6 a2m2 = (match & 0x01) << 7 - dydt = (1 << 6) if weekday else 0 # day / date bit - - self._al2buf[0] = dectobcd(time[0]) | a2m2 if len(time) > 1 else a2m2 # minute - self._al2buf[1] = dectobcd(time[1]) | a2m3 if len(time) > 2 else a2m3 # hour - self._al2buf[2] = dectobcd(time[2]) | a2m4 | dydt if len(time) > 3 else a2m4 | dydt # day + # fmt: off + dydt = (1 << 6) if weekday else 0 # day / date bit + self._al2buf[0] = dectobcd(time[0]) | a2m2 if len(time) > 1 else a2m2 # minute + self._al2buf[1] = dectobcd(time[1]) | a2m3 if len(time) > 2 else a2m3 # hour + self._al2buf[2] = dectobcd(time[2]) | a2m4 | dydt if len(time) > 3 else a2m4 | dydt # day + # fmt: on self.i2c.writeto_mem(self.addr, ALARM2_REG, self._al2buf) diff --git a/software/firmware/experimental/clocks/null_clock.py b/software/firmware/experimental/clocks/null_clock.py index 32523dc56..f71a140f6 100644 --- a/software/firmware/experimental/clocks/null_clock.py +++ b/software/firmware/experimental/clocks/null_clock.py @@ -1,7 +1,7 @@ from experimental.clocks.clock_source import ExternalClockSource - from utime import ticks_ms, ticks_diff + class NullClock(ExternalClockSource): """ A placeholder replacement for a realtime clock. diff --git a/software/firmware/experimental/rtc.py b/software/firmware/experimental/rtc.py index f4c6d3792..ac27ecde1 100644 --- a/software/firmware/experimental/rtc.py +++ b/software/firmware/experimental/rtc.py @@ -12,7 +12,15 @@ import europi from experimental.experimental_config import RTC_NONE, RTC_DS1307, RTC_DS3231 + class RealtimeClock: + """ + A continually-running clock that provides the day & date. + + This class wraps around an external clock source, e.g. an I2C-compatible RTC + module or a network connection to an NTP server + """ + YEAR = 0 MONTH = 1 DAY = 2 @@ -21,6 +29,8 @@ class RealtimeClock: SECOND = 5 WEEKDAY = 6 + # fmt: off + # The lengths of the months in a non-leap-year _month_lengths = [ 31, 28, @@ -35,24 +45,13 @@ class RealtimeClock: 30, 31 ] - - def is_leap_year(self, datetime): - # a year is a leap year if it is divisible by 4 - # but NOT a multple of 100, unless it's also a multiple of 400 - year = datetime[self.YEAR] - return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) - - def month_length(self, datetime): - if datetime[self.MONTH] == 2 and self.is_leap_year(datetime): - return 29 - else: - return self._month_lengths[datetime[self.MONTH] - 1] + # fmt: on def __init__(self, source): """ Create a new realtime clock. - @param source An ExternalClock implementation we read the time from + @param source An ExternalClockSource implementation we read the time from """ self.source = source @@ -64,6 +63,42 @@ def now(self): """ return self.source.datetime() + def is_leap_year(self, datetime): + """ + Determine if the datetime's year is a leap year or not + + @return True if the datetime is a leap year, otherwise False + """ + # a year is a leap year if it is divisible by 4 + # but NOT a multple of 100, unless it's also a multiple of 400 + year = datetime[self.YEAR] + return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) + + def year_length(self, datetime): + """ + Determine the number of days in the datetime's year + + @return The number of days in the year, taking leap years into account + """ + if self.is_leap_year(datetime): + return 366 + return 365 + + def month_length(self, datetime): + """ + Get the numer of days in the month + + This takes leap-years into consideration + + @return The number of days in the datetime's month + """ + if datetime[self.MONTH] == 2 and self.is_leap_year(datetime): + return 29 + else: + return self._month_lengths[datetime[self.MONTH] - 1] + + +# fmt: off if europi.experimental_config.RTC_IMPLEMENTATION == RTC_DS1307: from experimental.clocks.ds1307 import DS1307 source = DS1307(europi.external_i2c) @@ -73,5 +108,6 @@ def now(self): else: from experimental.clocks.null_clock import NullClock source = NullClock() +# fmt: on clock = RealtimeClock(source) From 52f6a51c96b85a4cd4237a898a17a24eeb343324 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sun, 5 Jan 2025 19:51:56 -0500 Subject: [PATCH 06/45] Remove an unnecessary else --- software/firmware/experimental/rtc.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/software/firmware/experimental/rtc.py b/software/firmware/experimental/rtc.py index ac27ecde1..a4d1bc382 100644 --- a/software/firmware/experimental/rtc.py +++ b/software/firmware/experimental/rtc.py @@ -94,8 +94,7 @@ def month_length(self, datetime): """ if datetime[self.MONTH] == 2 and self.is_leap_year(datetime): return 29 - else: - return self._month_lengths[datetime[self.MONTH] - 1] + return self._month_lengths[datetime[self.MONTH] - 1] # fmt: off From 7c8a31ddeea660edb4694ef4a27ec282781ad8d1 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Mon, 6 Jan 2025 02:11:15 -0500 Subject: [PATCH 07/45] Fix some inconsistencies with the datetime tuples when setting vs getting the time. Move some code around, add a __str__ function to the clock class so we can print the time with print(clock). Add some supporting data structures to get the name of the weekday or month. Validate the datetime tuple before setting the clock. --- .../experimental/clocks/clock_source.py | 102 +++++++++++++++++ .../firmware/experimental/clocks/ds1307.py | 38 ++++--- .../firmware/experimental/clocks/ds3231.py | 7 +- software/firmware/experimental/rtc.py | 105 +++++++++--------- 4 files changed, 180 insertions(+), 72 deletions(-) diff --git a/software/firmware/experimental/clocks/clock_source.py b/software/firmware/experimental/clocks/clock_source.py index e81f81fca..a378129e6 100644 --- a/software/firmware/experimental/clocks/clock_source.py +++ b/software/firmware/experimental/clocks/clock_source.py @@ -26,6 +26,32 @@ class ExternalClockSource: The implemented clock source must provide the time and date in UTC, 24-hour time. """ + YEAR = 0 + MONTH = 1 + DAY = 2 + HOUR = 3 + MINUTE = 4 + SECOND = 5 + WEEKDAY = 6 + + # fmt: off + # The lengths of the months in a non-leap-year + _month_lengths = [ + 31, + 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31 + ] + # fmt: on + def __init__(self): pass @@ -47,3 +73,79 @@ def set_datetime(self, datetime): @param datetime A tuple of the form (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) """ raise NotImplementedError() + + def is_leap_year(self, datetime): + """ + Determine if the datetime's year is a leap year or not. + + @return True if the datetime is a leap year, otherwise False + """ + # a year is a leap year if it is divisible by 4 + # but NOT a multple of 100, unless it's also a multiple of 400 + year = datetime[self.YEAR] + return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) + + def year_length(self, datetime): + """ + Determine the number of days in the datetime's year. + + @return The number of days in the year, taking leap years into account + """ + if self.is_leap_year(datetime): + return 366 + return 365 + + def month_length(self, datetime): + """ + Get the numer of days in the month. + + This takes leap-years into consideration + + @return The number of days in the datetime's month + """ + if datetime[self.MONTH] == 2 and self.is_leap_year(datetime): + return 29 + return self._month_lengths[datetime[self.MONTH] - 1] + + def check_valid_datetime(self, datetime): + """ + Check if a datetime contains valid values. + + Raises a ValueError if any field is out of range + + @param datetime + """ + n_days = self.month_length(datetime) + if ( + # To anyone reading this from the past: congrats on time-travel! Your year is not supported + # To anyone reading this from the future: sorry, you'll need to modify some code to get your + # dates supported + # Also, it's pretty cool if you're actually reading this beyond 2099. I'm flattered. + datetime[self.YEAR] < 2000 or + datetime[self.YEAR] > 2099 or # somewhat arbitrary, but the DS3231 library I'm using cuts off here, too + datetime[self.MONTH] < 1 or + datetime[self.MONTH] > 12 or + datetime[self.DAY] < 1 or + datetime[self.DAY] > n_days or + datetime[self.HOUR] < 0 or + datetime[self.HOUR] >= 24 or # time is 00:00:00 to 23:59:59 + datetime[self.MINUTE] < 0 or + datetime[self.MINUTE] >= 60 or + ( + # seconds are optional, must be 0-59 + len(datetime) >= self.SECOND + 1 and + ( + datetime[self.SECOND] < 0 or + datetime[self.SECOND] >= 60 + ) + ) or + ( + # weekday is optional, must be 1-7 + len(datetime) >= self.WEEKDAY + 1 and + ( + datetime[self.WEEKDAY] < 1 or + datetime[self.WEEKDAY] > 7 + ) + ) + ): + raise ValueError("Invalid datetime tuple") diff --git a/software/firmware/experimental/clocks/ds1307.py b/software/firmware/experimental/clocks/ds1307.py index 626b901a9..b20218c5e 100644 --- a/software/firmware/experimental/clocks/ds1307.py +++ b/software/firmware/experimental/clocks/ds1307.py @@ -39,7 +39,6 @@ def __init__(self, i2c, addr=0x68): super().__init__() self.i2c = i2c self.addr = addr - self.weekday_start = 1 self._halt = False def _dec2bcd(self, value): @@ -59,14 +58,13 @@ def datetime(self): buf = self.i2c.readfrom_mem(self.addr, DATETIME_REG, 7) # fmt: off return ( - self._bcd2dec(buf[6]) + 2000, # year - self._bcd2dec(buf[5]), # month - self._bcd2dec(buf[4]), # day - self._bcd2dec(buf[3] - self.weekday_start), # weekday - self._bcd2dec(buf[2]), # hour - self._bcd2dec(buf[1]), # minute - self._bcd2dec(buf[0] & 0x7F), # second - 0 # subseconds + self._bcd2dec(buf[6]) + 2000, # year + self._bcd2dec(buf[5]), # month + self._bcd2dec(buf[4]), # day + self._bcd2dec(buf[2]), # hour + self._bcd2dec(buf[1]), # minute + self._bcd2dec(buf[0] & 0x7F), # second + self._bcd2dec(buf[3]), # weekday ) # fmt: on @@ -76,15 +74,23 @@ def set_datetime(self, datetime): @param datetime : tuple, (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) """ + self.check_valid_datetime(datetime) + # fmt: off buf = bytearray(7) - buf[0] = self._dec2bcd(datetime[6]) & 0x7F # second, msb = CH, 1=halt, 0=go - buf[1] = self._dec2bcd(datetime[5]) # minute - buf[2] = self._dec2bcd(datetime[4]) # hour - buf[3] = self._dec2bcd(datetime[3] + self.weekday_start) # weekday - buf[4] = self._dec2bcd(datetime[2]) # day - buf[5] = self._dec2bcd(datetime[1]) # month - buf[6] = self._dec2bcd(datetime[0] - 2000) # year + try: + buf[3] = dectobcd(datetime[6]) # Day of week + except IndexError: + buf[3] = 0 + try: + buf[0] = dectobcd(datetime[5]) # Seconds + except IndexError: + buf[0] = 0 + buf[1] = dectobcd(datetime[4]) # Minutes + buf[2] = dectobcd(datetime[3]) # Hour + the 24h format flag + buf[4] = dectobcd(datetime[2]) # Day + buf[5] = dectobcd(datetime[1]) & 0xFF # Month + mask the century flag + buf[6] = dectobcd(int(str(datetime[0])[-2:])) # Year can be yyyy, or yy if (self._halt): buf[0] |= (1 << 7) self.i2c.writeto_mem(self.addr, DATETIME_REG, buf) diff --git a/software/firmware/experimental/clocks/ds3231.py b/software/firmware/experimental/clocks/ds3231.py index e3aec2516..528c6736d 100644 --- a/software/firmware/experimental/clocks/ds3231.py +++ b/software/firmware/experimental/clocks/ds3231.py @@ -121,12 +121,11 @@ def datetime(self): year, month, day, - weekday, hour, minutes, seconds, - 0, - ) # Conforms to the ESP8266 RTC (v1.13) + weekday, + ) def set_datetime(self, datetime): """ @@ -134,6 +133,8 @@ def set_datetime(self, datetime): @param datetime : tuple, (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) """ + self.check_valid_datetime(datetime) + # fmt: off try: self._timebuf[3] = dectobcd(datetime[6]) # Day of week diff --git a/software/firmware/experimental/rtc.py b/software/firmware/experimental/rtc.py index a4d1bc382..fede15f34 100644 --- a/software/firmware/experimental/rtc.py +++ b/software/firmware/experimental/rtc.py @@ -12,15 +12,42 @@ import europi from experimental.experimental_config import RTC_NONE, RTC_DS1307, RTC_DS3231 - -class RealtimeClock: +# Weekdays are represented by an integer 1-7 +# ISO 8601 specifies the week starts on Monday +WeekdayNames = { + 1: "Monday", + 2: "Tuesday", + 3: "Wednesday", + 4: "Thursday", + 5: "Friday", + 6: "Saturday", + 7: "Sunday", +} + +# Months are specified as an integer 1-12 +# Shortened names can be extracted using the first 3 letters +MonthNames = { + 1: "January", + 2: "February", + 3: "March", + 4: "April", + 5: "May", + 6: "June", + 7: "July", + 8: "August", + 9: "September", + 10: "October", + 11: "November", + 12: "December", +} + + +class DateTimeIndex: """ - A continually-running clock that provides the day & date. + Indices for the fields within a weekday tuple - This class wraps around an external clock source, e.g. an I2C-compatible RTC - module or a network connection to an NTP server + Note that SECOND and WEEKDAY are optional and may be omitted in some implementations """ - YEAR = 0 MONTH = 1 DAY = 2 @@ -29,23 +56,15 @@ class RealtimeClock: SECOND = 5 WEEKDAY = 6 - # fmt: off - # The lengths of the months in a non-leap-year - _month_lengths = [ - 31, - 28, - 31, - 30, - 31, - 30, - 31, - 31, - 30, - 31, - 30, - 31 - ] - # fmt: on + + +class RealtimeClock: + """ + A continually-running clock that provides the day & date. + + This class wraps around an external clock source, e.g. an I2C-compatible RTC + module or a network connection to an NTP server + """ def __init__(self, source): """ @@ -63,39 +82,19 @@ def now(self): """ return self.source.datetime() - def is_leap_year(self, datetime): - """ - Determine if the datetime's year is a leap year or not - - @return True if the datetime is a leap year, otherwise False - """ - # a year is a leap year if it is divisible by 4 - # but NOT a multple of 100, unless it's also a multiple of 400 - year = datetime[self.YEAR] - return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) - - def year_length(self, datetime): + def __str__(self): """ - Determine the number of days in the datetime's year + Return the current time as a string - @return The number of days in the year, taking leap years into account + @return A string with the format "[Weekday] YYYY/MM/DD HH:MM[:SS]" """ - if self.is_leap_year(datetime): - return 366 - return 365 - - def month_length(self, datetime): - """ - Get the numer of days in the month - - This takes leap-years into consideration - - @return The number of days in the datetime's month - """ - if datetime[self.MONTH] == 2 and self.is_leap_year(datetime): - return 29 - return self._month_lengths[datetime[self.MONTH] - 1] - + t = self.now() + if len(t) > DateTimeIndex.WEEKDAY: + return f"{WeekdayNames[t[DateTimeIndex.WEEKDAY]]} {t[DateTimeIndex.YEAR]}/{t[DateTimeIndex.MONTH]:02}/{t[DateTimeIndex.DAY]:02} {t[DateTimeIndex.HOUR]:02}:{t[DateTimeIndex.MINUTE]:02}:{t[DateTimeIndex.SECOND]:02}" + elif len(t) > DateTimeIndex.SECOND: + return f"{t[DateTimeIndex.YEAR]}/{t[DateTimeIndex.MONTH]:02}/{t[DateTimeIndex.DAY]:02} {t[DateTimeIndex.HOUR]:02}:{t[DateTimeIndex.MINUTE]:02}:{t[DateTimeIndex.SECOND]:02}" + else: + return f"{t[DateTimeIndex.YEAR]}/{t[DateTimeIndex.MONTH]:02}/{t[DateTimeIndex.DAY]:02} {t[DateTimeIndex.HOUR]:02}:{t[DateTimeIndex.MINUTE]:02}" # fmt: off if europi.experimental_config.RTC_IMPLEMENTATION == RTC_DS1307: From debda2adb0dbd306b91b38a401ce8289cdddc4ff Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Mon, 6 Jan 2025 02:15:01 -0500 Subject: [PATCH 08/45] Formatting fixes --- software/firmware/experimental/clocks/clock_source.py | 2 ++ software/firmware/experimental/rtc.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/software/firmware/experimental/clocks/clock_source.py b/software/firmware/experimental/clocks/clock_source.py index a378129e6..c39a2ec64 100644 --- a/software/firmware/experimental/clocks/clock_source.py +++ b/software/firmware/experimental/clocks/clock_source.py @@ -115,6 +115,7 @@ def check_valid_datetime(self, datetime): @param datetime """ + # fmt: off n_days = self.month_length(datetime) if ( # To anyone reading this from the past: congrats on time-travel! Your year is not supported @@ -149,3 +150,4 @@ def check_valid_datetime(self, datetime): ) ): raise ValueError("Invalid datetime tuple") + # fmt: on diff --git a/software/firmware/experimental/rtc.py b/software/firmware/experimental/rtc.py index fede15f34..994d04515 100644 --- a/software/firmware/experimental/rtc.py +++ b/software/firmware/experimental/rtc.py @@ -48,6 +48,7 @@ class DateTimeIndex: Note that SECOND and WEEKDAY are optional and may be omitted in some implementations """ + YEAR = 0 MONTH = 1 DAY = 2 @@ -57,7 +58,6 @@ class DateTimeIndex: WEEKDAY = 6 - class RealtimeClock: """ A continually-running clock that provides the day & date. From a79adcd9e1208d8570d78f8664422e08eef208e2 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Mon, 6 Jan 2025 02:16:13 -0500 Subject: [PATCH 09/45] Missing newline --- software/firmware/experimental/rtc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/software/firmware/experimental/rtc.py b/software/firmware/experimental/rtc.py index 994d04515..c009ff749 100644 --- a/software/firmware/experimental/rtc.py +++ b/software/firmware/experimental/rtc.py @@ -96,6 +96,7 @@ def __str__(self): else: return f"{t[DateTimeIndex.YEAR]}/{t[DateTimeIndex.MONTH]:02}/{t[DateTimeIndex.DAY]:02} {t[DateTimeIndex.HOUR]:02}:{t[DateTimeIndex.MINUTE]:02}" + # fmt: off if europi.experimental_config.RTC_IMPLEMENTATION == RTC_DS1307: from experimental.clocks.ds1307 import DS1307 From 951b89c1f7ba67d1c5dfe51c35a21a569e2dbc97 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Mon, 6 Jan 2025 02:30:08 -0500 Subject: [PATCH 10/45] Expand the comment block to provide instructions on how to set the clock --- .../firmware/experimental/clocks/ds3231.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/software/firmware/experimental/clocks/ds3231.py b/software/firmware/experimental/clocks/ds3231.py index 528c6736d..357b2323b 100644 --- a/software/firmware/experimental/clocks/ds3231.py +++ b/software/firmware/experimental/clocks/ds3231.py @@ -18,6 +18,24 @@ in-turn based on work by Mike Causer for the DS1307: https://github.com/mcauser/micropython-tinyrtc-i2c/blob/master/ds1307.py +If you have a brand-new DS3231, or have recently replaced your module's battery, you may need to manually update +the clock. To do so: + +1. Connect the clock module to your EuroPi +2. Connect your EuroPi to a computer via the USB port +3. Open Thonny and make sure experimental_config is configured to use the DS3231. If you make any changes to + experimental_config, restart the Raspberry Pi Pico before proceeding. +4. In Thonny's Python terminal, run the following code: + + >>> from experimental.rtc import clock + >>> clock.source.set_datetime((2025, 6, 14, 22, 59, 0, 6)) + +This will set the clock to 14 June 2025, 22:59:00, and set the weekday to Saturday (6). +The tuple is of the form (Year, Month, Day, Hour, Minute [, Second[, Weekday]]). It is recommended +to set the seconds & weekday, but it is optional. + +Note that the clock _should_ be set to UTC, not local time. If you choose to use local time instead +some scripts that assume the clock is set to UTC may behave incorrectly. """ from experimental.clocks.clock_source import ExternalClockSource From 5a5fde80572f2f54b98a120548cd7310859d149c Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Mon, 6 Jan 2025 02:32:37 -0500 Subject: [PATCH 11/45] Minor formatting changes --- software/firmware/experimental/clocks/ds3231.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/software/firmware/experimental/clocks/ds3231.py b/software/firmware/experimental/clocks/ds3231.py index 357b2323b..9b6f2ea2c 100644 --- a/software/firmware/experimental/clocks/ds3231.py +++ b/software/firmware/experimental/clocks/ds3231.py @@ -42,13 +42,13 @@ from micropython import const # fmt: off -DATETIME_REG = const(0) # 7 bytes -ALARM1_REG = const(7) # 5 bytes -ALARM2_REG = const(11) # 4 bytes +DATETIME_REG = const(0) # 7 bytes +ALARM1_REG = const(7) # 5 bytes +ALARM2_REG = const(11) # 4 bytes CONTROL_REG = const(14) STATUS_REG = const(15) AGING_REG = const(16) -TEMPERATURE_REG = const(17) # 2 bytes +TEMPERATURE_REG = const(17) # 2 bytes # fmt: on From d376631333527bb4e1f48bffc95ce4f4fd1858af Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Mon, 6 Jan 2025 03:44:31 -0500 Subject: [PATCH 12/45] Typo --- software/firmware/experimental/clocks/ds3231.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/software/firmware/experimental/clocks/ds3231.py b/software/firmware/experimental/clocks/ds3231.py index 9b6f2ea2c..3ef3c680e 100644 --- a/software/firmware/experimental/clocks/ds3231.py +++ b/software/firmware/experimental/clocks/ds3231.py @@ -2,7 +2,7 @@ Interface class for the DS3231 Realtime Clock This class is designed to work with a DS3231 chip mounted on an I2C carrier board -that can be connected to EuroPi's external I2C interface. The user us required to +that can be connected to EuroPi's external I2C interface. The user is required to 1) provide their own RTC module 2) create/source an appropriate adapter to connect the GND, VCC, SDA, and SCL pins on EuroPi to the RTC module From 5c4f8c58846935263851c696717e5c959920542c Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Mon, 6 Jan 2025 03:45:28 -0500 Subject: [PATCH 13/45] Typo --- software/firmware/experimental/clocks/ds1307.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/software/firmware/experimental/clocks/ds1307.py b/software/firmware/experimental/clocks/ds1307.py index b20218c5e..01f8b9e77 100644 --- a/software/firmware/experimental/clocks/ds1307.py +++ b/software/firmware/experimental/clocks/ds1307.py @@ -2,7 +2,7 @@ Interface class for the DS1307 Realtime Clock This class is designed to work with a DS1307 chip mounted on an I2C carrier board -that can be connected to EuroPi's external I2C interface. The user us required to +that can be connected to EuroPi's external I2C interface. The user is required to 1) provide their own RTC module 2) create/source an appropriate adapter to connect the GND, VCC, SDA, and SCL pins on EuroPi to the RTC module From 1239ce0ae98e8dca06d62818d14be9eb88aa055d Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Mon, 6 Jan 2025 13:43:17 -0500 Subject: [PATCH 14/45] Refactor the RTC module to move all of the weekday/month names into classes, add enum-like constants for their integer equivalents. Remove subseconds from the null clock. Remove references to local time offsets from docstrings --- .../experimental/clocks/clock_source.py | 10 +- .../experimental/clocks/null_clock.py | 3 +- software/firmware/experimental/rtc.py | 91 +++++++++++++------ 3 files changed, 66 insertions(+), 38 deletions(-) diff --git a/software/firmware/experimental/clocks/clock_source.py b/software/firmware/experimental/clocks/clock_source.py index c39a2ec64..a7f0d3568 100644 --- a/software/firmware/experimental/clocks/clock_source.py +++ b/software/firmware/experimental/clocks/clock_source.py @@ -1,16 +1,14 @@ """ Interface for interacting with realtime clock hardware. -For simplicity, we always assume that the external clock source is synchronized with UTC. This means -your I2C clocks should be set to UTC time, not local time. To configure the time zone offset, use -experimental_config to set the desired offset hours and minutes. For regions using Daylight Savings -time (most of North America, western Europe, Australia, and New Zealand, among others) you will -need to manually adjust your config file to keep local time properly adjusted. +For consistency, we always assume that the external clock source is synchronized with UTC. This means +your I2C clock should be set to UTC time, not local time. If you set your I2C clock to local time +some scripts may behave incorrectly. The Raspberry Pi Pico (and official variants like the Pico W, Pico 2, etc...) does NOT include a realtime clock. All RTC implementations rely on some external reference time, e.g. - external hardware (e.g. an I2C-supported external clock module) -- an wireless connection and an accessible NTP server +- a wireless connection and an accessible NTP server The external clock source must implement the ExternalClockSource class """ diff --git a/software/firmware/experimental/clocks/null_clock.py b/software/firmware/experimental/clocks/null_clock.py index f71a140f6..13d272736 100644 --- a/software/firmware/experimental/clocks/null_clock.py +++ b/software/firmware/experimental/clocks/null_clock.py @@ -25,7 +25,6 @@ def set_datetime(self, datetime): def datetime(self): t = ticks_ms() - ms = t % 1000 s = (t // 1000) % 60 m = (t // (1000 * 60)) % 60 h = (t // (1000 * 60 * 60)) % 24 @@ -34,4 +33,4 @@ def datetime(self): yy = 1970 wd = (4 + dd) % 7 # 1 jan 1970 was a thursday - return (yy, mm, dd, h, m, s + ms / 1000.0, wd) + return (yy, mm, dd, h, m, s, wd) diff --git a/software/firmware/experimental/rtc.py b/software/firmware/experimental/rtc.py index c009ff749..b45d933e5 100644 --- a/software/firmware/experimental/rtc.py +++ b/software/firmware/experimental/rtc.py @@ -12,35 +12,6 @@ import europi from experimental.experimental_config import RTC_NONE, RTC_DS1307, RTC_DS3231 -# Weekdays are represented by an integer 1-7 -# ISO 8601 specifies the week starts on Monday -WeekdayNames = { - 1: "Monday", - 2: "Tuesday", - 3: "Wednesday", - 4: "Thursday", - 5: "Friday", - 6: "Saturday", - 7: "Sunday", -} - -# Months are specified as an integer 1-12 -# Shortened names can be extracted using the first 3 letters -MonthNames = { - 1: "January", - 2: "February", - 3: "March", - 4: "April", - 5: "May", - 6: "June", - 7: "July", - 8: "August", - 9: "September", - 10: "October", - 11: "November", - 12: "December", -} - class DateTimeIndex: """ @@ -58,6 +29,66 @@ class DateTimeIndex: WEEKDAY = 6 +class Month: + """ + Container class for month names + """ + + JANUARY = 1 + FEBRUARY = 2 + MARCH = 3 + APRIL = 4 + MAY = 5 + JUNE = 6 + JULY = 7 + AUGUST = 8 + SEPTEMBER = 9 + OCTOBER = 10 + NOVEMBER = 11 + DECEMBER = 12 + + NAME = { + 1: "January", + 2: "February", + 3: "March", + 4: "April", + 5: "May", + 6: "June", + 7: "July", + 8: "August", + 9: "September", + 10: "October", + 11: "November", + 12: "December", + } + + +class Weekday: + """ + Container class for weekday names + + ISO 8601 specifies the week starts on Monday (1) and ends on Sunday (7) + """ + + MONDAY = 1 + TUESDAY = 2 + WEDNESDAY = 3 + THURSDAY = 4 + FRIDAY = 5 + SATURDAY = 6 + SUNDAY = 7 + + NAME = { + 1: "Monday", + 2: "Tuesday", + 3: "Wednesday", + 4: "Thursday", + 5: "Friday", + 6: "Saturday", + 7: "Sunday", + } + + class RealtimeClock: """ A continually-running clock that provides the day & date. @@ -90,7 +121,7 @@ def __str__(self): """ t = self.now() if len(t) > DateTimeIndex.WEEKDAY: - return f"{WeekdayNames[t[DateTimeIndex.WEEKDAY]]} {t[DateTimeIndex.YEAR]}/{t[DateTimeIndex.MONTH]:02}/{t[DateTimeIndex.DAY]:02} {t[DateTimeIndex.HOUR]:02}:{t[DateTimeIndex.MINUTE]:02}:{t[DateTimeIndex.SECOND]:02}" + return f"{Weekday.NAME[t[DateTimeIndex.WEEKDAY]]} {t[DateTimeIndex.YEAR]}/{t[DateTimeIndex.MONTH]:02}/{t[DateTimeIndex.DAY]:02} {t[DateTimeIndex.HOUR]:02}:{t[DateTimeIndex.MINUTE]:02}:{t[DateTimeIndex.SECOND]:02}" elif len(t) > DateTimeIndex.SECOND: return f"{t[DateTimeIndex.YEAR]}/{t[DateTimeIndex.MONTH]:02}/{t[DateTimeIndex.DAY]:02} {t[DateTimeIndex.HOUR]:02}:{t[DateTimeIndex.MINUTE]:02}:{t[DateTimeIndex.SECOND]:02}" else: From 36b99dfd7ee1e8e5db2e96a7163d0a48a56114b5 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Tue, 7 Jan 2025 01:40:54 -0500 Subject: [PATCH 15/45] Start working on a basic RTC-based random sequencer --- software/contrib/README.md | 9 ++++ software/contrib/daily_random.md | 57 +++++++++++++++++++++ software/contrib/daily_random.py | 85 ++++++++++++++++++++++++++++++++ software/contrib/menu.py | 1 + 4 files changed, 152 insertions(+) create mode 100644 software/contrib/daily_random.md create mode 100644 software/contrib/daily_random.py diff --git a/software/contrib/README.md b/software/contrib/README.md index b5a4eab4a..42ab83a1c 100644 --- a/software/contrib/README.md +++ b/software/contrib/README.md @@ -54,6 +54,15 @@ Recording of CV can be primed so that you can record a movement without missing Author: [anselln](https://github.com/anselln)
Labels: sequencer, CV, performance +### Daily Random \[ [documentation](/software/contrib/daily_random.md) | [script](/software/contrib/daily_random.md) \] +A pseudo-random gate and CV sequencer that uses a realtime clock to generate patterns. + +Requires installing and configuring a realtime clock module, connected to EuroPi's external I2C interface for best results. + +Author: [chrisib](https://github.com/chrisib) +
Labels: sequencer, gate, cv, random, realtime clock + + ### Egressus Melodium \[ [documentation](/software/contrib/egressus_melodiam.md) | [script](/software/contrib/egressus_melodiam.py) \] Clockable and free-running LFO and random CV pattern generator diff --git a/software/contrib/daily_random.md b/software/contrib/daily_random.md new file mode 100644 index 000000000..660865925 --- /dev/null +++ b/software/contrib/daily_random.md @@ -0,0 +1,57 @@ +# Daily Random + +Generates pseudo-random gate and CV patterns based on the current date and time. + +## I/O Assignments + +- `ain`: not used +- `din`: external clock input +- `b1`: not used +- `b2`: not used +- `k1`: not used +- `k2`: not used +- `cv1`: daily gate sequence (updates at midnight UTC) +- `cv2`: hourly gate sequence (updates at the top of every hour) +- `cv3`: minute gate sequence (updates at the top of every minute) +- `cv4`: daily CV sequence (updates at midnight UTC) +- `cv5`: hourly CV sequence (updates at the top of every hour) +- `cv6`: minute CV sequence (updates at the top of every minute) + +## Required Hardware + +This script _can_ be used on a normal EuroPi, but will result in highly predictable +patterns. For best result, connect a Realtime Clock (RTC) to EuroPi's secondary I2C +header pins, located on the underside of the board. + +## Installing the clock + +TODO: pictures of mounting a DS3231 + +## Configuring the clock + +The default external I2C settings from `europi_config` should be used, unless you have +a specific need to change them in `config/EuroPiConfig.json`: +```json +{ + "EXTERNAL_I2C_SDA": 2, + "EXTERNAL_I2C_SCL": 3, + "EXTERNAL_I2C_CHANNEL": 1, + "EXTERNAL_I2C_FREQUENCY": 100000, + "EXTERNAL_I2C_TIMEOUT": 50000, +} +``` + +You will also need to edit `config/ExperimentalConfig.json`: +```json +{ + "RTC_IMPLEMENTATION": "ds3231" +} +``` + +Once installed and configured, if you have not already set the clock's time, you can do so by +connecting your EuroPi to Thonny's Python terminal and running the following commands: +```python +from experimental.rtc import clock, Month, Weekday +clock.source.set_datetime(clock.source.set_datetime((2025, Month.JUNE, 14, 22, 59, 0, Weekday.THURSDAY))) +``` +You should change the day and time to match the current UTC time. diff --git a/software/contrib/daily_random.py b/software/contrib/daily_random.py new file mode 100644 index 000000000..36ec65eac --- /dev/null +++ b/software/contrib/daily_random.py @@ -0,0 +1,85 @@ +from europi import * +from europi_script import EuroPiScript + +from experimental.rtc import clock + +import random + + +class DailyRandom(EuroPiScript): + """ + Generates a set of pseudo-random gate and CV sequences every day + + This script requires a realtime clock. Please refer to + experimental.clocks for supported clocks. + + If no RTC is installed & configured, the default clock will be used, + but this will generate the same pattern every time the module is + restarted. + """ + + SEQUENCE_LENGTH = 16 + + def __init__(self): + super().__init__() + + current_time = clock.now() + self.regenerate_sequences(current_time) + + self.sequence_index = 0 + + @din.handler + def advance_sequence(): + self.sequence_index += 1 + + def regenerate_sequences(self, datetime): + (year, month, day, hour, minute) = datetime[0:5] + + seed_1 = year ^ month ^ day + seed_2 = year ^ month ^ day ^ hour + seed_3 = year ^ month ^ day ^ hour ^ minute + + def generate_gates(seed): + random.seed(seed) + bits = random.getrandbits(self.SEQUENCE_LENGTH) + pattern = [] + for i in range(self.SEQUENCE_LENGTH): + pattern.append(bits & 0x01) + bits = bits >> 1 + return pattern + + def generate_cv(seed): + random.seed(seed) + pattern = [] + for i in range(self.SEQUENCE_LENGTH): + pattern.append(random.random() * europi_config.MAX_OUTPUT_VOLTAGE) + return pattern + + self.sequences = [ + generate_gates(seed_1), + generate_gates(seed_2), + generate_gates(seed_3), + generate_cv(seed_1), + generate_cv(seed_2), + generate_cv(seed_3), + ] + + def main(self): + # clear the display + oled.fill(0) + oled.show() + + while True: + # regenerate the patterns when the day rolls over + current_time = clock.now() + self.regenerate_sequences(current_time) + + for i in range(len(cvs)): + if i < len(cvs) // 2: + cvs[i].voltage(self.sequences[i][self.sequence_index % self.SEQUENCE_LENGTH] * europi_config.GATE_VOLTAGE) + else: + cvs[i].voltage(self.sequences[i][self.sequence_index % self.SEQUENCE_LENGTH] * europi_config.MAX_OUTPUT_VOLTAGE) + + +if __name__ == "__main__": + DailyRandom().main() diff --git a/software/contrib/menu.py b/software/contrib/menu.py index 70cd31ca6..3f07f53f8 100644 --- a/software/contrib/menu.py +++ b/software/contrib/menu.py @@ -29,6 +29,7 @@ ["Consequencer", "contrib.consequencer.Consequencer"], ["Conway", "contrib.conway.Conway"], ["CVecorder", "contrib.cvecorder.CVecorder"], + ["Daily Random", "contrib.daily_random.DailyRandom"], ["Diagnostic", "contrib.diagnostic.Diagnostic"], ["EgressusMelodiam", "contrib.egressus_melodiam.EgressusMelodiam"], ["EnvelopeGen", "contrib.envelope_generator.EnvelopeGenerator"], From f61d7edf98e622eaea3c5ec1dade856b7f0e6e77 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Tue, 7 Jan 2025 03:51:54 -0500 Subject: [PATCH 16/45] Add a weekday-depending bitmask as a 12-bit baseline for the seed and then shift the datetime fields over it to reduce seed collisions --- software/contrib/daily_random.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/software/contrib/daily_random.py b/software/contrib/daily_random.py index 36ec65eac..bc5f5ecb9 100644 --- a/software/contrib/daily_random.py +++ b/software/contrib/daily_random.py @@ -20,6 +20,16 @@ class DailyRandom(EuroPiScript): SEQUENCE_LENGTH = 16 + BITMASKS = [ + 0b101010101010, + 0b001100110011, + 0b100100100100, + 0b111000111000, + 0b011000111001, + 0b011011011011, + 0b110011001100, + ] + def __init__(self): super().__init__() @@ -35,9 +45,21 @@ def advance_sequence(): def regenerate_sequences(self, datetime): (year, month, day, hour, minute) = datetime[0:5] - seed_1 = year ^ month ^ day - seed_2 = year ^ month ^ day ^ hour - seed_3 = year ^ month ^ day ^ hour ^ minute + try: + weekday = datetime[DateTimeIndex.WEEKDAY] % 7 + except IndexError: + weekday = 0 + + # bit-shift the fields around to reduce collisions + # mask: 12 bits + # year: 11 bits + # month: 4 bits + # day: 5 bits + # hour: 6 bits + # minute: 6 bits + seed_1 = self.BITMASKS[weekday] ^ year ^ (month << 7) ^ day + seed_2 = self.BITMASKS[weekday] ^ year ^ (month << 6) ^ day ^ hour + seed_3 = self.BITMASKS[weekday] ^ year ^ (month << 7) ^ day ^ (hour << 6) ^ minute def generate_gates(seed): random.seed(seed) From 10b97b55f3915cce06832ed0e6b3183b8332f947 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Tue, 7 Jan 2025 22:44:14 -0500 Subject: [PATCH 17/45] Show the clock on daily_random, add a Sequence class to help keep things organized. Tweak the seeds to further reduce collisions --- software/contrib/daily_random.py | 132 ++++++++++++++++---------- software/firmware/experimental/rtc.py | 11 +++ 2 files changed, 95 insertions(+), 48 deletions(-) diff --git a/software/contrib/daily_random.py b/software/contrib/daily_random.py index bc5f5ecb9..3c92524a0 100644 --- a/software/contrib/daily_random.py +++ b/software/contrib/daily_random.py @@ -1,11 +1,54 @@ from europi import * from europi_script import EuroPiScript -from experimental.rtc import clock +from experimental.rtc import clock, DateTimeIndex import random +class Sequence: + """ + A single gate or CV sequence + """ + BASE_SEQUENCE_LENGTH = 16 + + def __init__(self, seed): + self.index = 0 + self.regenerate(seed) + + def regenerate(self, seed): + random.seed(seed) + + # randomize the length so the majority are 16, but we get some longer or shorter + length = self.BASE_SEQUENCE_LENGTH + r = random.random() + if r < 0.1: + length -= 2 + elif r < 0.25: + length -= 1 + elif r > 0.8: + length += 2 + elif r > 0.75: + length += 1 + + pattern = [] + for i in range(length): + pattern.append(random.random()) + + self.pattern = pattern + + def next(self): + self.index = (self.index + 1) % len(self.pattern) + + @property + def gate_volts(self): + return (int(self.pattern[self.index] * 2) % 2) * europi_config.GATE_VOLTAGE + + @property + def cv_volts(self): + return self.pattern[self.index] * europi_config.MAX_OUTPUT_VOLTAGE + + class DailyRandom(EuroPiScript): """ Generates a set of pseudo-random gate and CV sequences every day @@ -23,26 +66,36 @@ class DailyRandom(EuroPiScript): BITMASKS = [ 0b101010101010, 0b001100110011, - 0b100100100100, + 0b000111000111, + 0b111111000000, 0b111000111000, - 0b011000111001, - 0b011011011011, 0b110011001100, + 0b010101010101, ] def __init__(self): super().__init__() - current_time = clock.now() - self.regenerate_sequences(current_time) + self.sequences = [ + Sequence(0) for cv in cvs + ] + self.regenerate_sequences() - self.sequence_index = 0 + self.trigger_recvd = False @din.handler def advance_sequence(): - self.sequence_index += 1 + for s in self.sequences: + s.next() + self.trigger_recvd = True - def regenerate_sequences(self, datetime): + @din.handler_falling + def gates_off(): + for i in range(len(cvs) // 2): + cvs[i].off() + + def regenerate_sequences(self): + datetime = clock.now() (year, month, day, hour, minute) = datetime[0:5] try: @@ -57,50 +110,33 @@ def regenerate_sequences(self, datetime): # day: 5 bits # hour: 6 bits # minute: 6 bits - seed_1 = self.BITMASKS[weekday] ^ year ^ (month << 7) ^ day - seed_2 = self.BITMASKS[weekday] ^ year ^ (month << 6) ^ day ^ hour - seed_3 = self.BITMASKS[weekday] ^ year ^ (month << 7) ^ day ^ (hour << 6) ^ minute - - def generate_gates(seed): - random.seed(seed) - bits = random.getrandbits(self.SEQUENCE_LENGTH) - pattern = [] - for i in range(self.SEQUENCE_LENGTH): - pattern.append(bits & 0x01) - bits = bits >> 1 - return pattern - - def generate_cv(seed): - random.seed(seed) - pattern = [] - for i in range(self.SEQUENCE_LENGTH): - pattern.append(random.random() * europi_config.MAX_OUTPUT_VOLTAGE) - return pattern - - self.sequences = [ - generate_gates(seed_1), - generate_gates(seed_2), - generate_gates(seed_3), - generate_cv(seed_1), - generate_cv(seed_2), - generate_cv(seed_3), + seeds = [ + self.BITMASKS[weekday] ^ year ^ (month << 7) ^ day, + self.BITMASKS[weekday] ^ year ^ (month << 6) ^ day ^ ~hour, + self.BITMASKS[weekday] ^ year ^ (month << 7) ^ day ^ (hour << 6) ^ minute, ] + for i in range(len(self.sequences)): + self.sequences[i].regenerate(seeds[i % len(seeds)]) + def main(self): - # clear the display - oled.fill(0) - oled.show() + oled.centre_text(str(clock).replace(" ", "\n")) + last_draw_at = clock.now() while True: - # regenerate the patterns when the day rolls over - current_time = clock.now() - self.regenerate_sequences(current_time) - - for i in range(len(cvs)): - if i < len(cvs) // 2: - cvs[i].voltage(self.sequences[i][self.sequence_index % self.SEQUENCE_LENGTH] * europi_config.GATE_VOLTAGE) - else: - cvs[i].voltage(self.sequences[i][self.sequence_index % self.SEQUENCE_LENGTH] * europi_config.MAX_OUTPUT_VOLTAGE) + now = clock.now() + if not clock.compare_datetimes(now, last_draw_at): + self.regenerate_sequences() + oled.centre_text(str(clock).replace(" ", "\n")) + last_draw_at = now + + if self.trigger_recvd: + self.trigger_recvd = False + for i in range(len(self.sequences)): + if i < len(cvs) // 2: + cvs[i].voltage(self.sequences[i].gate_volts) + else: + cvs[i].voltage(self.sequences[i].cv_volts) if __name__ == "__main__": diff --git a/software/firmware/experimental/rtc.py b/software/firmware/experimental/rtc.py index b45d933e5..4f2ec8734 100644 --- a/software/firmware/experimental/rtc.py +++ b/software/firmware/experimental/rtc.py @@ -113,6 +113,17 @@ def now(self): """ return self.source.datetime() + def compare_datetimes(self, t1, t2): + """ + Comapre two datetimes to see if they represent the same time + + If one time has fewer fields than the other, we only consider the fields present in both + """ + for i in range(min(len(t1), len(t2))): + if t1[i] != t2[i]: + return False + return True + def __str__(self): """ Return the current time as a string From afa376e8d33e4b38a790d75a2ebf64d792885dc1 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Wed, 8 Jan 2025 13:19:34 -0500 Subject: [PATCH 18/45] Move the clock installation instructions to a new file --- software/contrib/daily_random.md | 34 +--------- software/realtime_clock.md | 110 +++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 32 deletions(-) create mode 100644 software/realtime_clock.md diff --git a/software/contrib/daily_random.md b/software/contrib/daily_random.md index 660865925..6d4e9c107 100644 --- a/software/contrib/daily_random.md +++ b/software/contrib/daily_random.md @@ -23,35 +23,5 @@ This script _can_ be used on a normal EuroPi, but will result in highly predicta patterns. For best result, connect a Realtime Clock (RTC) to EuroPi's secondary I2C header pins, located on the underside of the board. -## Installing the clock - -TODO: pictures of mounting a DS3231 - -## Configuring the clock - -The default external I2C settings from `europi_config` should be used, unless you have -a specific need to change them in `config/EuroPiConfig.json`: -```json -{ - "EXTERNAL_I2C_SDA": 2, - "EXTERNAL_I2C_SCL": 3, - "EXTERNAL_I2C_CHANNEL": 1, - "EXTERNAL_I2C_FREQUENCY": 100000, - "EXTERNAL_I2C_TIMEOUT": 50000, -} -``` - -You will also need to edit `config/ExperimentalConfig.json`: -```json -{ - "RTC_IMPLEMENTATION": "ds3231" -} -``` - -Once installed and configured, if you have not already set the clock's time, you can do so by -connecting your EuroPi to Thonny's Python terminal and running the following commands: -```python -from experimental.rtc import clock, Month, Weekday -clock.source.set_datetime(clock.source.set_datetime((2025, Month.JUNE, 14, 22, 59, 0, Weekday.THURSDAY))) -``` -You should change the day and time to match the current UTC time. +See [Realtime Clock Installation](/software/realtime_clock.md) for instructions on +installing and configuring the realtime clock. diff --git a/software/realtime_clock.md b/software/realtime_clock.md new file mode 100644 index 000000000..230d7001a --- /dev/null +++ b/software/realtime_clock.md @@ -0,0 +1,110 @@ +# Realtime Clock + +EuroPi does not natively include a realtime clock. An external clock module can be +installed and configured to provide time and date information to EuroPi's programs. + +## What is a realtime clock? + +A realtime clock is a clock with a battery that will continue to track the time +even when your synthesizer is powered-off. This allows EuroPi to know what the +current date and time are, even if the module has been turned off for some time. + +Without a realtime clock, the only way to get the time is by using Micropython's +`time.ticks_ms()` or `time.ticks_us()` functions; these return the ticks (in +milliseconds or microseconds) since the module was powered-on, but won't accurately +represent the real-world time and date. + +## What clock should I buy? + +There are many I2C compatible realtime clock modules available. EuroPi has been +tested with the DS3231, though the similar DS1307 may also work. + +These modules can be bought online from a variety of sources, for example: +- https://www.amazon.ca/dp/B09S8VF9GL +- https://shop.kincony.com/products/ds3231-rtc-module +- https://www.mouser.ca/ProductDetail/Analog-Devices-Maxim-Integrated/DS3231MPMB1 +- https://www.aliexpress.com/item/1005001875764383.html + +## How do I connect the clock? + +The realtime clock should be connected to EuroPi's I2C header pins, located on the +rear of the module, between the Raspberry Pi Pico and the power header. + +TODO: picture to locate the header + +Depending on the size and depth of your synthesizer's case there are a few ways you +can mount the clock. For deep cases, the easiest solution is to simply use a standoff +to connect the clock to the Raspberry Pi Pico: + +TODO: pictures of the standoff mount + +For shallower cases, you can attach header pins and jumper wires to a pice of perfboard +or plastic, and mount the clock vertically. + +TODO: pictures of the vertical mount + +Other mounting solutions are also possible, for example attaching the clock behind a +blank panel or using double-sided foam tape to attach it directly to the inside of your +case. As long as the clock is securely fastened so it won't move around, and is connected +to the I2C header on EuroPi, it should be fine. + +## How do I configure the clock? + +The default external I2C settings from `europi_config` should be used, unless you have +a specific need to change them in `config/EuroPiConfig.json` (for example, your RTC module +needs a specific I2C frequency): +```json +{ + "EXTERNAL_I2C_SDA": 2, + "EXTERNAL_I2C_SCL": 3, + "EXTERNAL_I2C_CHANNEL": 1, + "EXTERNAL_I2C_FREQUENCY": 100000, + "EXTERNAL_I2C_TIMEOUT": 50000, +} +``` + +You will also need to edit `config/ExperimentalConfig.json` to specify what RTC module you +are using: +```json +{ + "RTC_IMPLEMENTATION": "ds3231" +} +``` + +See [Configuration](/software/CONFIGURATION.md) for more details on EuroPiConfig.json and +ExperimentalConfig.json. + +## How do I set the time? + +When you first connect your clock, it will probably not indicate the correct time. To set +the realtime clock's internal memory to the current time, connect and configure the clock +as described above, and then connect EuroPi to Thonny's Python terminal. + +Inside the terminal, run the following commands: +```python +from experimental.rtc import clock, Month, Weekday +clock.source.set_datetime((2025, Month.JUNE, 14, 22, 59, 0, Weekday.THURSDAY)) +``` + +The example above sets the date to Thursday, 25 June, 2025 at 22:59:00. Modify the +year, month, day, hour, minute, second, and weekday to the current UTC date and time +when you run the command. + +Once you've done this, restart your EuroPi and run the following command to make sure +the clock is properly configured: +```python +from experimenta.rtc import clock +print(clock) +``` +You should see the current date and time printed. + +## Troubleshooting + +If you see a warning of the form +``` +WARNING: Oscillator stop flag set. Time may not be accurate. +``` +when you print the time, this means the clock is not actually running and won't track the time +when the module is powered-off. Setting the time and date using the `set_datetime` method +described above should start the clock automatically, but if it does not you should refer +to your RTC module's datasheet to check how to clear the oscillator stop bit. From ffa04ba68932ca7a3233a26d867bf58f68e59af1 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Wed, 8 Jan 2025 13:31:31 -0500 Subject: [PATCH 19/45] Insert images of the I2C header and standoff mount --- software/realtime_clock.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/software/realtime_clock.md b/software/realtime_clock.md index 230d7001a..8bc5d90a1 100644 --- a/software/realtime_clock.md +++ b/software/realtime_clock.md @@ -30,13 +30,17 @@ These modules can be bought online from a variety of sources, for example: The realtime clock should be connected to EuroPi's I2C header pins, located on the rear of the module, between the Raspberry Pi Pico and the power header. -TODO: picture to locate the header + + +_EuroPi's I2C header (circled)_ Depending on the size and depth of your synthesizer's case there are a few ways you can mount the clock. For deep cases, the easiest solution is to simply use a standoff to connect the clock to the Raspberry Pi Pico: -TODO: pictures of the standoff mount + + +_The DS3231 RTC mounted to the Raspberry Pi Pico using a standoff_ For shallower cases, you can attach header pins and jumper wires to a pice of perfboard or plastic, and mount the clock vertically. From 17be47e12483189cc26c5d2a196fe60e7e1e0c85 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Wed, 8 Jan 2025 18:44:50 -0500 Subject: [PATCH 20/45] Add DateTime class, add support for local timezone offset via experimental_config --- software/CONFIGURATION.md | 5 + software/contrib/daily_random.md | 4 +- software/contrib/daily_random.py | 27 +- .../experimental/experimental_config.py | 14 + software/firmware/experimental/rtc.py | 282 +++++++++++++++--- software/realtime_clock.md | 19 +- 6 files changed, 301 insertions(+), 50 deletions(-) diff --git a/software/CONFIGURATION.md b/software/CONFIGURATION.md index d3fead7ce..73a9daf4a 100644 --- a/software/CONFIGURATION.md +++ b/software/CONFIGURATION.md @@ -71,6 +71,8 @@ shows the default configuration: { "VOLTS_PER_OCTAVE": 1.0, "RTC_IMPLEMENTATION": "", + "UTC_OFFSET_HOURS": 0, + "UTC_OFFSET_MINUTES": 0, } ``` @@ -83,6 +85,9 @@ RTC options: - `"ds3231"`: use a DS3231 module connected to the external I2C interface - `"ds1307"`: use a DS1307 module connected to the external I2C interface (THIS IS UNTESTED! USE AT YOUR OWN RISK) +Timezone options: +- `UTC_OFFSET_HOURS`: The number of hours ahead/behind UTC the local timezone is (-24 to +24) +- `UTC_OFFSET_MINUTES`: The number of minutes ahead/behind UTC the local timezone is (-59 to +59) # Accessing config members in Python code diff --git a/software/contrib/daily_random.md b/software/contrib/daily_random.md index 6d4e9c107..9ece8df69 100644 --- a/software/contrib/daily_random.md +++ b/software/contrib/daily_random.md @@ -10,10 +10,10 @@ Generates pseudo-random gate and CV patterns based on the current date and time. - `b2`: not used - `k1`: not used - `k2`: not used -- `cv1`: daily gate sequence (updates at midnight UTC) +- `cv1`: daily gate sequence (updates at midnight local time) - `cv2`: hourly gate sequence (updates at the top of every hour) - `cv3`: minute gate sequence (updates at the top of every minute) -- `cv4`: daily CV sequence (updates at midnight UTC) +- `cv4`: daily CV sequence (updates at midnight local time) - `cv5`: hourly CV sequence (updates at the top of every hour) - `cv6`: minute CV sequence (updates at the top of every minute) diff --git a/software/contrib/daily_random.py b/software/contrib/daily_random.py index 3c92524a0..e75c13d75 100644 --- a/software/contrib/daily_random.py +++ b/software/contrib/daily_random.py @@ -1,7 +1,7 @@ from europi import * from europi_script import EuroPiScript -from experimental.rtc import clock, DateTimeIndex +from experimental.rtc import clock import random @@ -95,13 +95,14 @@ def gates_off(): cvs[i].off() def regenerate_sequences(self): - datetime = clock.now() - (year, month, day, hour, minute) = datetime[0:5] - - try: - weekday = datetime[DateTimeIndex.WEEKDAY] % 7 - except IndexError: - weekday = 0 + datetime = clock.localnow() + year = datetime.year + month = datetime.month + day = datetime.day + hour = datetime.hour + minute = datetime.minute + second = datetime.second if datetime.second is not None else 0 + weekday = datetime.weekday if datetime.weekday is not None else 0 # bit-shift the fields around to reduce collisions # mask: 12 bits @@ -120,14 +121,14 @@ def regenerate_sequences(self): self.sequences[i].regenerate(seeds[i % len(seeds)]) def main(self): - oled.centre_text(str(clock).replace(" ", "\n")) - last_draw_at = clock.now() + last_draw_at = clock.localnow() + oled.centre_text(str(last_draw_at).replace(" ", "\n")) while True: - now = clock.now() - if not clock.compare_datetimes(now, last_draw_at): + now = clock.localnow() + if now != last_draw_at: self.regenerate_sequences() - oled.centre_text(str(clock).replace(" ", "\n")) + oled.centre_text(str(now).replace(" ", "\n")) last_draw_at = now if self.trigger_recvd: diff --git a/software/firmware/experimental/experimental_config.py b/software/firmware/experimental/experimental_config.py index 4c9c149b5..6e268c326 100644 --- a/software/firmware/experimental/experimental_config.py +++ b/software/firmware/experimental/experimental_config.py @@ -55,6 +55,20 @@ def config_points(cls): ], default=RTC_NONE, ), + + # RTC Timezone offset for local time + configuration.integer( + "UTC_OFFSET_HOURS", + minimum=-24, + maximum=24, + default=0, + ), + configuration.integer( + "UTC_OFFSET_MINUTES", + minimum=-59, + maximum=59, + default=0, + ), ] # fmt: on diff --git a/software/firmware/experimental/rtc.py b/software/firmware/experimental/rtc.py index 4f2ec8734..cf9f749c6 100644 --- a/software/firmware/experimental/rtc.py +++ b/software/firmware/experimental/rtc.py @@ -10,25 +10,10 @@ """ import europi +from experimental.clocks.clock_source import ExternalClockSource from experimental.experimental_config import RTC_NONE, RTC_DS1307, RTC_DS3231 -class DateTimeIndex: - """ - Indices for the fields within a weekday tuple - - Note that SECOND and WEEKDAY are optional and may be omitted in some implementations - """ - - YEAR = 0 - MONTH = 1 - DAY = 2 - HOUR = 3 - MINUTE = 4 - SECOND = 5 - WEEKDAY = 6 - - class Month: """ Container class for month names @@ -79,6 +64,7 @@ class Weekday: SUNDAY = 7 NAME = { + 0: "Unspecified", 1: "Monday", 2: "Tuesday", 3: "Wednesday", @@ -89,6 +75,220 @@ class Weekday: } +class Timezone: + """ + Represents a timezone shift relative to UTC + + We allow minutes & hours, since there are 15-, 30-, and 45-minute timzeones in the world + """ + + def __init__(self, hours, minutes): + """ + Create a time zone we can add to a datetime to get local time + + @param hours The number of hours ahead/behind we need to adjust [-24 to +24] + @param minutes The number of minutes ahead/behind we need to adjust [-59 to +59] + """ + if (hours < 0 and minutes > 0) or (hours > 0 and minutes < 0): + raise ValueError("Timezone offset must be in a consistent direction") + if abs(hours) > 24 or abs(minutes) > 59: + raise ValueError("Invalid time zone adjustment") + self.hours = hours + self.minutes = minutes + + def __str__(self): + """ + Get the offset as a string + + Result is of the format is {+|-}hh:mm + """ + s = f"{abs(self.hours):02}:{abs(self.minutes):02}" + if self.hours < 0: + s = f"-{s}" + else: + s = f"+{s}" + return s + + +class DateTime: + """Represents a date and time""" + + def __init__(self, year, month, day, hour, minute, second=None, weekday=None): + """ + Create a DateTime representing a specific moment + + @param year The current year (e.g. 2025) + @param month The current month (e.g. Month.JANUARY) + @param day The current day within the month (e.g. 17) + @param hour The current hour on a 24-hour clock (0-23) + @param minute The current minute within the hour (0-59) + @param second The current second within the minute (0-59, optional) + @param weekday The current day of the week (e.g. Weekday.MONDAY, optional) + """ + self.year = year + self.month = month + self.day = day + self.hour = hour + self.minute = minute + self.second = second + self.weekday = weekday + + def __str__(self): + """ + Get the current day and time as a string + + Result is of the format + [Weekday ]YYYY/MM/DD hh:mm[:ss] + """ + s = f"{self.year:04}/{self.month:02}/{self.day:02} {self.hour:02}:{self.minute:02}" + if self.second is not None: + s = f"{s}:{self.second:02}" + if self.weekday is not None: + s = f"{Weekday.NAME[self.weekday]} {s}" + return s + + def __add__(self, tz): + """ + Add a timezone offset to the current time, returning the result + + @param tz The timezone we're adding to this Datetime + """ + t = DateTime(self.year, self.month, self.day, self.hour, self.minute, self.second, self.weekday) + + # shortcut if there is no offset + if tz.hours == 0 and tz.minutes == 0: + return t + + # add the offset; this can be positive or negative + t.minute += tz.minutes + t.hour += tz.hours + + # cascade through the units, borrowing/carrying-over as needed + if t.minute < 0: + t.minute += 60 + t.hour -= 1 + elif t.minute >= 60: + t.minute -= 60 + t.hour += 1 + + if t.hour < 0: + t.hour += 24 + t.day -= 1 + elif t.hour >= 24: + t.hour -= 24 + t.day += 1 + + days_in_month = DateTime.days_in_month(t.month, t.year) + if t.day <= 0: + t.day += 1 + t.month -= 1 + elif t.day > days_in_month: + t.day = 1 + t.month += 1 + + if t.month <= 1: + t.month += 12 + t.year -= 1 + elif t.month > 12: + t.month -= 12 + t.year += 1 + + return t + + @staticmethod + def is_leap_year(year): + # a year is a leap year if it is divisible by 4 + return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) + + @staticmethod + def days_in_month(month, year): + month_lengths = [ + 31, + 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31 + ] + if DateTime.is_leap_year(year) and month == Month.FEBRUARY: + return 29 + else: + return month_lengths[month-1] + + def __eq__(self, other): + # fmt: off + return ( + self.year == other.year and + self.month == other.month and + self.day == other.day and + self.hour == other.hour and + self.minute == other.minute and + ( + (self.second is None or other.hour is None ) or + (self.second == other.second) + ) + ) + # fmt: on + + def __lt__(self, other): + if self.year == other.year: + if self.month == other.month: + if self.hour == other.hour: + if self.minute == other.minute: + if self.second is None or other.second is None: + return False + return self.second < other.second + return self.minute < other.minute + return self.hour < other.hour + return self.month < other.month + return self.year < other.year + + def __gt__(self, other): + if self.year == other.year: + if self.month == other.month: + if self.hour == other.hour: + if self.minute == other.minute: + if self.second is None or other.second is None: + return False + return self.second > other.second + return self.minute > other.minute + return self.hour > other.hour + return self.month > other.month + return self.year > other.year + + def __le__(self, other): + if self.year == other.year: + if self.month == other.month: + if self.hour == other.hour: + if self.minute == other.minute: + if self.second is None or other.second is None: + return False + return self.second <= other.second + return self.minute <= other.minute + return self.hour <= other.hour + return self.month <= other.month + return self.year <= other.year + + def __ge__(self, other): + if self.year == other.year: + if self.month == other.month: + if self.hour == other.hour: + if self.minute == other.minute: + if self.second is None or other.second is None: + return False + return self.second >= other.second + return self.minute >= other.minute + return self.hour >= other.hour + return self.month >= other.month + return self.year >= other.year + + class RealtimeClock: """ A continually-running clock that provides the day & date. @@ -105,38 +305,47 @@ def __init__(self, source): """ self.source = source - def now(self): + def utcnow(self): """ Get the current UTC time. - @return a tuple, (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) + @return A DateTime object representing the current UTC time """ - return self.source.datetime() - def compare_datetimes(self, t1, t2): + # get the raw tuple from the clock, append Nones so we have all 7 fields + t = list(self.source.datetime()) + if len(t) < ExternalClockSource.SECOND + 1: + t.append(None) + if len(t) < ExternalClockSource.WEEKDAY + 1: + t.append(None) + + return DateTime( + t[ExternalClockSource.YEAR], + t[ExternalClockSource.MONTH], + t[ExternalClockSource.DAY], + t[ExternalClockSource.HOUR], + t[ExternalClockSource.MINUTE], + t[ExternalClockSource.SECOND], + t[ExternalClockSource.WEEKDAY] + ) + + def localnow(self): """ - Comapre two datetimes to see if they represent the same time + Get the current local time - If one time has fewer fields than the other, we only consider the fields present in both + See experimental_config for instructions on how to configure the local timezone + + @return a DateTime object representing the current local time """ - for i in range(min(len(t1), len(t2))): - if t1[i] != t2[i]: - return False - return True + return self.utcnow() + local_timezone def __str__(self): """ - Return the current time as a string + Return the current UTC time as a string @return A string with the format "[Weekday] YYYY/MM/DD HH:MM[:SS]" """ - t = self.now() - if len(t) > DateTimeIndex.WEEKDAY: - return f"{Weekday.NAME[t[DateTimeIndex.WEEKDAY]]} {t[DateTimeIndex.YEAR]}/{t[DateTimeIndex.MONTH]:02}/{t[DateTimeIndex.DAY]:02} {t[DateTimeIndex.HOUR]:02}:{t[DateTimeIndex.MINUTE]:02}:{t[DateTimeIndex.SECOND]:02}" - elif len(t) > DateTimeIndex.SECOND: - return f"{t[DateTimeIndex.YEAR]}/{t[DateTimeIndex.MONTH]:02}/{t[DateTimeIndex.DAY]:02} {t[DateTimeIndex.HOUR]:02}:{t[DateTimeIndex.MINUTE]:02}:{t[DateTimeIndex.SECOND]:02}" - else: - return f"{t[DateTimeIndex.YEAR]}/{t[DateTimeIndex.MONTH]:02}/{t[DateTimeIndex.DAY]:02} {t[DateTimeIndex.HOUR]:02}:{t[DateTimeIndex.MINUTE]:02}" + return f"{self.localnow()}" # fmt: off @@ -151,4 +360,9 @@ def __str__(self): source = NullClock() # fmt: on +local_timezone = Timezone( + europi.experimental_config.UTC_OFFSET_HOURS, + europi.experimental_config.UTC_OFFSET_MINUTES +) + clock = RealtimeClock(source) diff --git a/software/realtime_clock.md b/software/realtime_clock.md index 8bc5d90a1..b72c73283 100644 --- a/software/realtime_clock.md +++ b/software/realtime_clock.md @@ -98,10 +98,27 @@ Once you've done this, restart your EuroPi and run the following command to make the clock is properly configured: ```python from experimenta.rtc import clock -print(clock) +print(clock.utcnow()) ``` You should see the current date and time printed. +## What about local time? + +You can further configure EuroPi to add a timezone offset. To do this, edit `/config/ExperimentalConfig.json` +to add the following: +```json +{ + "UTC_OFFSET_HOURS": -3, + "UTC_OFFSET_MINUTES": -30, +} +``` +This will set the local timezone to UTC -03:30, or Newfoundland time. If you don't liven in +Newfoundland (it's okay, I don't either), adjust the hours and minutes to your local timezone. Note that +EuroPi will _not_ track daylight savings time, so if your region changes the clocks every spring and +autumn, you will need to manually adjust EuroPi twice per year to keep the local time synchronized. + +The sign of the minutes and hours must match. + ## Troubleshooting If you see a warning of the form From bd73b24c7b52f3e22060e69d77a1843e46f122ed Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Wed, 8 Jan 2025 18:52:20 -0500 Subject: [PATCH 21/45] Formatting --- software/firmware/experimental/rtc.py | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/software/firmware/experimental/rtc.py b/software/firmware/experimental/rtc.py index cf9f749c6..8767be3fa 100644 --- a/software/firmware/experimental/rtc.py +++ b/software/firmware/experimental/rtc.py @@ -153,7 +153,9 @@ def __add__(self, tz): @param tz The timezone we're adding to this Datetime """ - t = DateTime(self.year, self.month, self.day, self.hour, self.minute, self.second, self.weekday) + t = DateTime( + self.year, self.month, self.day, self.hour, self.minute, self.second, self.weekday + ) # shortcut if there is no offset if tz.hours == 0 and tz.minutes == 0: @@ -202,24 +204,11 @@ def is_leap_year(year): @staticmethod def days_in_month(month, year): - month_lengths = [ - 31, - 28, - 31, - 30, - 31, - 30, - 31, - 31, - 30, - 31, - 30, - 31 - ] + month_lengths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] if DateTime.is_leap_year(year) and month == Month.FEBRUARY: return 29 else: - return month_lengths[month-1] + return month_lengths[month - 1] def __eq__(self, other): # fmt: off @@ -326,7 +315,7 @@ def utcnow(self): t[ExternalClockSource.HOUR], t[ExternalClockSource.MINUTE], t[ExternalClockSource.SECOND], - t[ExternalClockSource.WEEKDAY] + t[ExternalClockSource.WEEKDAY], ) def localnow(self): @@ -361,8 +350,7 @@ def __str__(self): # fmt: on local_timezone = Timezone( - europi.experimental_config.UTC_OFFSET_HOURS, - europi.experimental_config.UTC_OFFSET_MINUTES + europi.experimental_config.UTC_OFFSET_HOURS, europi.experimental_config.UTC_OFFSET_MINUTES ) clock = RealtimeClock(source) From 3360d19a9f22db1c60d706fb0a857563153beabc Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Wed, 8 Jan 2025 21:09:24 -0500 Subject: [PATCH 22/45] Add pictures of vertical mount, add note about insulating the clock --- software/realtime_clock.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/software/realtime_clock.md b/software/realtime_clock.md index b72c73283..8e0b35ae9 100644 --- a/software/realtime_clock.md +++ b/software/realtime_clock.md @@ -45,12 +45,15 @@ _The DS3231 RTC mounted to the Raspberry Pi Pico using a standoff_ For shallower cases, you can attach header pins and jumper wires to a pice of perfboard or plastic, and mount the clock vertically. -TODO: pictures of the vertical mount + + +_A vertical mount made from a piece of perboard, 2 female headers, and some wire_ Other mounting solutions are also possible, for example attaching the clock behind a blank panel or using double-sided foam tape to attach it directly to the inside of your -case. As long as the clock is securely fastened so it won't move around, and is connected -to the I2C header on EuroPi, it should be fine. +case. As long as the clock is securely fastened so it won't move around, insulated from +any accidental contact with conductive surfaces inside the case, and properly +connected to the I2C header pins it should be fine. ## How do I configure the clock? From c8029335d0a5efd3c50e347aabfe73db5bc5690a Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Wed, 8 Jan 2025 21:33:42 -0500 Subject: [PATCH 23/45] Fix the math for local timezone conversions --- software/firmware/experimental/rtc.py | 36 ++++++++++++++++----------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/software/firmware/experimental/rtc.py b/software/firmware/experimental/rtc.py index 8767be3fa..6770ee8d6 100644 --- a/software/firmware/experimental/rtc.py +++ b/software/firmware/experimental/rtc.py @@ -161,6 +161,12 @@ def __add__(self, tz): if tz.hours == 0 and tz.minutes == 0: return t + # temporarily shift the month and day to be zero-indexed to make subsequent math easier + t.month -= 1 + t.day -= 1 + if t.weekday is not None: + t.weekday -= 1 + # add the offset; this can be positive or negative t.minute += tz.minutes t.hour += tz.hours @@ -176,25 +182,35 @@ def __add__(self, tz): if t.hour < 0: t.hour += 24 t.day -= 1 + if t.weekday is not None: + t.weekday = (t.weekday - 1) % 7 elif t.hour >= 24: t.hour -= 24 t.day += 1 + if t.weekday is not None: + t.weekday = (t.weekday + 1) % 7 - days_in_month = DateTime.days_in_month(t.month, t.year) - if t.day <= 0: - t.day += 1 + days_in_month = DateTime.days_in_month(t.month + 1, t.year) + days_in_prev_month = DateTime.days_in_month((t.month + 1) % 12 + 1, t.year) + if t.day < 0: + t.day = days_in_prev_month - 1 # last day of the month, zero-indexed t.month -= 1 - elif t.day > days_in_month: + elif t.day >= days_in_month: t.day = 1 t.month += 1 - if t.month <= 1: + if t.month < 0: t.month += 12 t.year -= 1 - elif t.month > 12: + elif t.month >= 12: t.month -= 12 t.year += 1 + # shift the month, day and weekday back to be 1-indexed + t.month += 1 + t.day += 1 + t.weekday += 1 + return t @staticmethod @@ -328,14 +344,6 @@ def localnow(self): """ return self.utcnow() + local_timezone - def __str__(self): - """ - Return the current UTC time as a string - - @return A string with the format "[Weekday] YYYY/MM/DD HH:MM[:SS]" - """ - return f"{self.localnow()}" - # fmt: off if europi.experimental_config.RTC_IMPLEMENTATION == RTC_DS1307: From 0ce976d65167840b7dbb85a4ce506030459c905e Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Wed, 8 Jan 2025 21:59:38 -0500 Subject: [PATCH 24/45] Check that the weekday isn't None before re-incrementing it --- software/firmware/experimental/rtc.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/software/firmware/experimental/rtc.py b/software/firmware/experimental/rtc.py index 6770ee8d6..3715925b2 100644 --- a/software/firmware/experimental/rtc.py +++ b/software/firmware/experimental/rtc.py @@ -209,7 +209,8 @@ def __add__(self, tz): # shift the month, day and weekday back to be 1-indexed t.month += 1 t.day += 1 - t.weekday += 1 + if t.weekday is not None: + t.weekday += 1 return t From 56e5715359cacf2d23dd38746d82756cbed4c303 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Fri, 10 Jan 2025 23:48:52 -0500 Subject: [PATCH 25/45] Initial commit of pet rock script --- software/contrib/pet_rock-docs/cups.png | Bin 0 -> 248 bytes .../contrib/pet_rock-docs/first_quarter.png | Bin 0 -> 5946 bytes software/contrib/pet_rock-docs/full_moon.png | Bin 0 -> 423 bytes software/contrib/pet_rock-docs/new_moon.png | Bin 0 -> 438 bytes software/contrib/pet_rock-docs/pentacle.png | Bin 0 -> 418 bytes software/contrib/pet_rock-docs/swords.png | Bin 0 -> 313 bytes .../contrib/pet_rock-docs/third_quarter.png | Bin 0 -> 5947 bytes software/contrib/pet_rock-docs/wands.png | Bin 0 -> 261 bytes .../contrib/pet_rock-docs/waning_crescent.png | Bin 0 -> 5961 bytes .../contrib/pet_rock-docs/waning_gibbous.png | Bin 0 -> 511 bytes .../contrib/pet_rock-docs/waxing_crescent.png | Bin 0 -> 5980 bytes .../contrib/pet_rock-docs/waxing_gibbous.png | Bin 0 -> 508 bytes software/contrib/pet_rock.md | 28 + software/contrib/pet_rock.py | 528 ++++++++++++++++++ 14 files changed, 556 insertions(+) create mode 100644 software/contrib/pet_rock-docs/cups.png create mode 100644 software/contrib/pet_rock-docs/first_quarter.png create mode 100644 software/contrib/pet_rock-docs/full_moon.png create mode 100644 software/contrib/pet_rock-docs/new_moon.png create mode 100644 software/contrib/pet_rock-docs/pentacle.png create mode 100644 software/contrib/pet_rock-docs/swords.png create mode 100644 software/contrib/pet_rock-docs/third_quarter.png create mode 100644 software/contrib/pet_rock-docs/wands.png create mode 100644 software/contrib/pet_rock-docs/waning_crescent.png create mode 100644 software/contrib/pet_rock-docs/waning_gibbous.png create mode 100644 software/contrib/pet_rock-docs/waxing_crescent.png create mode 100644 software/contrib/pet_rock-docs/waxing_gibbous.png create mode 100644 software/contrib/pet_rock.md create mode 100644 software/contrib/pet_rock.py diff --git a/software/contrib/pet_rock-docs/cups.png b/software/contrib/pet_rock-docs/cups.png new file mode 100644 index 0000000000000000000000000000000000000000..1ad0e8f8f092c6e4537926e554399fb4f0edb064 GIT binary patch literal 248 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1SJ1Ryj={WI14-?iy0X7ltGxWVyS%@P>{XE z)7O>#B_lTr1Jl2s?W=)8k|nMYCC>S|xv6<249-QVi6yBi3gww484B*6z5(HleBwYw zMV>B>As)xyPIcrupuod&_wWDtmz@`StSvowB+2ugb#l|OpiOS6X$uS7%s03Qm@F0E zo#pb*H^#v&X1SE>ckSxt3*RHR6k6HsJ+2T}!0_xkmdKI;Vst0J+mu`v3p{ literal 0 HcmV?d00001 diff --git a/software/contrib/pet_rock-docs/first_quarter.png b/software/contrib/pet_rock-docs/first_quarter.png new file mode 100644 index 0000000000000000000000000000000000000000..9b75b3ba4559180bc647c925cc88ecb134b03dc0 GIT binary patch literal 5946 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 R`wsvB002ovPDHLkV1g`csT2SJ literal 0 HcmV?d00001 diff --git a/software/contrib/pet_rock-docs/new_moon.png b/software/contrib/pet_rock-docs/new_moon.png new file mode 100644 index 0000000000000000000000000000000000000000..7010d363952232e3d09579ba2c0f58ab9c431797 GIT binary patch literal 438 zcmV;n0ZIOeP)mBMg82*);aKas)>><=rIeiWvMc~#tu@AIt(8(rscD){ z8~_j0dk-NT zIp%0mXDj&ZR^PvKc!YDI`^I<##Q|WR=kw?Gfe>+?X8?dP zhLjQsB&8fSz!GT!ZT1)3{ zt>wlTI)E{TYpv-3T5GP9VvJF^Gscus^j`%ch7c$K4_or@`j&xn$H=^6=#$r$JBrsG g)$#K3%c$%755~)yH0GYTi2wiq07*qoM6N<$g6-6~ng9R* literal 0 HcmV?d00001 diff --git a/software/contrib/pet_rock-docs/pentacle.png b/software/contrib/pet_rock-docs/pentacle.png new file mode 100644 index 0000000000000000000000000000000000000000..e1235ae11e7b176ec920fdc43b2a77b9480394c4 GIT binary patch literal 418 zcmV;T0bTxyP)+Pg_GU4+BNq z-7oSHk??&()ny&f0W{vt{~2Qp1x6I!Wzc(8fp@|>MyxFoC9th42GbZAv-J0*tbFt+KOnu2g6ViwO=meXwUJh7gC0JE+?D(g+?p8x;= M07*qoM6N<$f&mbzmjD0& literal 0 HcmV?d00001 diff --git a/software/contrib/pet_rock-docs/swords.png b/software/contrib/pet_rock-docs/swords.png new file mode 100644 index 0000000000000000000000000000000000000000..9d75c4161f72d6eef76e2a3dfe207af88d857a82 GIT binary patch literal 313 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 V(K*I<2DVuM&wZ|2kxS&#e*q8D=7InK literal 0 HcmV?d00001 diff --git a/software/contrib/pet_rock-docs/wands.png b/software/contrib/pet_rock-docs/wands.png new file mode 100644 index 0000000000000000000000000000000000000000..50d43c641bd6b0f15a33ec361715cddbae2e0b57 GIT binary patch literal 261 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1SJ1Ryj={WI14-?iy0X7ltGxWVyS%@P>{XE z)7O>#B_lTrn?&fro8dqq$r9Iy66gHf+|;}h2Ir#G#FEq$h4Rdj3$88NF`fnn zN|syR@Nsr^aPr=DWSRP)hUvAm(6qB6dzW9jJ9p}(yL%PqM1I_?C9*Ai(&ECY9KWYB zgwER3?Co{)nL@>J>*_VX&UZh1W-0&vYpKou(i(;%Cp`=mziBK3I)%a0)z4*}Q$iB} Dx0GHk literal 0 HcmV?d00001 diff --git a/software/contrib/pet_rock-docs/waning_crescent.png b/software/contrib/pet_rock-docs/waning_crescent.png new file mode 100644 index 0000000000000000000000000000000000000000..8488f81fdb20730bf7055c1ec8e9642f70fbf56e GIT binary patch literal 5961 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 literal 0 HcmV?d00001 diff --git a/software/contrib/pet_rock-docs/waning_gibbous.png b/software/contrib/pet_rock-docs/waning_gibbous.png new file mode 100644 index 0000000000000000000000000000000000000000..74466336621362173574acc75ecc5e3636823468 GIT binary patch literal 511 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\x00\x00>@\x00\x00>\x00\x00\x00\x1e\x00\x00\x00\x1f\x80\x00\x00\x1f\x00\x00\x00\x1f\x80\x00\x00\x1f\x00\x00\x00\x1f\x00\x00\x00\x1f\x00\x00\x00\x1e@\x00\x00>@\x00\x00>\x00\x00\x00< \x00\x00|\x10\x00\x00\xf8\x00\x00\x00\xf8\x08\x00\x01\xf0\x00\x00\x03\xe0\x01\x00\x0f\xc0\x00\x80\x1f\x00\x000\xfc\x00\x00\x0b\xe0\x00'), + bytearray(b'\x00\x02\xe0\x00\x000\xfe\x00\x00\x80\xff\x00\x01\x00\xff\xc0\x04\x00\xff\xe0\x00\x00\xff\xf0\x00\x00\xff\xf8\x10\x00\xff\xf8 \x00\xff\xfc\x00\x00\xff\xfe@\x00\xff\xfe@\x00\xff\xfe\x00\x00\xff\xfe\x00\x00\xff\xff\x80\x00\xff\xff\x00\x00\xff\xff\x80\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xfe@\x00\xff\xfe@\x00\xff\xfe\x00\x00\xff\xfc \x00\xff\xfc\x10\x00\xff\xf8\x00\x00\xff\xf8\x08\x00\xff\xf0\x00\x00\xff\xe0\x01\x00\xff\xc0\x00\x80\xff\x00\x000\xfc\x00\x00\x02\xe0\x00'), + bytearray(b'\x00\x02\x90\x00\x000\xfc\x00\x00\x83\xff\x00\x01\x07\xff\xc0\x04\x0f\xff\xe0\x00\x1f\xff\xf0\x00?\xff\xf8\x10\x7f\xff\xf8 \x7f\xff\xfc\x00\xff\xff\xfe@\xff\xff\xfe@\xff\xff\xfe\x01\xff\xff\xfe\x01\xff\xff\xff\x81\xff\xff\xff\x01\xff\xff\xff\x81\xff\xff\xff\x01\xff\xff\xff\x01\xff\xff\xff\x01\xff\xff\xfe@\xff\xff\xfe@\xff\xff\xfe\x00\xff\xff\xfc \x7f\xff\xfc\x10\x7f\xff\xf8\x00?\xff\xf8\x08\x1f\xff\xf0\x00\x0f\xff\xe0\x01\x07\xff\xc0\x00\x83\xff\x00\x000\xfc\x00\x00\x02\x90\x00'), + bytearray(b'\x00\x07\xc0\x00\x00?\xfc\x00\x00\xff\xff\x00\x03\xff\xff\xc0\x07\xff\xff\xe0\x0f\xff\xff\xf0\x0f\xff\xff\xf0\x1f\xff\xff\xf8?\xff\xff\xfc?\xff\xff\xfc\x7f\xff\xff\xfe\x7f\xff\xff\xfe\x7f\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x7f\xff\xff\xfe\x7f\xff\xff\xfe\x7f\xff\xff\xfe\x7f\xff\xff\xfe?\xff\xff\xfc?\xff\xff\xfc\x1f\xff\xff\xf8\x1f\xff\xff\xf8\x0f\xff\xff\xf0\x07\xff\xff\xe0\x01\xff\xff\x80\x00\xff\xff\x00\x00?\xfc\x00\x00\x07\xc0\x00'), + bytearray(b'\x00\t\x80\x00\x00\x7f\x0c\x00\x00\xff\xc1\x00\x03\xff\xe0\x80\x07\xff\xf0 \x0f\xff\xf8\x00\x1f\xff\xfc\x00\x1f\xff\xfe\x08?\xff\xfe\x04\x7f\xff\xff\x00\x7f\xff\xff\x02\x7f\xff\xff\x02\xff\xff\xff\x80\x7f\xff\xff\x80\xff\xff\xff\x81\xff\xff\xff\x81\xff\xff\xff\x80\xff\xff\xff\x81\xff\xff\xff\x80\x7f\xff\xff\x80\x7f\xff\xff\x02\x7f\xff\xff\x02?\xff\xff\x00?\xff\xfe\x04\x1f\xff\xfe\x08\x1f\xff\xfc\x00\x0f\xff\xf8\x00\x07\xff\xf0\x00\x03\xff\xe0\x80\x00\xff\xc1\x00\x00\x7f\x0c\x00\x00\t\x80\x00'), + bytearray(b'\x00\x0b\x80\x00\x00\x7f\x0c\x00\x00\xff\x01\x00\x03\xff\x00\x80\x07\xff\x00 \x0f\xff\x00\x00\x1f\xff\x00\x00\x1f\xff\x00\x08?\xff\x00\x04\x7f\xff\x00\x00\x7f\xff\x00\x02\x7f\xff\x00\x02\xff\xff\x00\x00\x7f\xff\x00\x00\xff\xff\x00\x01\xff\xff\x00\x01\xff\xff\x00\x00\xff\xff\x00\x01\xff\xff\x00\x00\x7f\xff\x00\x00\x7f\xff\x00\x02\x7f\xff\x00\x02?\xff\x00\x00?\xff\x00\x04\x1f\xff\x00\x08\x1f\xff\x00\x00\x0f\xff\x00\x00\x07\xff\x00\x00\x03\xff\x00\x80\x00\xff\x01\x00\x00\x7f\x0c\x00\x00\x0b\x80\x00'), + bytearray(b'\x00\x0b\xe0\x00\x00\x7f\x0c\x00\x00\xf8\x01\x00\x03\xf0\x00\x80\x07\xc0\x00 \x0f\x80\x00\x00\x1f\x00\x00\x00\x1f\x00\x00\x08>\x00\x00\x04|\x00\x00\x00|\x00\x00\x02|\x00\x00\x02\xf8\x00\x00\x00x\x00\x00\x00\xf8\x00\x00\x01\xf8\x00\x00\x01\xf8\x00\x00\x00\xf8\x00\x00\x01\xf8\x00\x00\x00x\x00\x00\x00|\x00\x00\x02|\x00\x00\x02<\x00\x00\x00>\x00\x00\x04\x1f\x00\x00\x08\x1f\x00\x00\x00\x0f\x80\x00\x00\x07\xc0\x00\x00\x03\xf0\x00\x80\x00\xf8\x01\x00\x00\x7f\x0c\x00\x00\x0b\xe0\x00'), + ] + + @staticmethod + def calculate_days_since_new_moon(date): + """ + Calculate the number of days since a known full moon + + @see https://www.subsystems.us/uploads/9/8/9/4/98948044/moonphase.pdf + + @param date The current UTC DateTime + """ + if date.year < 2000: + raise ValueError(f"Date out of range; check your RTC") + + y = date.year + m = date.month + d = date.day + + if m == Month.JANUARY or m == Month.FEBRUARY: + y = y - 1 + m = m + 12 + + a = math.floor(y/100) + b = math.floor(a/4) + c = 2 - a + b + e = math.floor(365.25 * (y + 4716)) + f = math.floor(30.6001 * (m + 1)) + jd = c + d + e + f - 1524.5 + + days_since_new_moon = jd - 2451549.5 + + return days_since_new_moon + + @staticmethod + def calculate_phase(date): + """ + Calculate the current moon phase + + @param date The current UTC DateTime + """ + days_since_new_moon = MoonPhase.calculate_days_since_new_moon(date) + + yesterday_new_moons = (days_since_new_moon - 1) / 29.53 + today_new_moons = days_since_new_moon / 29.53 + tomorrow_new_moons = (days_since_new_moon + 1) / 29.53 + + # we always want 1 day assigned to new, first quarter, full, and third quarter + # so use yesterday, today, and tomorrow as a 3-day window + # if tomorrow is on one side of the curve and yesterday was the other, treat today + # as the "special" phase + + yesterday_fraction = yesterday_new_moons % 1 + today_fraction = today_new_moons % 1 + tomorrow_fraction = tomorrow_new_moons % 1 + + if yesterday_fraction > 0.75 and tomorrow_fraction < 0.25: + return MoonPhase.NEW_MOON + elif yesterday_fraction < 0.25 and tomorrow_fraction > 0.25: + return MoonPhase.FIRST_QUARTER + elif yesterday_fraction < 0.5 and tomorrow_fraction > 0.5: + return MoonPhase.FULL_MOON + elif yesterday_fraction < 0.75 and tomorrow_fraction > 0.75: + return MoonPhase.THIRD_QUARTER + elif today_fraction == 0.0: + return MoonPhase.NEW_MOON + elif today_fraction < 0.25: + return MoonPhase.WAXING_CRESCENT + elif today_fraction == 0.25: + return MoonPhase.FIRST_QUARTER + elif today_fraction < 0.5: + return MoonPhase.WAXING_GIBBOUS + elif today_fraction == 0.5: + return MoonPhase.FULL_MOON + elif today_fraction < 0.75: + return MoonPhase.WANING_GIBBOUS + elif today_fraction == 0.75: + return MoonPhase.THIRD_QUARTER + else: + return MoonPhase.WANING_CRESCENT + + +class Mood: + """ + The algorithm's "mood" + + Mood is one of 4 colours, which rotates every moon cycle + """ + + MOOD_RED = 0 + MOOD_BLUE = 1 + MOOD_YELLOW = 2 + MOOD_GREEN = 3 + + N_MOODS = 4 + + @staticmethod + def calculate_mood(date): + """ + Calculate the current mood + + @param date The current UTC DateTime + """ + days_since_new_moon = MoonPhase.calculate_days_since_new_moon(date) + cycles = math.floor(days_since_new_moon / 29.53) + + return cycles % Mood.N_MOODS + + @staticmethod + def mood_algorithm(date): + """ + Get the algorithm for the current mood + + @param date The current UTC DateTime + """ + mood = Mood.calculate_mood(date) + if mood == Mood.MOOD_RED: + return AlgoPlain + elif mood == Mood.MOOD_BLUE: + return AlgoReich + elif mood == Mood.MOOD_YELLOW: + return AlgoSparse + else: + return AlgoVari + + +class PetRock(EuroPiScript): + + def __init__(self): + super().__init__() + + self.seed_offset = 1 + self.generate_sequences() + + self.din2 = AnalogReaderDigitalWrapper( + ain, + cb_rising = self.on_channel_b_trigger, + cb_falling = self.on_channel_b_fall + ) + b2.handler(self.on_channel_b_trigger) + b2.handler_falling(self.on_channel_b_fall) + + din.handler(self.on_channel_a_trigger) + din.handler_falling(self.on_channel_a_fall) + b1.handler(self.on_channel_a_trigger) + b1.handler_falling(self.on_channel_a_fall) + + def generate_sequences(self): + continuity = randint(0, 99) + + now = clock.utcnow() + if now.weekday is None: + now.weekday = 0 + + cycle = MoonPhase.calculate_phase(now) + + today_seed = now.day + now.month + now.year + self.seed_offset + random.seed(today_seed) + + self.sequence_a = Mood.mood_algorithm(now)(Algo.CHANNEL_A, now.weekday, cycle, continuity) + self.sequence_b = Mood.mood_algorithm(now)(Algo.CHANNEL_B, now.weekday, cycle, continuity) + + self.last_generation_at = clock.localnow() + + def on_channel_a_trigger(self): + self.sequence_a.tick() + + def on_channel_a_fall(self): + self.sequence_a.outputs_off() + + def on_channel_b_trigger(self): + self.sequence_b.tick() + + def on_channel_b_fall(self): + self.sequence_b.outputs_off() + + def draw(self, local_time): + oled.fill(0) + + if local_time.weekday: + oled.text(Weekday.NAME[local_time.weekday][0:3].upper(), OLED_WIDTH - CHAR_WIDTH * 3, 0, 1) + + oled.text(f"{local_time.hour:02}:{local_time.minute:02}", OLED_WIDTH - CHAR_WIDTH * 5, OLED_HEIGHT - CHAR_HEIGHT, 1) + + moon_phase = MoonPhase.calculate_phase(clock.utcnow()) + moon_img = FrameBuffer(MoonPhase.moon_phase_images[moon_phase], 32, 32, MONO_HLSB) + oled.blit(moon_img, 0, 0) + + mood_img = FrameBuffer(self.sequence_a.mood_graphics, 32, 32, MONO_HLSB) + oled.blit(mood_img, 34, 0) + + oled.show() + + def main(self): + self.draw(clock.localnow()) + last_draw_at = clock.localnow() + + while True: + self.din2.update() + + local_time = clock.localnow() + + ui_dirty = local_time.minute != last_draw_at.minute + + # if the day has rolled over, generate new sequences and mark them as dirty + # so we'll continue playing + if local_time.day != self.last_generation_at.day: + self.generate_sequences() + self.sequence_a.state_dirty = True + self.sequence_b.state_dirty = True + + ui_dirty = True + + if self.sequence_a.state_dirty: + self.sequence_a.set_outputs() + + if self.sequence_b.state_dirty: + self.sequence_b.set_outputs() + + if ui_dirty: + self.draw(local_time) From a6a3483c4b560d8a83fc41307f1f506be90455ba Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Fri, 10 Jan 2025 23:52:36 -0500 Subject: [PATCH 26/45] Add pet rock to the menu & readme --- software/contrib/README.md | 7 +++++++ software/contrib/menu.py | 1 + 2 files changed, 8 insertions(+) diff --git a/software/contrib/README.md b/software/contrib/README.md index ce1d80aab..730ef3746 100644 --- a/software/contrib/README.md +++ b/software/contrib/README.md @@ -173,6 +173,13 @@ While not technically random, the effects of changing the particle's initial con Author: [chrisib](https://github.com/chrisib)
Labels: gate, lfo, sequencer, random, trigger +### Pet Rock \[ [documentation](/software/contrib/pet_rock.md) | [script](/software/contrib/pet_rock.py) \] +A pseudo-random gate generator that uses the realtime clock to track the phase of the moon as a seed. Based on [Pet Rock by Jonah Senzel](https://petrock.site) + +Requires installing and configuring a realtime clock module, connected to EuroPi's external I2C interface for best results. + +Author: [chrisib](https://github.com/chrisib) +
Labels: sequencer, gate, random, realtime clock ### Poly Square \[ [documentation](/software/contrib/poly_square.md) | [script](/software/contrib/poly_square.py) \] Six independent oscillators which output on CVs 1-6. diff --git a/software/contrib/menu.py b/software/contrib/menu.py index d57e02e37..ef549fc3e 100644 --- a/software/contrib/menu.py +++ b/software/contrib/menu.py @@ -49,6 +49,7 @@ ["NoddyHolder", "contrib.noddy_holder.NoddyHolder"], ["Pam's Workout", "contrib.pams.PamsWorkout"], ["Particle Phys.", "contrib.particle_physics.ParticlePhysics"], + ["Pet Rock", "contrib.pet_rock.PetRock"], ["Piconacci", "contrib.piconacci.Piconacci"], ["PolyrhythmSeq", "contrib.polyrhythmic_sequencer.PolyrhythmSeq"], ["PolySquare", "contrib.poly_square.PolySquare"], From 0c75c80375921fe3fa50e4cfa4f701505c9db431 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sat, 11 Jan 2025 01:21:57 -0500 Subject: [PATCH 27/45] Allow internally clocking the sequences, using the knobs as speed controls --- software/contrib/pet_rock.md | 7 ++-- software/contrib/pet_rock.py | 67 ++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/software/contrib/pet_rock.md b/software/contrib/pet_rock.md index 04ec74f15..d0d50e7dd 100644 --- a/software/contrib/pet_rock.md +++ b/software/contrib/pet_rock.md @@ -10,8 +10,8 @@ This script generates pseudo-random gate sequences based on the phase of the moo - `ain`: external clock input for sequence B - `b1`: manually advance sequence A - `b2`: manually advance sequence B -- `k1`: not used -- `k2`: not used +- `k1`: speed control for sequence A +- `k2`: speed control for sequence B - `cv1`: primary gate output for sequence A - `cv2`: inverted gate output for sequence A - `cv3`: end of sequence trigger for sequence A @@ -19,6 +19,9 @@ This script generates pseudo-random gate sequences based on the phase of the moo - `cv5`: inverted gate output for sequence B - `cv6`: end of sequence trigger for sequence B +Both sequence A and sequence B can be internally clocked by setting the speed using `K1` and `K2`. Turning +these knobs fully anticlockwise will stop the internal clocks. + ## Required Hardware This script requires a Realtime Clock (RTC) to EuroPi's secondary I2C header pins, diff --git a/software/contrib/pet_rock.py b/software/contrib/pet_rock.py index 113e35aad..df3c4a1cc 100644 --- a/software/contrib/pet_rock.py +++ b/software/contrib/pet_rock.py @@ -11,8 +11,10 @@ from framebuf import FrameBuffer, MONO_HLSB import math import random +import time from experimental.a_to_d import AnalogReaderDigitalWrapper +from experimental.math_extras import rescale from experimental.rtc import * @@ -431,6 +433,55 @@ def mood_algorithm(date): return AlgoVari +class IntervalTimer: + """ + Uses ticks_ms and ticks_diff to fire a callback at fixed-ish intervals + """ + + MIN_INTERVAL = 10 + MAX_INTERVAL = 500 + + def __init__(self, speed_knob, rise_cb=lambda: None, fall_cb=lambda: None): + self.interval_ms = 0 + self.last_tick_at = time.ticks_ms() + + self.speed_knob = speed_knob + + self.rise_callback = rise_cb + self.fall_callback = fall_cb + + self.next_rise = True + + self.update_interval() + + def update_interval(self): + DEADZONE = 0.1 + p = self.speed_knob.percent() + if p <= DEADZONE: + # disable the timer for the first 10% of travel so we have an easy-off + # for external clocking + self.interval_ms = 0 + else: + p = 1.0 - rescale(p, DEADZONE, 1, 0, 1) + self.interval_ms = round(rescale(p, 0, 1, self.MIN_INTERVAL, self.MAX_INTERVAL)) + + def tick(self): + # kick out immediately if the timer is off + if self.interval_ms <= 0: + return + + now = time.ticks_ms() + if time.ticks_diff(now, self.last_tick_at) >= self.interval_ms: + self.last_tick_at = now + + if self.next_rise: + self.rise_callback() + self.next_rise = False + else: + self.fall_callback() + self.next_rise = True + + class PetRock(EuroPiScript): def __init__(self): @@ -446,11 +497,21 @@ def __init__(self): ) b2.handler(self.on_channel_b_trigger) b2.handler_falling(self.on_channel_b_fall) + self.timer_b = IntervalTimer( + k2, + rise_cb=self.on_channel_b_trigger, + fall_cb=self.on_channel_b_fall + ) din.handler(self.on_channel_a_trigger) din.handler_falling(self.on_channel_a_fall) b1.handler(self.on_channel_a_trigger) b1.handler_falling(self.on_channel_a_fall) + self.timer_a = IntervalTimer( + k1, + rise_cb=self.on_channel_a_trigger, + fall_cb=self.on_channel_a_fall + ) def generate_sequences(self): continuity = randint(0, 99) @@ -505,6 +566,12 @@ def main(self): while True: self.din2.update() + self.timer_a.update_interval() + self.timer_b.update_interval() + + self.timer_a.tick() + self.timer_b.tick() + local_time = clock.localnow() ui_dirty = local_time.minute != last_draw_at.minute From 73b49937cff45fb21dc502e69cf3a7ecc68d5d3d Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sat, 11 Jan 2025 01:57:17 -0500 Subject: [PATCH 28/45] Add placeholders for the other algorithms (to be added as a config-only easter-egg) --- software/contrib/pet_rock-docs/diamond.png | Bin 0 -> 313 bytes software/contrib/pet_rock-docs/heart.png | Bin 0 -> 319 bytes software/contrib/pet_rock-docs/shield.png | Bin 0 -> 357 bytes software/contrib/pet_rock-docs/spade.png | Bin 0 -> 319 bytes software/contrib/pet_rock.py | 36 +++++++++++++++++++++ 5 files changed, 36 insertions(+) create mode 100644 software/contrib/pet_rock-docs/diamond.png create mode 100644 software/contrib/pet_rock-docs/heart.png create mode 100644 software/contrib/pet_rock-docs/shield.png create mode 100644 software/contrib/pet_rock-docs/spade.png diff --git a/software/contrib/pet_rock-docs/diamond.png b/software/contrib/pet_rock-docs/diamond.png new file mode 100644 index 0000000000000000000000000000000000000000..0575ba469d4462dc37d18282c7a08c9f424e7ca4 GIT binary patch literal 313 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzwj^(N7l!{JxM1({$v_d#0*}aI z1_nK45N51cYF`EvWH0gbb!C6a$jv6JW+!x;11Ka};u=xnoS&PUnpeW$T$GwvlA5AW zo>`Ki;O^-g5Z=fq4pg+z)5S3);_%by-h2%TJTA+5zwa-KohhMuOH;sT;#BTmGt(Qd zDJccSq_Htb6fhot_H^;9_xJxRTnLU?y1@N`Z)R(gSYwaVv?e}AH_6T6%??Xn8vx#qAe<&2BS5$?E7&f$WR7*_{r&>h@eWLBpVW>#xQh zqiu0tst%dAPQARyQvA!5Fnyi7H$SY3sIszSs95Ff63H(m0(2~cr>mdKI;Vst0113= A*Z=?k literal 0 HcmV?d00001 diff --git a/software/contrib/pet_rock-docs/heart.png b/software/contrib/pet_rock-docs/heart.png new file mode 100644 index 0000000000000000000000000000000000000000..340874d04e26e3aaf667aebbf1e23ff5bb817cb3 GIT binary patch literal 319 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzwj^(N7l!{JxM1({$v_d#0*}aI z1_nK45N51cYF`EvWH0gbb!C6a$jzo8GwGWb3s6Y1#5JPCIX^cyHLrxhxhOTUBsE2$ zJhLQ2!QIn0AiR-J9H?lyr;B4q#NoGLo_q%sI9h)GpZ{`UW)jb`O%W!V>1l-~3f&z6 z;b(lJG}mTDez+qg!I<7#@nJ)g``$R4H3_{cP3)rL!V^QJH2fc!e~>=#rXn^_sYpE_ z@zKSW(u*lw@4s{G_gHwnq5OGz$9rq`uq6hMT0|#k?)$HC&f{S(b6}Ikx1QV=o{3`3 zIZoN{BjqQ}oE*8HduI9TO6~N2oLeVXJ#V#c3^OoVqqp>4-sd;V^xJ_>X7F_Nb6Mw< G&;$Ufo^_1? literal 0 HcmV?d00001 diff --git a/software/contrib/pet_rock-docs/shield.png b/software/contrib/pet_rock-docs/shield.png new file mode 100644 index 0000000000000000000000000000000000000000..d238d5bd59428eeeaaca21523af7e18d6dc77769 GIT binary patch literal 357 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1SJ1Ryj={WI14-?iy0X7ltGxWVyS%@P>{XE z)7O>#B_lVR4*M3hGf#j*k|nMYCC>S|xv6<249-QVi6yBi3gww484B*6z5(HleBwYw z_dQ)4Lp+Y}opw;@umXq6=HLJS`|!U^kztK7Si9V%k-h0r>EFZ|GiUxiVUc&>JG;it zr!%5;3RVm6+IFMp@8U<{D#lSY`_#E(PO2U&le-u-sedi|Vm;;V{HvnE>*Es**0;1U zCU|WMaaB@2H6eI+l3Hwc7tfcdH?D0#d#Aq>;w(7l@HkG}Fyx|H^P|jHa~+Kr>&2$t zVbZBwVmwbZY;l;xNnC!<1;W8LHVHi?@!RxMw7 z;o_>EwG93;Ax`Ki;O^-g5Z=fq4pg+<)5S3);_%z)XSoh2@HAh3@OytzxvcZ|$=7x@Tk@uTVrAX< z>>XcV8XE(LOGDs~ue+Bx@UNJbz-ANF6j`v9#rWZ=s5JJvuLW#>x~Fe`kZ)`u{7 Date: Sat, 11 Jan 2025 03:38:27 -0500 Subject: [PATCH 29/45] Implement the 4 deprecated moods, add a config point to enable them --- software/contrib/pet_rock.py | 276 ++++++++++++++++++++++++++++------- 1 file changed, 226 insertions(+), 50 deletions(-) diff --git a/software/contrib/pet_rock.py b/software/contrib/pet_rock.py index 638a5559f..720cf9fa8 100644 --- a/software/contrib/pet_rock.py +++ b/software/contrib/pet_rock.py @@ -8,6 +8,7 @@ from europi import * from europi_script import EuroPiScript +import configuration from framebuf import FrameBuffer, MONO_HLSB import math import random @@ -15,16 +16,10 @@ from experimental.a_to_d import AnalogReaderDigitalWrapper from experimental.math_extras import rescale +from experimental.random_extras import shuffle from experimental.rtc import * -def randint(min, max): - """ - Return a random integer in the range [min, max] - """ - return int(random.random() * (max - min + 1) + min) - - class Algo: """ Generic algorithm for generating the gate sequences @@ -114,9 +109,9 @@ def __init__(self, channel, weekday, cycle, continuity): seqmax = 0 if cycle == MoonPhase.NEW_MOON: - seqmax = randint(5, 7) + seqmax = random.randint(5, 7) elif cycle == MoonPhase.WAXING_CRESCENT or cycle == MoonPhase.WANING_CRESCENT: - seqmax = randint(4, 16) + seqmax = random.randint(4, 16) elif cycle == MoonPhase.FIRST_QUARTER or cycle == MoonPhase.THIRD_QUARTER: seqmax = Algo.map(continuity, 0, 100, 6, 12) seqmax = seqmax * self.channel # channel B is twice as long as A @@ -138,7 +133,7 @@ def __init__(self, channel, weekday, cycle, continuity): # being played against each other - this is the "meta movement" of the rhythmic # flavor, in every algo/mood for i in range(seqmax): - self.sequence.append(randint(0, 1)) + self.sequence.append(random.randint(0, 1)) class AlgoReich(Algo): @@ -153,7 +148,7 @@ def __init__(self, channel, weekday, cycle, continuity): super().__init__(channel, weekday, cycle, continuity) if cycle == MoonPhase.NEW_MOON: - seqmax = randint(3, 5) + seqmax = random.randint(3, 5) elif cycle == MoonPhase.WAXING_CRESCENT or cycle == MoonPhase.WAXING_CRESCENT: if channel == Algo.CHANNEL_A: seqmax = Algo.map(continuity, 0, 100, 3, 8) @@ -161,7 +156,7 @@ def __init__(self, channel, weekday, cycle, continuity): a = Algo.map(continuity, 0, 100, 3, 8) b = 0 while b == 0 or b == a or b == a*2 or b*2 == a: - b = randint(3, 8) + b = random.randint(3, 8) seqmax = b elif cycle == MoonPhase.FIRST_QUARTER or cycle == MoonPhase.THIRD_QUARTER: @@ -174,7 +169,7 @@ def __init__(self, channel, weekday, cycle, continuity): seqDensity=50 for i in range(seqmax): - if randint(0, 99) < seqDensity: + if random.randint(0, 99) < seqDensity: self.sequence.append(1) else: self.sequence.append(0) @@ -184,7 +179,7 @@ def __init__(self, channel, weekday, cycle, continuity): if self.sequence[i] == 1: empty = False if empty: - self.sequence[randint(0, len(self.sequence)-1)] = 1 + self.sequence[random.randint(0, len(self.sequence)-1)] = 1 class AlgoSparse(Algo): @@ -199,9 +194,9 @@ def __init__(self, channel, weekday, cycle, continuity): super().__init__(channel, weekday, cycle, continuity) if cycle == MoonPhase.NEW_MOON: - seqmax = randint(10, 19) + seqmax = random.randint(10, 19) elif cycle == MoonPhase.WAXING_CRESCENT or cycle == MoonPhase.WANING_CRESCENT: - seqmax = randint(15, 30) + seqmax = random.randint(15, 30) elif cycle == MoonPhase.FIRST_QUARTER: if channel == Algo.CHANNEL_A: seqmax = 32 @@ -224,11 +219,11 @@ def __init__(self, channel, weekday, cycle, continuity): for i in range(seqmax): self.sequence.append(0) - seedStepInd = randint(0, seqmax - 1) + seedStepInd = random.randint(0, seqmax - 1) self.sequence[seedStepInd] = 1 for i in range(seqmax): - if randint(0, 99) < densityPercent: + if random.randint(0, 99) < densityPercent: self.sequence[i] = 1 @@ -244,11 +239,11 @@ def __init__(self, channel, weekday, cycle, continuity): super().__init__(channel, weekday, cycle, continuity) if cycle == MoonPhase.NEW_MOON: - seqmax = randint(3, 19) + seqmax = random.randint(3, 19) repeats = 3 elif cycle == MoonPhase.WAXING_CRESCENT or cycle == MoonPhase.WANING_CRESCENT: - seqmax = randint(8, 12) - repeats = randint(3, 6) + seqmax = random.randint(8, 12) + repeats = random.randint(3, 6) elif cycle == MoonPhase.FIRST_QUARTER or cycle == MoonPhase.THIRD_QUARTER: seqmax = 8 if channel == Algo.CHANNEL_A: @@ -265,12 +260,12 @@ def __init__(self, channel, weekday, cycle, continuity): seq_a = [] seq_b = [] for i in range(seqmax): - r = randint(0, 1) + r = random.randint(0, 1) seq_a.append(r) seq_b.append(r) for i in range(seqmax-1, -1, -1): - j = randint(0, i) + j = random.randint(0, i) tmp = seq_b[i] seq_b[i] = seq_b[j] @@ -293,6 +288,39 @@ class AlgoBlocks(Algo): # hearts mood_graphics = bytearray(b'\x07\xe0\x07\xe0\x1f\xf8\x1f\xf8?\xfc?\xfc\x7f\xfe\x7f\xfe\x7f\xfe\x7f\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x7f\xff\xff\xfe?\xff\xff\xfc\x1f\xff\xff\xf8\x1f\xff\xff\xf8\x0f\xff\xff\xf0\x07\xff\xff\xe0\x07\xff\xff\xe0\x03\xff\xff\xc0\x01\xff\xff\x80\x01\xff\xff\x80\x00\xff\xff\x00\x00\x7f\xfe\x00\x00\x7f\xfe\x00\x00?\xfc\x00\x00\x1f\xf8\x00\x00\x1f\xf8\x00\x00\x0f\xf0\x00\x00\x07\xe0\x00\x00\x07\xe0\x00\x00\x03\xc0\x00\x00\x01\x80\x00') + blocks = [ + [0, 0, 0, 0], + [1, 0, 0, 0], + [1, 0, 1, 0], + [1, 0, 0, 1], + [1, 1, 1, 0], + [1, 0, 1, 1], + [0, 0, 1, 0], + [1, 1, 1, 1], + ] + + def __init__(self, channel, weekday, cycle, continuity): + super().__init__(channel, weekday, cycle, continuity) + + # This is wholly original + # the original, deprecated code has a to-do here + if cycle == MoonPhase.NEW_MOON: + numblocks = 2 + elif cycle == MoonPhase.WAXING_CRESCENT or cycle == MoonPhase.WANING_CRESCENT: + numblocks = random.randint(3, 4) + elif cycle == MoonPhase.FIRST_QUARTER or cycle == MoonPhase.THIRD_QUARTER: + numblocks = random.randint(2, 3) + numblocks = numblocks * self.channel # B is double A + elif cycle == MoonPhase.WAXING_GIBBOUS or cycle == MoonPhase.WANING_GIBBOUS: + numblocks = random.randint(4, 5) + else: + numblocks = 6 + + for i in range(numblocks): + block = AlgoBlocks.blocks[random.randint(0, len(AlgoBlocks.blocks) - 1)] + for n in block: + self.sequence.append(block) + class AlgoCulture(Algo): """ @@ -302,6 +330,29 @@ class AlgoCulture(Algo): # spades mood_graphics = bytearray(b'\x00\x01\x80\x00\x00\x03\xc0\x00\x00\x07\xe0\x00\x00\x0f\xf0\x00\x00\x1f\xf8\x00\x00?\xfc\x00\x00\x7f\xfe\x00\x00\xff\xff\x00\x01\xff\xff\x80\x03\xff\xff\xc0\x07\xff\xff\xe0\x0f\xff\xff\xf0\x1f\xff\xff\xf8?\xff\xff\xfc\x7f\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x7f\xff\xff\xfe\x7f\xff\xff\xfe?\xfd\xbf\xfc\x1f\xf9\x9f\xf8\x07\xe1\x87\xe0\x00\x01\x80\x00\x00\x01\x80\x00\x00\x01\x80\x00\x00\x07\xe0\x00\x00\x1f\xf8\x00\x00?\xfc\x00') + rhythms = [ + [1,0,0,1,1,0,1,0,1,0,0,1,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], + [1,0,0,1,0,0,1,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], + [1,0,0,1,0,0,1,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], + [1,0,1,0,1,0,1,0,1,0,0,1,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], + [1,1,1,1,1,0,0,1,1,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], + [1,1,0,1,1,0,1,0,1,1,0,1,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], + [1,0,0,1,0,0,1,0,0,0,1,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], + [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], + ] + + def __init__(self, channel, weekday, cycle, continuity): + super().__init__(channel, weekday, cycle, continuity) + + for i in range(32): + self.sequence.append(AlgoCulture.rhythms[weekday][i]) + + # most of the time we won't add any additional steps, but sometimes + # we will add 1-7 more + extra_steps = random.randint(-8, 7) + for i in range(extra_steps): + self.sequence.append(random.randint(0, 1)) + class AlgoOver(Algo): """ @@ -311,6 +362,66 @@ class AlgoOver(Algo): # diamonds mood_graphics = bytearray(b'\x00\x01\x80\x00\x00\x01\x80\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x07\xe0\x00\x00\x07\xe0\x00\x00\x0f\xf0\x00\x00\x0f\xf0\x00\x00\x1f\xf8\x00\x00\x1f\xf8\x00\x00?\xfc\x00\x00?\xfc\x00\x00\x7f\xfe\x00\x00\xff\xff\x00\x03\xff\xff\xc0\x0f\xff\xff\xf0\x0f\xff\xff\xf0\x03\xff\xff\xc0\x00\xff\xff\x00\x00\x7f\xfe\x00\x00?\xfc\x00\x00?\xfc\x00\x00\x1f\xf8\x00\x00\x1f\xf8\x00\x00\x0f\xf0\x00\x00\x0f\xf0\x00\x00\x07\xe0\x00\x00\x07\xe0\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x01\x80\x00\x00\x01\x80\x00') + def __init__(self, channel, weekday, cycle, continuity): + super().__init__(channel, weekday, cycle, continuity) + + densityPercent = 50 + + # same lengths as AlgoPlain + if cycle == MoonPhase.NEW_MOON: + seqmax = random.randint(5, 7) + elif cycle == MoonPhase.WAXING_CRESCENT or cycle == MoonPhase.WANING_CRESCENT: + seqmax = random.randint(4, 16) + elif cycle == MoonPhase.FIRST_QUARTER or cycle == MoonPhase.THIRD_QUARTER: + seqmax = Algo.map(continuity, 0, 100, 6, 12) + seqmax = seqmax * self.channel # channel B is twice as long as A + elif cycle == MoonPhase.WAXING_GIBBOUS: + seqmax = 12 + elif cycle == MoonPhase.WANING_GIBBOUS: + seqmax = 16 + else: + seqmax = 16 + + self.seq1 = [] + self.seq2 = [] + + for i in range(seqmax): + if random.randint(0, 99) < densityPercent: + self.seq1.append(1) + else: + self.seq1.append(0) + + if random.randint(0, 99) < densityPercent: + self.seq2.append(1) + else: + self.seq2.append(0) + + + self.swaps = list(range(seqmax)) + shuffle(self.swaps) + self.switch_index = 0 + self.swap = True + + for n in self.seq1: + self.sequence.append(n) + + def tick(self): + super().tick() + + # overwrite steps betwen seq1 and seq2 + if self.index == 0: + overwrite_index = self.swaps[self.switch_index] + self.switch_index += 1 + + if self.switch_index > len(self.sequence) - 1: + self.switch_index = 0 + self.swap = not self.swap + + if not self.swap: + self.sequence[overwrite_index] = self.seq2[overwrite_index] + else: + self.sequence[overwrite_index] = self.seq1[overwrite_index] + class AlgoWonk(Algo): """ @@ -320,6 +431,40 @@ class AlgoWonk(Algo): # shields mood_graphics = bytearray(b'\xff\xff\xff\xff\x9f\xff\xff\xff\x8f\xff\xe0?\x87\xff\xf0\x7f\x83\xff\xb8\xef\xc1\xff\x98\xcf\xe0\xff\x80\x0f\xf0\x7f\x80\x0f\xf8?\x80\x0f\xfc\x1f\x98\xcf\xfe\x0f\xb8\xef\xff\x07\xf0\x7f\xff\x83\xe0?\xff\xc1\xff\xff\xff\xe0\xff\xff\xff\xf0\x7f\xff\xff\xf8?\xff\x7f\xfc\x1f\xfe?\xfe\x0f\xfc\x1f\xff\x07\xf8\x0f\xff\x83\xf0\x07\xff\xc1\xe0\x03\xff\xe0\xc0\x01\xff\xf0\x80\x00\xff\xf9\x00\x00\x7f\xfe\x00\x00?\xfc\x00\x00\x1f\xf8\x00\x00\x0f\xf0\x00\x00\x07\xe0\x00\x00\x03\xc0\x00\x00\x01\x80\x00') + def __init__(self, channel, weekday, cycle, continuity): + super().__init__(channel, weekday, cycle, continuity) + + densityPercent = Algo.map(weekday, 1, 7, 30, 60) + + seqmax = 32 + + for i in range(seqmax): + self.sequence.append(0) + + + # ensure there is at least 2 filled steps + self.sequence[random.randint(0, seqmax - 1)] = 1 + + steps_placed = 1 + for i in range(0, seqmax, 4): + if random.randint(0, 99) < densityPercent: + self.sequence[i] = 1 + steps_placed += 1 + + steps_to_wonk = Algo.map(cycle, 0, 7, 1, steps_placed) + steps_wonked = 0 + while steps_wonked < steps_to_wonk: + chosen_step = random.randint(0, seqmax - 1) + + if self.sequence[chosen_step] == 1: + steps_wonked += 1 + self.sequence[chosen_step] = 0 + + if random.randint(0, 99) < 50: + self.sequence[chosen_step + 1] = 1 + else: + self.sequence[chosen_step - 1] = 1 + class MoonPhase: """ @@ -432,24 +577,40 @@ class Mood: Mood is one of 4 colours, which rotates every moon cycle """ - MOOD_RED = 0 - MOOD_BLUE = 1 - MOOD_YELLOW = 2 - MOOD_GREEN = 3 - - N_MOODS = 4 + available_moods = [ + AlgoPlain, + AlgoReich, + AlgoSparse, + AlgoVari + ] @staticmethod - def calculate_mood(date): - """ - Calculate the current mood - - @param date The current UTC DateTime - """ - days_since_new_moon = MoonPhase.calculate_days_since_new_moon(date) - cycles = math.floor(days_since_new_moon / 29.53) - - return cycles % Mood.N_MOODS + def set_moods(moodstring): + if moodstring == "all": + Mood.available_moods = [ + AlgoPlain, + AlgoReich, + AlgoSparse, + AlgoVari, + AlgoBlocks, + AlgoCulture, + AlgoOver, + AlgoWonk, + ] + elif moodstring == "alternate": + Mood.available_moods = [ + AlgoBlocks, + AlgoCulture, + AlgoOver, + AlgoWonk, + ] + else: # "classic" + Mood.available_moods = [ + AlgoPlain, + AlgoReich, + AlgoSparse, + AlgoVari, + ] @staticmethod def mood_algorithm(date): @@ -458,15 +619,10 @@ def mood_algorithm(date): @param date The current UTC DateTime """ - mood = Mood.calculate_mood(date) - if mood == Mood.MOOD_RED: - return AlgoPlain - elif mood == Mood.MOOD_BLUE: - return AlgoReich - elif mood == Mood.MOOD_YELLOW: - return AlgoSparse - else: - return AlgoVari + days_since_new_moon = MoonPhase.calculate_days_since_new_moon(date) + cycles = math.floor(days_since_new_moon / 29.53) + + return Mood.available_moods[cycles % len(Mood.available_moods)] class IntervalTimer: @@ -523,6 +679,8 @@ class PetRock(EuroPiScript): def __init__(self): super().__init__() + Mood.set_moods(self.config.MOODS) + self.seed_offset = 1 self.generate_sequences() @@ -549,8 +707,26 @@ def __init__(self): fall_cb=self.on_channel_a_fall ) + @classmethod + def config_points(cls): + return [ + # What moods does the user want to use? + # - classic: the original 4 moods from the hardware Pet Rock + # - alternate: the 4 deprecated moods that weren't implemented + # - all: all 8 possible moods + configuration.choice( + "MOODS", + choices=[ + "classic", + "alternate", + "all" + ], + default="classic", + ), + ] + def generate_sequences(self): - continuity = randint(0, 99) + continuity = random.randint(0, 99) now = clock.utcnow() if now.weekday is None: @@ -591,7 +767,7 @@ def draw(self, local_time): oled.blit(moon_img, 0, 0) mood_img = FrameBuffer(self.sequence_a.mood_graphics, 32, 32, MONO_HLSB) - oled.blit(mood_img, 34, 0) + oled.blit(mood_img, 40, 0) oled.show() From b84be0bf74ce931915d8ad4ac20c3b789af9a12c Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sat, 11 Jan 2025 03:46:58 -0500 Subject: [PATCH 30/45] Document the new config option --- software/contrib/pet_rock.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/software/contrib/pet_rock.md b/software/contrib/pet_rock.md index d0d50e7dd..a80aacb5d 100644 --- a/software/contrib/pet_rock.md +++ b/software/contrib/pet_rock.md @@ -22,6 +22,41 @@ This script generates pseudo-random gate sequences based on the phase of the moo Both sequence A and sequence B can be internally clocked by setting the speed using `K1` and `K2`. Turning these knobs fully anticlockwise will stop the internal clocks. +## Configuration + +Pet Rock can be configured to use different pseudo-random rhythm-generating algorithms. To choose, edit +`config/PetRock.json` to set the `MOODS` key: +```json +{ + "MOODS": "classic" +} +``` + +- `MOODS` can be one of `classic` (default), `alternate`, or `all` + +Depending on the `MOODS` configured, the following algorithms are used, cycling every new moon: + +**Classic** +- ![swords](./pet_rock-docs/swords.png) Plain +- ![cups](./pet_rock-docs/cups.png) Reich +- ![wands](./pet_rock-docs/wands.png) Sparse +- ![pentacles](./pet_rock-docs/pentacle.png) Vari + +**Alternate** + +These algorithms were implemented in the original Pet Rock firmware, but ultimately not used in the final +release. +- ![hearts](./pet_rock-docs/heart.png) Blocks +- ![spades](./pet_rock-docs/spade.png) Culture +- ![diamonds](./pet_rock-docs/diamond.png) Over +- ![shields](./pet_rock-docs/shield.png) Wonk + +**All** + +When `"all"` moods are selected, the order is the 4 classic algorithms, followed by the 4 alternate algorithms, +in the order listed above. + + ## Required Hardware This script requires a Realtime Clock (RTC) to EuroPi's secondary I2C header pins, From 67decb9863ea3b3e9fbc5bf9210bb4b9bc096412 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sat, 11 Jan 2025 03:54:42 -0500 Subject: [PATCH 31/45] Add license information, link to the original source code --- software/contrib/pet_rock.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/software/contrib/pet_rock.py b/software/contrib/pet_rock.py index 720cf9fa8..0185ff190 100644 --- a/software/contrib/pet_rock.py +++ b/software/contrib/pet_rock.py @@ -3,6 +3,29 @@ Tracks the phase of the moon using a realtime clock and generates pseudo-random gate sequences based on the date & moon phase + +The original code is written in C++ and released under the +CC BY-NC-SA 4.0 license (https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode.en) + +Original source code is located here: https://github.com/jsenzel1/petrock + +This re-implementation is not a perfect 1:1 copy of the original, but attempts +to faithfully recreate the idea behind it for the EuroPi module. + +Instead of using colours to differentiate between the moods, this program +displays moods using Tarot and/or playing card suits: +- swords (red) +- cups (blue) +- wands/clubs (yellow) +- pentacles (green) + +An additional 4 moods were included in the original C++ firmware, but were +deprecated. They are also re-implemented here, but disabled by default: +- hearts +- spades +- diamonds +- shields (since clubs overlap with the original tarot suits; some Swiss-German + decks of cards include shields as an additional suit) """ from europi import * From bd60f60d78dce68aefa17cd29dfc2ab15a8fb409 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sat, 11 Jan 2025 03:59:26 -0500 Subject: [PATCH 32/45] Fix param documentation for continuity variable --- software/contrib/pet_rock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/software/contrib/pet_rock.py b/software/contrib/pet_rock.py index 0185ff190..5758973ad 100644 --- a/software/contrib/pet_rock.py +++ b/software/contrib/pet_rock.py @@ -60,7 +60,7 @@ def __init__(self, channel, weekday, cycle, continuity): @param channel 1 for channel A, 2 for channel B @param weekday The current weekday 1-7 (M-Su) @param cycle The current moon phase - @param continuity ??? + @param continuity A random value shared between both A and B channels """ self.channel = channel From 892675aaf9f27fc6eefa545024e6db4162395faa Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sat, 11 Jan 2025 11:34:54 -0500 Subject: [PATCH 33/45] Swap the shields & clubs icons around, add a note to the readme about the suits --- software/contrib/pet_rock.md | 20 ++++++++++++++++++-- software/contrib/pet_rock.py | 20 ++++++++++---------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/software/contrib/pet_rock.md b/software/contrib/pet_rock.md index a80aacb5d..09c4325a8 100644 --- a/software/contrib/pet_rock.md +++ b/software/contrib/pet_rock.md @@ -39,7 +39,7 @@ Depending on the `MOODS` configured, the following algorithms are used, cycling **Classic** - ![swords](./pet_rock-docs/swords.png) Plain - ![cups](./pet_rock-docs/cups.png) Reich -- ![wands](./pet_rock-docs/wands.png) Sparse +- ![shields](./pet_rock-docs/shield.png) Sparse - ![pentacles](./pet_rock-docs/pentacle.png) Vari **Alternate** @@ -49,7 +49,7 @@ release. - ![hearts](./pet_rock-docs/heart.png) Blocks - ![spades](./pet_rock-docs/spade.png) Culture - ![diamonds](./pet_rock-docs/diamond.png) Over -- ![shields](./pet_rock-docs/shield.png) Wonk +- ![clubs](./pet_rock-docs/wands.png) Wonk **All** @@ -57,6 +57,22 @@ When `"all"` moods are selected, the order is the 4 classic algorithms, followed in the order listed above. +### Note on suits + +Yes, I'm aware that "shields" isn't a normal Tarot suit. Originally I used "clubs" (an alternative to the +traditional "wands" suit in most tarot decks). But it seemed weird having 3/4 English card suits used for +the alternate moods, and then have "shields" tossed-in to fill it out; "shields" is a suit more associated +with Swiss playing cards. + +Since shields and swords are a natural pairing, I swapped things around to have, in my mind, more logical +groupings. I'm sorry if this decision causes anyone distress. + +Additionally, I realize the "pentacle" symbol has some negative associations for some. No offense is meant; +this is a traditional suit in tarot cards, and felt appropriate for a moon-phase-tracking program. I did +consider swapping it for its "coins" alternative, but was concerned that a circular or elliptical coin +motif might be too visually similar to the full & gibbous moon icons. + + ## Required Hardware This script requires a Realtime Clock (RTC) to EuroPi's secondary I2C header pins, diff --git a/software/contrib/pet_rock.py b/software/contrib/pet_rock.py index 5758973ad..6b3237a4e 100644 --- a/software/contrib/pet_rock.py +++ b/software/contrib/pet_rock.py @@ -210,8 +210,8 @@ class AlgoSparse(Algo): The Yellow-mood algorithm """ - # wands/clubs - mood_graphics = bytearray(b'\x00\x07\xe0\x00\x00\x0f\xf0\x00\x00\x1f\xf8\x00\x00?\xfc\x00\x00\x7f\xfe\x00\x00\x7f\xfe\x00\x00\x7f\xfe\x00\x00\x7f\xfe\x00\x00\x7f\xfe\x00\x00\x7f\xfe\x00\x0f\xff\xff\xf0\x1f\xff\xff\xf8?\xff\xff\xfc\x7f\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x7f\xfb\xdf\xfe?\xf3\xcf\xfc\x1f\xe3\xc7\xf8\x0f\xc3\xc3\xf0\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x07\xe0\x00\x00\x0f\xf0\x00\x00\x1f\xf8\x00\x00?\xfc\x00\x00\x7f\xfe\x00') + # shields + mood_graphics = bytearray(b'\xff\xff\xff\xff\x9f\xff\xff\xff\x8f\xff\xe0?\x87\xff\xf0\x7f\x83\xff\xb8\xef\xc1\xff\x98\xcf\xe0\xff\x80\x0f\xf0\x7f\x80\x0f\xf8?\x80\x0f\xfc\x1f\x98\xcf\xfe\x0f\xb8\xef\xff\x07\xf0\x7f\xff\x83\xe0?\xff\xc1\xff\xff\xff\xe0\xff\xff\xff\xf0\x7f\xff\xff\xf8?\xff\x7f\xfc\x1f\xfe?\xfe\x0f\xfc\x1f\xff\x07\xf8\x0f\xff\x83\xf0\x07\xff\xc1\xe0\x03\xff\xe0\xc0\x01\xff\xf0\x80\x00\xff\xf9\x00\x00\x7f\xfe\x00\x00?\xfc\x00\x00\x1f\xf8\x00\x00\x0f\xf0\x00\x00\x07\xe0\x00\x00\x03\xc0\x00\x00\x01\x80\x00') def __init__(self, channel, weekday, cycle, continuity): super().__init__(channel, weekday, cycle, continuity) @@ -355,13 +355,13 @@ class AlgoCulture(Algo): rhythms = [ [1,0,0,1,1,0,1,0,1,0,0,1,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], - [1,0,0,1,0,0,1,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], - [1,0,0,1,0,0,1,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], - [1,0,1,0,1,0,1,0,1,0,0,1,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], - [1,1,1,1,1,0,0,1,1,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], + [1,0,0,1,0,0,1,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], + [1,0,0,1,0,0,1,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], + [1,0,1,0,1,0,1,0,1,0,0,1,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], + [1,1,1,1,1,0,0,1,1,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], [1,1,0,1,1,0,1,0,1,1,0,1,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], - [1,0,0,1,0,0,1,0,0,0,1,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], - [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], + [1,0,0,1,0,0,1,0,0,0,1,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], + [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], ] def __init__(self, channel, weekday, cycle, continuity): @@ -451,8 +451,8 @@ class AlgoWonk(Algo): One of the unimplemented algorithms in the original firmware """ - # shields - mood_graphics = bytearray(b'\xff\xff\xff\xff\x9f\xff\xff\xff\x8f\xff\xe0?\x87\xff\xf0\x7f\x83\xff\xb8\xef\xc1\xff\x98\xcf\xe0\xff\x80\x0f\xf0\x7f\x80\x0f\xf8?\x80\x0f\xfc\x1f\x98\xcf\xfe\x0f\xb8\xef\xff\x07\xf0\x7f\xff\x83\xe0?\xff\xc1\xff\xff\xff\xe0\xff\xff\xff\xf0\x7f\xff\xff\xf8?\xff\x7f\xfc\x1f\xfe?\xfe\x0f\xfc\x1f\xff\x07\xf8\x0f\xff\x83\xf0\x07\xff\xc1\xe0\x03\xff\xe0\xc0\x01\xff\xf0\x80\x00\xff\xf9\x00\x00\x7f\xfe\x00\x00?\xfc\x00\x00\x1f\xf8\x00\x00\x0f\xf0\x00\x00\x07\xe0\x00\x00\x03\xc0\x00\x00\x01\x80\x00') + # clubs + mood_graphics = bytearray(b'\x00\x07\xe0\x00\x00\x0f\xf0\x00\x00\x1f\xf8\x00\x00?\xfc\x00\x00\x7f\xfe\x00\x00\x7f\xfe\x00\x00\x7f\xfe\x00\x00\x7f\xfe\x00\x00\x7f\xfe\x00\x00\x7f\xfe\x00\x0f\xff\xff\xf0\x1f\xff\xff\xf8?\xff\xff\xfc\x7f\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x7f\xfb\xdf\xfe?\xf3\xcf\xfc\x1f\xe3\xc7\xf8\x0f\xc3\xc3\xf0\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x07\xe0\x00\x00\x0f\xf0\x00\x00\x1f\xf8\x00\x00?\xfc\x00\x00\x7f\xfe\x00') def __init__(self, channel, weekday, cycle, continuity): super().__init__(channel, weekday, cycle, continuity) From d095d23b81b45930859bbe384ed4f89c655358f4 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sat, 11 Jan 2025 11:45:51 -0500 Subject: [PATCH 34/45] Make Algo.map return an int to avoid floating-point conversion issues --- software/contrib/pet_rock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/software/contrib/pet_rock.py b/software/contrib/pet_rock.py index 6b3237a4e..d0614d3af 100644 --- a/software/contrib/pet_rock.py +++ b/software/contrib/pet_rock.py @@ -87,7 +87,7 @@ def __init__(self, channel, weekday, cycle, continuity): def map(x, in_min, in_max, out_min, out_max): # treat the output as inclusive out_max = out_max + 1 - return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min + return int((x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min) def tick(self): """ From 963dd8bd688e94dc62bf78dc0d8370904c5fe372 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sat, 11 Jan 2025 13:32:38 -0500 Subject: [PATCH 35/45] Fix a bug when appending the block contents in AlgoBlock --- software/contrib/pet_rock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/software/contrib/pet_rock.py b/software/contrib/pet_rock.py index d0614d3af..d5db50e58 100644 --- a/software/contrib/pet_rock.py +++ b/software/contrib/pet_rock.py @@ -342,7 +342,7 @@ def __init__(self, channel, weekday, cycle, continuity): for i in range(numblocks): block = AlgoBlocks.blocks[random.randint(0, len(AlgoBlocks.blocks) - 1)] for n in block: - self.sequence.append(block) + self.sequence.append(n) class AlgoCulture(Algo): From 276e681e3779ca23f06b607fa1d9c7dfc4dee578 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sat, 11 Jan 2025 13:54:24 -0500 Subject: [PATCH 36/45] Update Algo docstrings to briefly describe each algorithm. Tidy up AlgoWonk a little bit --- software/contrib/pet_rock.py | 55 ++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/software/contrib/pet_rock.py b/software/contrib/pet_rock.py index d5db50e58..6b20752bc 100644 --- a/software/contrib/pet_rock.py +++ b/software/contrib/pet_rock.py @@ -46,6 +46,8 @@ class Algo: """ Generic algorithm for generating the gate sequences + + All pseudo-random algorithms inherit from this class """ CHANNEL_A = 1 @@ -59,8 +61,8 @@ def __init__(self, channel, weekday, cycle, continuity): @param channel 1 for channel A, 2 for channel B @param weekday The current weekday 1-7 (M-Su) - @param cycle The current moon phase - @param continuity A random value shared between both A and B channels + @param cycle The current moon phase: 0-7 (new to waning crescent) + @param continuity A random value shared between both A and B channels: 0-100 """ self.channel = channel @@ -120,7 +122,14 @@ def outputs_off(self): class AlgoPlain(Algo): """ - The Red-mood algorithm + A straight-forward random-fill + + We choose a length based on the moon phase and then fill it with on/off signals + using a coin-toss. + + This _could_ result in all-on or all-off patterns, but this is generally unlikely + + This corresponds to the red mood in the original firmware """ # swords @@ -161,7 +170,10 @@ def __init__(self, channel, weekday, cycle, continuity): class AlgoReich(Algo): """ - The Blue-mood algorithm + Choose a random pattern length pased on the moon phase and our continuity variable + and fill the pattern with on/off using a fixed density + + This corresponds to the blue mood in the original firmware """ # cups @@ -207,7 +219,10 @@ def __init__(self, channel, weekday, cycle, continuity): class AlgoSparse(Algo): """ - The Yellow-mood algorithm + Chooses a fixed length based on moon phase and fills the pattern with a low-density + on/off pattern (10% on) + + This corresponds to the yellow mood in the original firmware """ # shields @@ -252,7 +267,12 @@ def __init__(self, channel, weekday, cycle, continuity): class AlgoVari(Algo): """ - The Green-mood algorithm + Generates two sub-sequences A & B, repeating each a fixed number of times + + e.g. if sequence a is 0011 and sequence b is 1010, with 2 repeats the final pattern + is 0011 0011 1010 1010 + + This corresponds to the green mood in the original firmware """ # pentacles @@ -305,6 +325,8 @@ def __init__(self, channel, weekday, cycle, continuity): class AlgoBlocks(Algo): """ + Builds the sequence by randomly choosing N pre-defined pattern blocks + One of the unimplemented algorithms in the original firmware """ @@ -347,6 +369,12 @@ def __init__(self, channel, weekday, cycle, continuity): class AlgoCulture(Algo): """ + Chooses a pre-programmed rhythm based on the weekday and adds a random number + of coin-toss steps to the end of it + + The back half of the rhythms is zero, resulting in some interesting hand-offs between + the main and inverted outputs. + One of the unimplemented algorithms in the original firmware """ @@ -379,6 +407,9 @@ def __init__(self, channel, weekday, cycle, continuity): class AlgoOver(Algo): """ + Generates two sub-sequences and overwrites parts of the main sequence with each + after each complete cycle + One of the unimplemented algorithms in the original firmware """ @@ -448,6 +479,12 @@ def tick(self): class AlgoWonk(Algo): """ + Generates a fixed-length pattern with a random density based on the weekday. We then + "wonk" a number of steps based on the moon phase + + Wonking means we choose a random on-step, turn it off, and then turn the step before + or after on. + One of the unimplemented algorithms in the original firmware """ @@ -465,10 +502,10 @@ def __init__(self, channel, weekday, cycle, continuity): self.sequence.append(0) - # ensure there is at least 2 filled steps + # ensure there is at least 1 filled step self.sequence[random.randint(0, seqmax - 1)] = 1 - steps_placed = 1 + steps_placed = 0 for i in range(0, seqmax, 4): if random.randint(0, 99) < densityPercent: self.sequence[i] = 1 @@ -483,7 +520,7 @@ def __init__(self, channel, weekday, cycle, continuity): steps_wonked += 1 self.sequence[chosen_step] = 0 - if random.randint(0, 99) < 50: + if random.randint(0, 1) == 0: self.sequence[chosen_step + 1] = 1 else: self.sequence[chosen_step - 1] = 1 From 404713fe311d57c1757999e8db91fe4d56516671 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sat, 11 Jan 2025 20:46:27 -0500 Subject: [PATCH 37/45] Refactor the DateTime class to add properties for the number of days in the current month & year, is_leap_year flag. Static methods renamed. Fix a bug in the wonk generation that could result in out-of-bounds errors --- software/contrib/pet_rock.py | 72 ++++++++++++++++++++++++--- software/firmware/experimental/rtc.py | 33 ++++++++---- 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/software/contrib/pet_rock.py b/software/contrib/pet_rock.py index 6b20752bc..3e320bcbe 100644 --- a/software/contrib/pet_rock.py +++ b/software/contrib/pet_rock.py @@ -520,7 +520,12 @@ def __init__(self, channel, weekday, cycle, continuity): steps_wonked += 1 self.sequence[chosen_step] = 0 - if random.randint(0, 1) == 0: + r = random.randint(0, 1) + if chosen_step == 0: + self.sequence[chosen_step + 1] = 1 + elif chosen_step == seqmax - 1: + self.sequence[chosen_step - 1] = 1 + elif r == 0: self.sequence[chosen_step + 1] = 1 else: self.sequence[chosen_step - 1] = 1 @@ -742,7 +747,7 @@ def __init__(self): Mood.set_moods(self.config.MOODS) self.seed_offset = 1 - self.generate_sequences() + self.generate_sequences(clock.utcnow()) self.din2 = AnalogReaderDigitalWrapper( ain, @@ -785,15 +790,17 @@ def config_points(cls): ), ] - def generate_sequences(self): - continuity = random.randint(0, 99) + def generate_sequences(self, now): + """ + Regenerate the day's rhythms - now = clock.utcnow() + @param now The current UTC + """ if now.weekday is None: now.weekday = 0 + continuity = random.randint(0, 99) cycle = MoonPhase.calculate_phase(now) - today_seed = now.day + now.month + now.year + self.seed_offset random.seed(today_seed) @@ -831,6 +838,52 @@ def draw(self, local_time): oled.show() + def run_test(self): + self.draw(clock.localnow()) + last_draw_at = clock.localnow() + + fake_date = clock.utcnow() + + while True: + self.din2.update() + + self.timer_a.update_interval() + self.timer_b.update_interval() + + self.timer_a.tick() + self.timer_b.tick() + + local_time = clock.localnow() + + ui_dirty = local_time.minute != last_draw_at.minute + + if ui_dirty: + fake_date.day = fake_date.day + 1 + fake_date.weekday = fake_date.weekday + 1 + if fake_date.weekday == 8: + fake_date.weekday = 1 + + if fake_date.day > fake_date.days_in_month: + fake_date.day = 1 + fake_date.month = fake_date.month + 1 + + if fake_date.month == 13: + fake_date.month = 1 + fake_date.year += 1 + + self.generate_sequences(fake_date) + self.sequence_a.state_dirty = True + self.sequence_b.state_dirty = True + + self.draw(local_time) + last_draw_at = local_time + + if self.sequence_a.state_dirty: + self.sequence_a.set_outputs() + + if self.sequence_b.state_dirty: + self.sequence_b.set_outputs() + def main(self): self.draw(clock.localnow()) last_draw_at = clock.localnow() @@ -851,7 +904,7 @@ def main(self): # if the day has rolled over, generate new sequences and mark them as dirty # so we'll continue playing if local_time.day != self.last_generation_at.day: - self.generate_sequences() + self.generate_sequences(clock.utcnow()) self.sequence_a.state_dirty = True self.sequence_b.state_dirty = True @@ -865,3 +918,8 @@ def main(self): if ui_dirty: self.draw(local_time) + last_draw_at = local_time + + +if __name__ == "__main__": + PetRock().main() diff --git a/software/firmware/experimental/rtc.py b/software/firmware/experimental/rtc.py index 3715925b2..5c821c35b 100644 --- a/software/firmware/experimental/rtc.py +++ b/software/firmware/experimental/rtc.py @@ -190,8 +190,8 @@ def __add__(self, tz): if t.weekday is not None: t.weekday = (t.weekday + 1) % 7 - days_in_month = DateTime.days_in_month(t.month + 1, t.year) - days_in_prev_month = DateTime.days_in_month((t.month + 1) % 12 + 1, t.year) + days_in_month = DateTime.calculate_days_in_month(t.month + 1, t.year) + days_in_prev_month = DateTime.calculate_days_in_month((t.month + 1) % 12 + 1, t.year) if t.day < 0: t.day = days_in_prev_month - 1 # last day of the month, zero-indexed t.month -= 1 @@ -215,18 +215,33 @@ def __add__(self, tz): return t @staticmethod - def is_leap_year(year): - # a year is a leap year if it is divisible by 4 - return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) - - @staticmethod - def days_in_month(month, year): + def calculate_days_in_month(month, year): month_lengths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] - if DateTime.is_leap_year(year) and month == Month.FEBRUARY: + if DateTime.calculate_is_leap_year(year) and month == Month.FEBRUARY: return 29 else: return month_lengths[month - 1] + @staticmethod + def calculate_is_leap_year(year): + # a year is a leap year if it is divisible by 4 + return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) + + @property + def is_leap_year(self): + return DateTime.calculate_is_leap_year(self.year) + + @property + def days_in_month(self): + return DateTime.calculate_days_in_month(self.month, self.year) + + @property + def days_in_year(self): + if self.is_leap_year: + return 366 + else: + return 365 + def __eq__(self, other): # fmt: off return ( From 3b40e479288e959d7e7d478e7d27f1e39121bae5 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sat, 11 Jan 2025 21:22:24 -0500 Subject: [PATCH 38/45] Render the fake time when running the tests --- software/contrib/pet_rock.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/software/contrib/pet_rock.py b/software/contrib/pet_rock.py index 3e320bcbe..6db0c20a2 100644 --- a/software/contrib/pet_rock.py +++ b/software/contrib/pet_rock.py @@ -842,7 +842,7 @@ def run_test(self): self.draw(clock.localnow()) last_draw_at = clock.localnow() - fake_date = clock.utcnow() + fake_date = clock.localnow() while True: self.din2.update() @@ -858,6 +858,8 @@ def run_test(self): ui_dirty = local_time.minute != last_draw_at.minute if ui_dirty: + fake_date.minute = local_time.minute + fake_date.hour = local_time.hour fake_date.day = fake_date.day + 1 fake_date.weekday = fake_date.weekday + 1 if fake_date.weekday == 8: @@ -875,7 +877,7 @@ def run_test(self): self.sequence_a.state_dirty = True self.sequence_b.state_dirty = True - self.draw(local_time) + self.draw(fake_date) last_draw_at = local_time if self.sequence_a.state_dirty: From c2c9acddf26a2462d49fce1650fbe76ad287888b Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sat, 11 Jan 2025 22:46:23 -0500 Subject: [PATCH 39/45] Only use localtime for displaying the clock, checking the last display update time, or for midnight; otherwise use UTC --- software/contrib/pet_rock.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/software/contrib/pet_rock.py b/software/contrib/pet_rock.py index 6db0c20a2..f735ebbe1 100644 --- a/software/contrib/pet_rock.py +++ b/software/contrib/pet_rock.py @@ -821,15 +821,16 @@ def on_channel_b_trigger(self): def on_channel_b_fall(self): self.sequence_b.outputs_off() - def draw(self, local_time): + def draw(self, utc_time): oled.fill(0) + local_time = utc_time + local_timezone if local_time.weekday: oled.text(Weekday.NAME[local_time.weekday][0:3].upper(), OLED_WIDTH - CHAR_WIDTH * 3, 0, 1) oled.text(f"{local_time.hour:02}:{local_time.minute:02}", OLED_WIDTH - CHAR_WIDTH * 5, OLED_HEIGHT - CHAR_HEIGHT, 1) - moon_phase = MoonPhase.calculate_phase(clock.utcnow()) + moon_phase = MoonPhase.calculate_phase(utc_time) moon_img = FrameBuffer(MoonPhase.moon_phase_images[moon_phase], 32, 32, MONO_HLSB) oled.blit(moon_img, 0, 0) @@ -839,10 +840,10 @@ def draw(self, local_time): oled.show() def run_test(self): - self.draw(clock.localnow()) - last_draw_at = clock.localnow() + self.draw(clock.utcnow()) + last_draw_at = clock.utcnow() - fake_date = clock.localnow() + fake_date = clock.utcnow() while True: self.din2.update() @@ -853,7 +854,7 @@ def run_test(self): self.timer_a.tick() self.timer_b.tick() - local_time = clock.localnow() + local_time = clock.utcnow() ui_dirty = local_time.minute != last_draw_at.minute @@ -887,7 +888,7 @@ def run_test(self): self.sequence_b.set_outputs() def main(self): - self.draw(clock.localnow()) + self.draw(clock.utcnow()) last_draw_at = clock.localnow() while True: @@ -919,7 +920,7 @@ def main(self): self.sequence_b.set_outputs() if ui_dirty: - self.draw(local_time) + self.draw(clock.utcnow()) last_draw_at = local_time From f5afe17bedc2849797919478caad547b92e51174 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sun, 12 Jan 2025 01:28:02 -0500 Subject: [PATCH 40/45] Add logging for debugging, ensure that the sequence is never full nor empty --- software/contrib/pet_rock.py | 92 +++++++++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 17 deletions(-) diff --git a/software/contrib/pet_rock.py b/software/contrib/pet_rock.py index f735ebbe1..fb13b6bc1 100644 --- a/software/contrib/pet_rock.py +++ b/software/contrib/pet_rock.py @@ -53,7 +53,7 @@ class Algo: CHANNEL_A = 1 CHANNEL_B = 2 - def __init__(self, channel, weekday, cycle, continuity): + def __init__(self, channel, weekday, cycle, continuity, algorithm, mood_name): """ Child constructors must call this first @@ -63,7 +63,11 @@ def __init__(self, channel, weekday, cycle, continuity): @param weekday The current weekday 1-7 (M-Su) @param cycle The current moon phase: 0-7 (new to waning crescent) @param continuity A random value shared between both A and B channels: 0-100 + @param algorithm The name of the generator algorithm (used for logging only) + @param mood_name The name of the mood symbol/suit (used for logging only) """ + self.algorithm_name = algorithm + self.mood_name = mood_name self.channel = channel @@ -85,6 +89,34 @@ def __init__(self, channel, weekday, cycle, continuity): self.state_dirty = False + def sanitize_sequence(self, sequence=None): + """ + Ensure that the sequence is neither all-1 nor all-0 + + If either is true, flip a random element + + This should be called after generating the sequence in the child class constructors + + @param sequence If none, self.sequence is sanitized + """ + if sequence is None: + sequence = self.sequence + + empty = True + full = True + for n in sequence: + if n == 0: + full = False + else: + empty = False + + # flip an item if the pattern is wholly uniform + if empty or full: + sequence[random.randint(0, len(sequence) -1)] = (sequence[random.randint(0, len(sequence) -1)] + 1) % 2 + + def __str__(self): + return f"{self.sequence}" + @staticmethod def map(x, in_min, in_max, out_min, out_max): # treat the output as inclusive @@ -136,7 +168,7 @@ class AlgoPlain(Algo): mood_graphics = bytearray(b'\x00\x00\x00\x1f\x00\x00\x00!\x00\x00\x00A\x00\x00\x00\x81\x00\x00\x01\x01\x00\x00\x02\x02\x00\x00\x04\x04\x00\x00\x08\x08\x00\x00\x10\x10\x00\x00 \x00\x00@@\x00\x00\x80\x80\x00\x01\x01\x00\x00\x02\x02\x00\x04\x04\x04\x00\x04\x08\x08\x00\x06\x10\x10\x00\x07 \x00\x03\xc0@\x00\x01\xc0\x80\x00\x00\xe1\x00\x00\x00r\x00\x00\x00\xfc\x00\x00\x01\xdc\x00\x00\x03\x8e\x00\x00\x07\x07\x00\x00~\x03\xc0\x00|\x00\x00\x00|\x00\x00\x00|\x00\x00\x00|\x00\x00\x00\x00\x00\x00\x00') def __init__(self, channel, weekday, cycle, continuity): - super().__init__(channel, weekday, cycle, continuity) + super().__init__(channel, weekday, cycle, continuity, "plain", "swords") seqmax = 0 @@ -167,6 +199,8 @@ def __init__(self, channel, weekday, cycle, continuity): for i in range(seqmax): self.sequence.append(random.randint(0, 1)) + self.sanitize_sequence() + class AlgoReich(Algo): """ @@ -180,7 +214,7 @@ class AlgoReich(Algo): mood_graphics = bytearray(b'\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x7f\xff\xff\xfe\x7f\xff\xff\xfe\x7f\xff\xff\xfe?\xff\xff\xfc?\xff\xff\xfc\x1f\xff\xff\xf8\x0f\xff\xff\xf0\x07\xff\xff\xe0\x03\xff\xff\xc0\x01\xff\xff\x80\x00\x7f\xfe\x00\x00\x0f\xf0\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x0f\xf0\x00\x00\x7f\xfe\x00\x01\xff\xff\x80\x03\xff\xff\xc0') def __init__(self, channel, weekday, cycle, continuity): - super().__init__(channel, weekday, cycle, continuity) + super().__init__(channel, weekday, cycle, continuity, "reich", "cups") if cycle == MoonPhase.NEW_MOON: seqmax = random.randint(3, 5) @@ -209,12 +243,7 @@ def __init__(self, channel, weekday, cycle, continuity): else: self.sequence.append(0) - empty = True - for i in range(len(self.sequence)): - if self.sequence[i] == 1: - empty = False - if empty: - self.sequence[random.randint(0, len(self.sequence)-1)] = 1 + self.sanitize_sequence() class AlgoSparse(Algo): @@ -229,7 +258,7 @@ class AlgoSparse(Algo): mood_graphics = bytearray(b'\xff\xff\xff\xff\x9f\xff\xff\xff\x8f\xff\xe0?\x87\xff\xf0\x7f\x83\xff\xb8\xef\xc1\xff\x98\xcf\xe0\xff\x80\x0f\xf0\x7f\x80\x0f\xf8?\x80\x0f\xfc\x1f\x98\xcf\xfe\x0f\xb8\xef\xff\x07\xf0\x7f\xff\x83\xe0?\xff\xc1\xff\xff\xff\xe0\xff\xff\xff\xf0\x7f\xff\xff\xf8?\xff\x7f\xfc\x1f\xfe?\xfe\x0f\xfc\x1f\xff\x07\xf8\x0f\xff\x83\xf0\x07\xff\xc1\xe0\x03\xff\xe0\xc0\x01\xff\xf0\x80\x00\xff\xf9\x00\x00\x7f\xfe\x00\x00?\xfc\x00\x00\x1f\xf8\x00\x00\x0f\xf0\x00\x00\x07\xe0\x00\x00\x03\xc0\x00\x00\x01\x80\x00') def __init__(self, channel, weekday, cycle, continuity): - super().__init__(channel, weekday, cycle, continuity) + super().__init__(channel, weekday, cycle, continuity, "sparse", "shields") if cycle == MoonPhase.NEW_MOON: seqmax = random.randint(10, 19) @@ -264,6 +293,8 @@ def __init__(self, channel, weekday, cycle, continuity): if random.randint(0, 99) < densityPercent: self.sequence[i] = 1 + self.sanitize_sequence() + class AlgoVari(Algo): """ @@ -279,7 +310,7 @@ class AlgoVari(Algo): mood_graphics = bytearray(b'\x00\x07\xe0\x00\x009\x9c\x00\x00\xc1\x83\x00\x01\x01\x80\x80\x02\x02@@\x04\x02@ \x08\x02@\x10\x10\x04 \x08 \x04 \x04 \x04 \x04@\x08\x10\x02\x7f\xff\xff\xfeP\x08\x10\n\x88\x10\x08\x11\x84\x10\x08!\x83\x10\x08\xc1\x80\xa0\x05\x01\x80`\x06\x01\x800\x0c\x01@H\x12\x02@Fb\x02@A\x82\x02 \x81\x81\x04 \x86a\x04\x10\x88\x11\x08\t0\x0c\x90\x05@\x02\xa0\x03\x80\x01\xc0\x01\x00\x00\x80\x00\xc0\x03\x00\x008\x1c\x00\x00\x07\xe0\x00') def __init__(self, channel, weekday, cycle, continuity): - super().__init__(channel, weekday, cycle, continuity) + super().__init__(channel, weekday, cycle, continuity, "vari", "pentacles") if cycle == MoonPhase.NEW_MOON: seqmax = random.randint(3, 19) @@ -322,6 +353,8 @@ def __init__(self, channel, weekday, cycle, continuity): for n in seq_b: self.sequence.append(n) + self.sanitize_sequence() + class AlgoBlocks(Algo): """ @@ -345,7 +378,7 @@ class AlgoBlocks(Algo): ] def __init__(self, channel, weekday, cycle, continuity): - super().__init__(channel, weekday, cycle, continuity) + super().__init__(channel, weekday, cycle, continuity, "blocks", "hearts") # This is wholly original # the original, deprecated code has a to-do here @@ -366,6 +399,8 @@ def __init__(self, channel, weekday, cycle, continuity): for n in block: self.sequence.append(n) + self.sanitize_sequence() + class AlgoCulture(Algo): """ @@ -393,7 +428,7 @@ class AlgoCulture(Algo): ] def __init__(self, channel, weekday, cycle, continuity): - super().__init__(channel, weekday, cycle, continuity) + super().__init__(channel, weekday, cycle, continuity, "culture", "spades") for i in range(32): self.sequence.append(AlgoCulture.rhythms[weekday][i]) @@ -404,6 +439,8 @@ def __init__(self, channel, weekday, cycle, continuity): for i in range(extra_steps): self.sequence.append(random.randint(0, 1)) + self.sanitize_sequence() + class AlgoOver(Algo): """ @@ -417,7 +454,7 @@ class AlgoOver(Algo): mood_graphics = bytearray(b'\x00\x01\x80\x00\x00\x01\x80\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x07\xe0\x00\x00\x07\xe0\x00\x00\x0f\xf0\x00\x00\x0f\xf0\x00\x00\x1f\xf8\x00\x00\x1f\xf8\x00\x00?\xfc\x00\x00?\xfc\x00\x00\x7f\xfe\x00\x00\xff\xff\x00\x03\xff\xff\xc0\x0f\xff\xff\xf0\x0f\xff\xff\xf0\x03\xff\xff\xc0\x00\xff\xff\x00\x00\x7f\xfe\x00\x00?\xfc\x00\x00?\xfc\x00\x00\x1f\xf8\x00\x00\x1f\xf8\x00\x00\x0f\xf0\x00\x00\x0f\xf0\x00\x00\x07\xe0\x00\x00\x07\xe0\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x01\x80\x00\x00\x01\x80\x00') def __init__(self, channel, weekday, cycle, continuity): - super().__init__(channel, weekday, cycle, continuity) + super().__init__(channel, weekday, cycle, continuity, "over", "diamonds") densityPercent = 50 @@ -456,6 +493,9 @@ def __init__(self, channel, weekday, cycle, continuity): self.switch_index = 0 self.swap = True + self.sanitize_sequence(self.seq1) + self.sanitize_sequence(self.seq2) + for n in self.seq1: self.sequence.append(n) @@ -492,7 +532,7 @@ class AlgoWonk(Algo): mood_graphics = bytearray(b'\x00\x07\xe0\x00\x00\x0f\xf0\x00\x00\x1f\xf8\x00\x00?\xfc\x00\x00\x7f\xfe\x00\x00\x7f\xfe\x00\x00\x7f\xfe\x00\x00\x7f\xfe\x00\x00\x7f\xfe\x00\x00\x7f\xfe\x00\x0f\xff\xff\xf0\x1f\xff\xff\xf8?\xff\xff\xfc\x7f\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x7f\xfb\xdf\xfe?\xf3\xcf\xfc\x1f\xe3\xc7\xf8\x0f\xc3\xc3\xf0\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x03\xc0\x00\x00\x07\xe0\x00\x00\x0f\xf0\x00\x00\x1f\xf8\x00\x00?\xfc\x00\x00\x7f\xfe\x00') def __init__(self, channel, weekday, cycle, continuity): - super().__init__(channel, weekday, cycle, continuity) + super().__init__(channel, weekday, cycle, continuity, "wonk", "clubs") densityPercent = Algo.map(weekday, 1, 7, 30, 60) @@ -530,6 +570,8 @@ def __init__(self, channel, weekday, cycle, continuity): else: self.sequence[chosen_step - 1] = 1 + self.sanitize_sequence() + class MoonPhase: """ @@ -799,16 +841,32 @@ def generate_sequences(self, now): if now.weekday is None: now.weekday = 0 + local_weekday = (now + local_timezone).weekday + continuity = random.randint(0, 99) cycle = MoonPhase.calculate_phase(now) today_seed = now.day + now.month + now.year + self.seed_offset random.seed(today_seed) - self.sequence_a = Mood.mood_algorithm(now)(Algo.CHANNEL_A, now.weekday, cycle, continuity) - self.sequence_b = Mood.mood_algorithm(now)(Algo.CHANNEL_B, now.weekday, cycle, continuity) + self.sequence_a = Mood.mood_algorithm(now)(Algo.CHANNEL_A, local_weekday, cycle, continuity) + self.sequence_b = Mood.mood_algorithm(now)(Algo.CHANNEL_B, local_weekday, cycle, continuity) self.last_generation_at = clock.localnow() + # print a YAML-like block with the sequences for debugging + print(f"# Generated sequences at {self.last_generation_at} ({local_timezone})") + print("parameters:") + print(f" continuity: {continuity}") + print(f" date: '{now} UTC'") + print(f" moon_phase: {cycle}") + print(f" today_seed: {today_seed}") + print(f" weekday: {local_weekday}") + print("sequences:") + print(f" algorithm: {self.sequence_a.algorithm_name}") + print(f" mood: {self.sequence_a.mood_name}") + print(f" seq_a: {self.sequence_a}") + print(f" seq_b: {self.sequence_b}") + def on_channel_a_trigger(self): self.sequence_a.tick() From 8060650f43f1f7312227fd44f3a1bbcaae1b7a3d Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sun, 12 Jan 2025 01:43:18 -0500 Subject: [PATCH 41/45] Change the length of Cultures based on the moon phase to reduce instances of synchronization between them --- software/contrib/pet_rock.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/software/contrib/pet_rock.py b/software/contrib/pet_rock.py index fb13b6bc1..570a4789d 100644 --- a/software/contrib/pet_rock.py +++ b/software/contrib/pet_rock.py @@ -430,14 +430,23 @@ class AlgoCulture(Algo): def __init__(self, channel, weekday, cycle, continuity): super().__init__(channel, weekday, cycle, continuity, "culture", "spades") - for i in range(32): - self.sequence.append(AlgoCulture.rhythms[weekday][i]) + # add or remove some steps based on the moon phase + if cycle == MoonPhase.NEW_MOON: + extra_steps = random.randint(-16, -8) + elif cycle == MoonPhase.WAXING_CRESCENT or cycle == MoonPhase.WANING_CRESCENT: + extra_steps = random.randint(0, 4) + elif cycle == MoonPhase.FIRST_QUARTER or cycle == MoonPhase.THIRD_QUARTER: + extra_steps = random.randint(-8, 8) + elif cycle == MoonPhase.WAXING_GIBBOUS or cycle == MoonPhase.WANING_GIBBOUS: + extra_steps = random.randint(-4, 12) + else: + extra_steps = random.randint(-4, 4) - # most of the time we won't add any additional steps, but sometimes - # we will add 1-7 more - extra_steps = random.randint(-8, 7) - for i in range(extra_steps): - self.sequence.append(random.randint(0, 1)) + for i in range(32 + extra_steps): + if i < len(AlgoCulture.rhythms[weekday]): + self.sequence.append(AlgoCulture.rhythms[weekday][i]) + else: + self.sequence.append(random.randint(0, 1)) self.sanitize_sequence() From ecacf3742e86e8bc307e9f330e86705a46fc4a12 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sun, 12 Jan 2025 01:54:23 -0500 Subject: [PATCH 42/45] Choose different culture rhythms for channels A and B, so even if they're the same negative length adjustment they won't be wholly synchronized --- software/contrib/pet_rock.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/software/contrib/pet_rock.py b/software/contrib/pet_rock.py index 570a4789d..77cc4f8bd 100644 --- a/software/contrib/pet_rock.py +++ b/software/contrib/pet_rock.py @@ -442,9 +442,10 @@ def __init__(self, channel, weekday, cycle, continuity): else: extra_steps = random.randint(-4, 4) + rhythm = AlgoCulture.rhythms[(weekday + self.channel) % len(AlgoCulture.rhythms)] for i in range(32 + extra_steps): - if i < len(AlgoCulture.rhythms[weekday]): - self.sequence.append(AlgoCulture.rhythms[weekday][i]) + if i < len(rhythm): + self.sequence.append(rhythm[i]) else: self.sequence.append(random.randint(0, 1)) From 8773ce1eb0a85bc8d2fece2826dc648ae54658d1 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sun, 12 Jan 2025 02:07:20 -0500 Subject: [PATCH 43/45] Choose the same rhythm, but backfill the second half (+/- extra steps) with randomness --- software/contrib/pet_rock.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/software/contrib/pet_rock.py b/software/contrib/pet_rock.py index 77cc4f8bd..9815c2b77 100644 --- a/software/contrib/pet_rock.py +++ b/software/contrib/pet_rock.py @@ -442,12 +442,16 @@ def __init__(self, channel, weekday, cycle, continuity): else: extra_steps = random.randint(-4, 4) - rhythm = AlgoCulture.rhythms[(weekday + self.channel) % len(AlgoCulture.rhythms)] - for i in range(32 + extra_steps): + rhythm = AlgoCulture.rhythms[weekday] + for i in range(len(rhythm) + extra_steps): if i < len(rhythm): self.sequence.append(rhythm[i]) else: - self.sequence.append(random.randint(0, 1)) + self.sequence.append(0) + + # backfill the second half-ish of the rhythm with randomness + for i in range(15, len(self.sequence)): + self.sequence[i] = random.randint(0, 1) self.sanitize_sequence() From 3b64f9ced69ae1fd2c4ca39bfd1ba17ea70e6146 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sun, 12 Jan 2025 13:34:24 -0500 Subject: [PATCH 44/45] Fix an out-of-bounds error with daily_random --- software/contrib/daily_random.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/software/contrib/daily_random.py b/software/contrib/daily_random.py index e75c13d75..8f1a7a73a 100644 --- a/software/contrib/daily_random.py +++ b/software/contrib/daily_random.py @@ -102,7 +102,7 @@ def regenerate_sequences(self): hour = datetime.hour minute = datetime.minute second = datetime.second if datetime.second is not None else 0 - weekday = datetime.weekday if datetime.weekday is not None else 0 + weekday = datetime.weekday if datetime.weekday is not None else 1 # bit-shift the fields around to reduce collisions # mask: 12 bits @@ -112,9 +112,9 @@ def regenerate_sequences(self): # hour: 6 bits # minute: 6 bits seeds = [ - self.BITMASKS[weekday] ^ year ^ (month << 7) ^ day, - self.BITMASKS[weekday] ^ year ^ (month << 6) ^ day ^ ~hour, - self.BITMASKS[weekday] ^ year ^ (month << 7) ^ day ^ (hour << 6) ^ minute, + self.BITMASKS[weekday - 1] ^ year ^ (month << 7) ^ day, + self.BITMASKS[weekday - 1] ^ year ^ (month << 6) ^ day ^ ~hour, + self.BITMASKS[weekday - 1] ^ year ^ (month << 7) ^ day ^ (hour << 6) ^ minute, ] for i in range(len(self.sequences)): From 8414bcd06e5c4730d3f59e5b61f66793876913c1 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sun, 12 Jan 2025 14:33:31 -0500 Subject: [PATCH 45/45] Add some additional test logging to make sure we have distinct patterns and only 1 special phase per cycle. Fix a bug where we accidentally swapped waning for waxing in the phase calculations --- software/contrib/pet_rock.py | 76 +++++++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 6 deletions(-) diff --git a/software/contrib/pet_rock.py b/software/contrib/pet_rock.py index 9815c2b77..962492d1e 100644 --- a/software/contrib/pet_rock.py +++ b/software/contrib/pet_rock.py @@ -117,6 +117,17 @@ def sanitize_sequence(self, sequence=None): def __str__(self): return f"{self.sequence}" + def __eq__(self, other): + """ + Return True if both sequences are identical + """ + if len(self.sequence) == len(other.sequence): + for i in range(len(self.sequence)): + if self.sequence[i] != other.sequence[i]: + return False + return True + return False + @staticmethod def map(x, in_min, in_max, out_min, out_max): # treat the output as inclusive @@ -660,19 +671,41 @@ def calculate_phase(date): # so use yesterday, today, and tomorrow as a 3-day window # if tomorrow is on one side of the curve and yesterday was the other, treat today # as the "special" phase - yesterday_fraction = yesterday_new_moons % 1 today_fraction = today_new_moons % 1 tomorrow_fraction = tomorrow_new_moons % 1 + # first cases are for the special 1-day events + # check if we're in the transition area, and then decide if today is actually closest + # to the event if yesterday_fraction > 0.75 and tomorrow_fraction < 0.25: - return MoonPhase.NEW_MOON + if MoonPhase.closest_to_fraction(0.0, yesterday_fraction, today_fraction, tomorrow_fraction) == 0: + return MoonPhase.NEW_MOON + elif today_fraction > 0.75: + return MoonPhase.WANING_CRESCENT + else: + return MoonPhase.WAXING_CRESCENT elif yesterday_fraction < 0.25 and tomorrow_fraction > 0.25: - return MoonPhase.FIRST_QUARTER + if MoonPhase.closest_to_fraction(0.25, yesterday_fraction, today_fraction, tomorrow_fraction) == 0: + return MoonPhase.FIRST_QUARTER + elif today_fraction < 0.25: + return MoonPhase.WAXING_CRESCENT + else: + return MoonPhase.WAXING_GIBBOUS elif yesterday_fraction < 0.5 and tomorrow_fraction > 0.5: - return MoonPhase.FULL_MOON + if MoonPhase.closest_to_fraction(0.5, yesterday_fraction, today_fraction, tomorrow_fraction) == 0: + return MoonPhase.FULL_MOON + elif today_fraction < 0.5: + return MoonPhase.WAXING_GIBBOUS + else: + return MoonPhase.WANING_GIBBOUS elif yesterday_fraction < 0.75 and tomorrow_fraction > 0.75: - return MoonPhase.THIRD_QUARTER + if MoonPhase.closest_to_fraction(0.75, yesterday_fraction, today_fraction, tomorrow_fraction) == 0: + return MoonPhase.THIRD_QUARTER + elif today_fraction < 0.75: + return MoonPhase.WANING_GIBBOUS + else: + return MoonPhase.WANING_CRESCENT elif today_fraction == 0.0: return MoonPhase.NEW_MOON elif today_fraction < 0.25: @@ -690,6 +723,19 @@ def calculate_phase(date): else: return MoonPhase.WANING_CRESCENT + @staticmethod + def closest_to_fraction(fraction, yesterday, today, tomorrow): + yesterday = abs(fraction - yesterday) + today = abs(fraction - today) + tomorrow = abs(fraction - tomorrow) + + if yesterday < tomorrow and yesterday < today: # yesterday is closest + return -1 + elif today < yesterday and today < tomorrow: # today is closest + return 0 + else: + return 1 # tomorrow is closest + class Mood: """ @@ -917,6 +963,8 @@ def run_test(self): fake_date = clock.utcnow() + + yesterday_moon_phase = -1 while True: self.din2.update() @@ -937,7 +985,6 @@ def run_test(self): fake_date.weekday = fake_date.weekday + 1 if fake_date.weekday == 8: fake_date.weekday = 1 - if fake_date.day > fake_date.days_in_month: fake_date.day = 1 fake_date.month = fake_date.month + 1 @@ -950,6 +997,23 @@ def run_test(self): self.sequence_a.state_dirty = True self.sequence_b.state_dirty = True + # check that we don't have two consecutive special-phase days + today_moon_phase = MoonPhase.calculate_phase(fake_date) + if ( + today_moon_phase == MoonPhase.NEW_MOON or + today_moon_phase == MoonPhase.FIRST_QUARTER or + today_moon_phase == MoonPhase.FULL_MOON or + today_moon_phase == MoonPhase.THIRD_QUARTER + ): + if today_moon_phase == yesterday_moon_phase: + print(f"WARNING: two consecutive {today_moon_phase}-phases!") + + # check that the two sequences are different + if self.sequence_a == self.sequence_b: + print("WARNING: identical sequences generated!") + + + self.draw(fake_date) last_draw_at = local_time