# Copyright 2024 Allen Synthesis
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
A clone of Pet Rock by Jonah Senzel.
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:
- shields (wands/clubs is the official tarot suit here, but clubs goes with the
english card suits below, and shields fit with swords, and are found in some
swiss-german decks of cards) (yellow)
An additional 4 moods were included in the original C++ firmware, but were
deprecated. They are also re-implemented here, but disabled by default:
from europi_script import EuroPiScript
from framebuf import FrameBuffer, MONO_HLSB
from experimental.a_to_d import AnalogReaderDigitalWrapper
from experimental.experimental_config import RTC_NONE
from experimental.math_extras import rescale
from experimental.random_extras import shuffle
from experimental.rtc import *
Generic algorithm for generating the gate sequences
All pseudo-random algorithms inherit from this class
def __init__(self, channel, weekday, cycle, continuity, algorithm, mood_name):
Child constructors must call this first
Child constructors must initialize self.sequence by appending {0, 1} values to it
@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: 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
if channel == self.CHANNEL_A:
self.continuity = continuity
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
# flip an item if the pattern is wholly uniform
sequence[random.randint(0, len(sequence) -1)] = (sequence[random.randint(0, len(sequence) -1)] + 1) % 2
return f"{self.sequence}"
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]:
def map(x, in_min, in_max, out_min, out_max):
# treat the output as inclusive
return int((x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min)
self.index = (self.index + 1 ) % len(self.sequence)
Set the outputs high/low as needed
if self.sequence[self.index]:
if self.index == len(self.sequence) - 1:
A straight-forward random-fill
We choose a length based on the moon phase and then fill it with on/off signals
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
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, "plain", "swords")
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:
elif cycle == MoonPhase.WANING_GIBBOUS:
# Randomly populate rhythm
# this may seems weird/cheesy, but as I note in the manual - I decided to focus on
# one and only one translated elements for the moon cycle, which is the length
# relationship of A and B - I found through practice that the random population of
# steps actually produces great results, the key is how many steps you use, and the
# relationship between the 2 step lengths. The interesting difference is comparing
# for example a pair of 8 step rhythms, vs a 7 step rhythm, and a 15 step rhythm
# being played against each other - this is the "meta movement" of the rhythmic
# flavor, in every algo/mood
self.sequence.append(random.randint(0, 1))
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
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, "reich", "cups")
if cycle == MoonPhase.NEW_MOON:
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)
a = Algo.map(continuity, 0, 100, 3, 8)
while b == 0 or b == a or b == a*2 or b*2 == a:
elif cycle == MoonPhase.FIRST_QUARTER or cycle == MoonPhase.THIRD_QUARTER:
seqmax = Algo.map(continuity, 0, 100, 5, 9)
seqmax = seqmax * channel # B is double A
elif cycle == MoonPhase.WAXING_GIBBOUS or cycle == MoonPhase.WANING_GIBBOUS:
seqmax = Algo.map(continuity, 0, 100, 4, 8)
if random.randint(0, 99) < seqDensity:
Chooses a fixed length based on moon phase and fills the pattern with a low-density
This corresponds to the yellow mood in the original firmware
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, "sparse", "shields")
if cycle == MoonPhase.NEW_MOON:
seqmax = random.randint(10, 19)
elif cycle == MoonPhase.WAXING_CRESCENT or cycle == MoonPhase.WANING_CRESCENT:
seqmax = random.randint(15, 30)
elif cycle == MoonPhase.FIRST_QUARTER:
if channel == Algo.CHANNEL_A:
elif cycle == MoonPhase.THIRD_QUARTER:
if channel == Algo.CHANNEL_A:
elif cycle == MoonPhase.WAXING_GIBBOUS:
elif cycle == MoonPhase.WANING_GIBBOUS:
seedStepInd = random.randint(0, seqmax - 1)
self.sequence[seedStepInd] = 1
if random.randint(0, 99) < densityPercent:
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
This corresponds to the green mood in the original firmware
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, "vari", "pentacles")
if cycle == MoonPhase.NEW_MOON:
seqmax = random.randint(3, 19)
elif cycle == MoonPhase.WAXING_CRESCENT or cycle == MoonPhase.WANING_CRESCENT:
seqmax = random.randint(8, 12)
repeats = random.randint(3, 6)
elif cycle == MoonPhase.FIRST_QUARTER or cycle == MoonPhase.THIRD_QUARTER:
if channel == Algo.CHANNEL_A:
elif cycle == MoonPhase.WAXING_GIBBOUS or cycle == MoonPhase.WANING_GIBBOUS:
for i in range(seqmax-1, -1, -1):
# the whole sequence is r * [seq_a] + r * [seq_b]
Builds the sequence by randomly choosing N pre-defined pattern blocks
One of the unimplemented algorithms in the original firmware
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')
def __init__(self, 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
if cycle == MoonPhase.NEW_MOON:
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)
for i in range(numblocks):
block = AlgoBlocks.blocks[random.randint(0, len(AlgoBlocks.blocks) - 1)]
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
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')
[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, "culture", "spades")
# 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)
extra_steps = random.randint(-4, 4)
rhythm = AlgoCulture.rhythms[weekday]
for i in range(len(rhythm) + extra_steps):
self.sequence.append(rhythm[i])
# 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)
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
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, "over", "diamonds")
# 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:
elif cycle == MoonPhase.WANING_GIBBOUS:
if random.randint(0, 99) < densityPercent:
if random.randint(0, 99) < densityPercent:
self.swaps = list(range(seqmax))
self.sanitize_sequence(self.seq1)
self.sanitize_sequence(self.seq2)
# overwrite steps betwen seq1 and seq2
overwrite_index = self.swaps[self.switch_index]
if self.switch_index > len(self.sequence) - 1:
self.swap = not self.swap
self.sequence[overwrite_index] = self.seq2[overwrite_index]
self.sequence[overwrite_index] = self.seq1[overwrite_index]
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
One of the unimplemented algorithms in the original firmware
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, "wonk", "clubs")
densityPercent = Algo.map(weekday, 1, 7, 30, 60)
# ensure there is at least 1 filled step
self.sequence[random.randint(0, seqmax - 1)] = 1
for i in range(0, seqmax, 4):
if random.randint(0, 99) < densityPercent:
steps_to_wonk = Algo.map(cycle, 0, 7, 1, steps_placed)
while steps_wonked < steps_to_wonk:
chosen_step = random.randint(0, seqmax - 1)
if self.sequence[chosen_step] == 1:
self.sequence[chosen_step] = 0
self.sequence[chosen_step + 1] = 1
elif chosen_step == seqmax - 1:
self.sequence[chosen_step - 1] = 1
self.sequence[chosen_step + 1] = 1
self.sequence[chosen_step - 1] = 1
Calculates the current moon phase
bytearray(b'\x00\x07\xc0\x00\x000\x0c\x00\x00\x80\x01\x00\x03\x00\x00\xc0\x04\x00\x00 \x08\x00\x00\x10\x00\x00\x00\x00\x10\x00\x00\x08 \x00\x00\x04\x00\x00\x00\x00@\x00\x00\x02@\x00\x00\x02\x00\x00\x00\x00\x80\x00\x00\x01\x80\x00\x00\x01\x80\x00\x00\x01\x80\x00\x00\x01\x80\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00@\x00\x00\x02@\x00\x00\x02\x00\x00\x00\x00 \x00\x00\x04\x10\x00\x00\x08\x10\x00\x00\x08\x08\x00\x00\x10\x04\x00\x00 \x01\x00\x00\x80\x00\x80\x01\x00\x000\x0c\x00\x00\x07\xc0\x00'),
bytearray(b'\x00\x0b\xe0\x00\x000\xfe\x00\x00\x80\x1f\x00\x01\x00\x0f\xc0\x04\x00\x03\xe0\x00\x00\x01\xf0\x00\x00\x00\xf8\x10\x00\x00\xf8 \x00\x00|\x00\x00\x00>@\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'),
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
raise ValueError(f"Date out of range; check your RTC")
if m == Month.JANUARY or m == Month.FEBRUARY:
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
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
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
if yesterday_fraction > 0.75 and tomorrow_fraction < 0.25:
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
return MoonPhase.WAXING_CRESCENT
elif yesterday_fraction < 0.25 and tomorrow_fraction > 0.25:
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
return MoonPhase.WAXING_GIBBOUS
elif yesterday_fraction < 0.5 and tomorrow_fraction > 0.5:
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
return MoonPhase.WANING_GIBBOUS
elif yesterday_fraction < 0.75 and tomorrow_fraction > 0.75:
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
return MoonPhase.WANING_CRESCENT
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
return MoonPhase.WANING_CRESCENT
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
elif today < yesterday and today < tomorrow: # today is closest
return 1 # tomorrow is closest
Mood is one of 4 colours, which rotates every moon cycle
def set_moods(moodstring):
elif moodstring == "alternate":
def mood_algorithm(date):
Get the algorithm for 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 Mood.available_moods[cycles % len(Mood.available_moods)]
Uses ticks_ms and ticks_diff to fire a callback at fixed-ish intervals
def __init__(self, speed_knob, rise_cb=lambda: None, fall_cb=lambda: None):
self.last_tick_at = time.ticks_ms()
self.speed_knob = speed_knob
self.rise_callback = rise_cb
self.fall_callback = fall_cb
def update_interval(self):
p = self.speed_knob.percent()
# disable the timer for the first 10% of travel so we have an easy-off
p = 1.0 - rescale(p, DEADZONE, 1, 0, 1)
self.interval_ms = round(rescale(p, 0, 1, self.MIN_INTERVAL, self.MAX_INTERVAL))
# kick out immediately if the timer is off
if self.interval_ms <= 0:
if time.ticks_diff(now, self.last_tick_at) >= self.interval_ms:
class NoClockError(Exception):
A custom exception we can raise if the RTC is not configured correctly
def __init__(self, message):
super().__init__(message)
class PetRock(EuroPiScript):
# validate the clock and raise a NoClockError if
# we are using the default no-RTC implementation
if experimental_config.RTC_IMPLEMENTATION == RTC_NONE:
raise NoClockError("No clock configured")
Mood.set_moods(self.config.MOODS)
self.generate_sequences(clock.utcnow())
self.din2 = AnalogReaderDigitalWrapper(
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)
self.timer_b = IntervalTimer(
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(
rise_cb=self.on_channel_a_trigger,
fall_cb=self.on_channel_a_fall
# 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
def generate_sequences(self, now):
Regenerate the day's rhythms
@param now The current UTC
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
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(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(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):
def on_channel_a_fall(self):
self.sequence_a.outputs_off()
def on_channel_b_trigger(self):
def on_channel_b_fall(self):
self.sequence_b.outputs_off()
def draw(self, utc_time):
local_time = utc_time + local_timezone
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(utc_time)
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, 40, 0)
self.draw(clock.utcnow())
last_draw_at = clock.utcnow()
fake_date = clock.utcnow()
yesterday_moon_phase = -1
self.timer_a.update_interval()
self.timer_b.update_interval()
local_time = clock.utcnow()
ui_dirty = local_time.minute != last_draw_at.minute
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:
if fake_date.day > fake_date.days_in_month:
fake_date.month = fake_date.month + 1
if fake_date.month == 13:
self.generate_sequences(fake_date)
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)
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!")
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()
self.draw(clock.utcnow())
last_draw_at = clock.localnow()
self.timer_a.update_interval()
self.timer_b.update_interval()
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(clock.utcnow())
self.sequence_a.state_dirty = True
self.sequence_b.state_dirty = True
if self.sequence_a.state_dirty:
self.sequence_a.set_outputs()
if self.sequence_b.state_dirty:
self.sequence_b.set_outputs()
self.draw(clock.utcnow())
last_draw_at = local_time
if __name__ == "__main__":