The code for this project is split into 3 main parts. Two of them are shared across all of the different supported devices, and the last part is more specific to the hardware used.
General components used by all versions
-
AnchoredTilegrid- This library provides a small wrapper class that extends TileGrid and provides the ability to place theTileGridrelative to points other than it's top left corner. This is useful because it allows us to place the planchette relative to the center of the window and not have to worry about the offset from the top left. See the documentation and library repository for more information and basic example usage of this class. -
SpiritBoard- This class holds everything needed to display the spirit board and planchette as well as slide the planchette around to write messages.
Hardware specific code.py and media
The display setup (or lack thereof), the different possible touch overlays, and the different methods used to connect to WiFi mean that this project has a few variations of the code.py file in order to support the different possible bits of hardware.
- PyPortal Titano - Built-in display with generic touch driver, esp32spi wifi
- PyPortal - Built-in display w/ smaller size and generic touch driver, esp32spi wifi
- Feather ESP32-S3 or S2 + TFT Featherwing - External display with tsc2007 touch driver, core wifi
The two different supported display sizes are 480x320 and 320x240. There are spirit board and planchette image files provided in the respective project bundles for each of those display sizes.
SpiritBoard Class
This class contains most of the shared code that all versions use. It is responsible for all of the graphics in this project. It extends the displayio.Group class, so that it can hold other displayio elements and can be set as the root_group on the display.
When initialized, it loads and displays the spirit board image in the background, and the planchette image in the foreground. It checks the height and width of the display object passed to the initialization to determine which assets should be used. If it finds the smaller of the two supported screen sizes it will also scale down the letter positions to fit the smaller sized board.
Write Message
The write_message() function is the highest level function, and is one of the functions called by the code.py files. It accepts a string message and will slide the planchette around to output the message. The logic in the function is responsible for breaking the message into its different words, then determining whether each word is one of the full words on the board, or needs to be output letter by letter. The movement of the planchette is delegated to the another of the primary functions for this class.
Slide Planchette
The slide_planchette() function handles moving the planchette from one location on the display to another. In order to do this it finds equally spaced points along the line between the points representing where we are moving from and moving to. The target location tuple is a required argument. There are optional arguments delay and step_size which work together to control how fast the planchette will slide. Delay is the time it will wait between steps, and step_size is how big of a step it will take with each movement.
Get Messages
get_messages() is responsible for getting the messages which will be displayed by the board. It uses two other functions descriptively named after the purpose they serve. It will first use sync_with_io() to attempt to fetch the messages from a "SpiritBoard" feed on AdafruitIO using the username and token stored in the settings.toml file. If that does not succeed then it will use read_local_messages_file() to fallback to looking for messages inside of a local file named spirit_messages.txt. See the Loading Messages page of this guide for instructions on how to load these messages.
Find the SpiritBoard class code file is embedded below. It is thoroughly commented with details explaining what each portion does.
# SPDX-FileCopyrightText: 2024 Tim Cocks
#
# SPDX-License-Identifier: MIT
"""
SpiritBoard helper class
"""
import math
import os
import random
import time
import displayio
# pylint: disable=import-error
from adafruit_anchored_tilegrid import AnchoredTileGrid
from adafruit_io.adafruit_io_errors import AdafruitIO_RequestError
class SpiritBoard(displayio.Group):
"""
DisplayIO Based SpiritBoard
Holds and manages everything needed to draw the spirit board and planchette, as well
as move the planchette around to output messages from the spirits.
"""
# Mapping of letters and words on the board to their pixel coordinates.
# Points are centered on the target letter.
# Words can contain a list of points, the planchette will move between them.
LOCATIONS = {"a": (42, 145), "b": (63, 115), "c": (97, 97), "d": (133, 85),
"e": (172, 78), "f": (207, 75), "g": (245, 74), "h": (284, 75),
"i": (319, 80), "j": (345, 85), "k": (375, 95), "l": (410, 111),
"m": (435, 140), "n": (78, 190), "o": (96, 162), "p": (122, 145),
"q": (149, 132), "r": (179, 123), "s": (208, 118), "t": (235, 116),
"u": (267, 116), "v": (302, 119), "w": (334, 130), "x": (368, 147),
"y": (393, 168), "z": (405, 194),
" ": (151, 201), "<3": (247, 20), "?": (162, 18), "&": (339, 18),
"home": (234, 246), "yes": [(26, 20), (82, 20)], "no": [(418, 20), (450, 20)],
"hello": [(20, 300), (123, 300)], "goodbye": [(314, 300), (456, 300)]}
# List of full words on the board (multi-character strings)
# used to know whether to parse the message
# one letter at a time, or with a full word.
FULL_WORDS = ["yes", "no", "hello", "goodbye", "home", "<3"]
def __init__(self, display):
"""
Create a SpiritBoard instance and put it in the displays root_group to make it visible.
:param displayio.AnyDisplay display: Display object to show the spirit board on.
"""
self._display = display
super().__init__()
# board image file
if display.width == 480 and display.height == 320:
self.spirit_board_odb = displayio.OnDiskBitmap("spirit_board_480x320.bmp")
elif display.width == 320 and display.height == 240:
self.spirit_board_odb = displayio.OnDiskBitmap("spirit_board_320x240.bmp")
self._convert_locations_for_small_screen()
self.spirit_board_tilegrid = displayio.TileGrid(
bitmap=self.spirit_board_odb, pixel_shader=self.spirit_board_odb.pixel_shader)
self.append(self.spirit_board_tilegrid)
# planchette image file
if display.width == 480 and display.height == 320:
self.planchette_odb = displayio.OnDiskBitmap("planchette_v1.bmp")
elif display.width == 320 and display.height == 240:
self.planchette_odb = displayio.OnDiskBitmap("planchette_v1_sm.bmp")
self.planchette_odb.pixel_shader.make_transparent(0)
self.planchette_tilegrid = AnchoredTileGrid(
bitmap=self.planchette_odb, pixel_shader=self.planchette_odb.pixel_shader)
# AnchoredTileGrid is used so that we can move the planchette
# relative to the cetner of the window.
self.planchette_tilegrid.anchor_point = (0.5, 0.5)
# move the planchette to it's home to start
self.planchette_tilegrid.anchored_position = SpiritBoard.LOCATIONS['home']
# append the planchette to the self Group instance
self.append(self.planchette_tilegrid)
# set the self Group instance to root_group, so it's shown on the display.
display.root_group = self
def _convert_locations_for_small_screen(self):
_x_ratio = 320/480
_y_ratio = 240/320
# 46x
print(_x_ratio, _y_ratio)
for key, value in self.LOCATIONS.items():
if isinstance(value, tuple):
_x, _y = value
self.LOCATIONS[key] = (int(_x * _x_ratio), int(_y * _y_ratio))
elif isinstance(value, list):
for i in range(len(value)):
_x, _y = value[i]
self.LOCATIONS[key][i] = (int(_x * _x_ratio), int(_y * _y_ratio))
@staticmethod
def dist(point_a, point_b):
"""
Calculate the distance between two points.
:param tuple point_a: x,y pair of the first point
:param point_b: x,y pair of the second point
:return: the distance between the two points
"""
return math.sqrt((point_b[0] - point_a[0]) ** 2 + (point_b[1] - point_a[1]) ** 2)
def slide_planchette(self, target_location, delay=0.1, step_size=4):
"""
Slide the planchette to the target location.
delay and step_size parameters can be used to control the speed of the sliding.
If the planchette is already at the target_location it will jump up slightly and
then return to the target location. This helps to clarify messages that contain
consecutive matching letters.
:param tuple target_location: x,y pair of the target location
:param float delay: length of time to sleep inbetween each movement step
:param int step_size: how big of a step to take with each movement.
:return: None
"""
# disable auto_refresh during sliding, we refresh manually for each step
self._display.auto_refresh = False
# current location
current_location = self.planchette_tilegrid.anchored_position
# get the distance between the two
distance = SpiritBoard.dist(current_location, target_location)
# if the planchette is already at the target location
if distance == 0:
# cannot slide to the location we're already at.
# slide up a tiny bit and then back to where we were.
self.slide_planchette((current_location[0], current_location[1] - 20), delay, step_size)
# update the current location to where we moved to
current_location = self.planchette_tilegrid.anchored_position
distance = SpiritBoard.dist(current_location, target_location)
# variables used to calculate where the next point
# between where we are at and where we are going is.
distance_ratio = step_size / distance
one_minus_distance_ratio = 1 - distance_ratio
# calculate the next point
next_point = (
round(one_minus_distance_ratio * current_location[0]
+ distance_ratio * target_location[0]),
round(one_minus_distance_ratio * current_location[1]
+ distance_ratio * target_location[1])
)
# print(current_location)
# print(next_point)
# update the anchored_position of the planchette to move it to
# the next point.
self.planchette_tilegrid.anchored_position = next_point
# refresh the display
self._display.refresh()
# wait for delay amount of time (seconds)
time.sleep(delay)
# while we haven't made it to the target location
while 0 < distance_ratio < 1:
# update current location variable
current_location = self.planchette_tilegrid.anchored_position
# calculate distance between new current location and target location
distance = SpiritBoard.dist(current_location, target_location)
# if we have arrived at the target location
if distance == 0:
# break out of the function
break
# distance ratio variables used to calculate next point
distance_ratio = step_size / distance
one_minus_distance_ratio = 1 - distance_ratio
# calculate the next point
next_point = (
round(one_minus_distance_ratio * current_location[0]
+ distance_ratio * target_location[0]),
round(one_minus_distance_ratio * current_location[1]
+ distance_ratio * target_location[1])
)
# if we have not arrived at the target location yet
if 0 < distance_ratio < 1:
# update the anchored position to move the planchette to the
# next point
self.planchette_tilegrid.anchored_position = next_point
# refresh the display
self._display.refresh()
# wait for delay amount of time (seconds)
time.sleep(delay)
# update the anchored position to move the planchette to the
# target_location. This is needed in-case we undershot
# the target location due to a step size that does not
# divide into the total distance evenly.
self.planchette_tilegrid.anchored_position = target_location
# refresh the display
self._display.refresh()
# re-enable auto_refresh in case any other parts of the program
# want to update the display
self._display.auto_refresh = True
def write_message(self, message, skip_spaces=True, step_size=6, delay=0.02):
"""
:param string message: The message to output with the planchette
:param skip_spaces: Whether to skip space characters
:param step_size: How big of a step to take with each movement
:param delay: How many seconds to sleep between each movement step
:return: None
"""
# ignore empty messages
if message == "":
return
# split the message on space to get a list of words
message_words = message.split(" ")
# loop over the words in the message
for index, word in enumerate(message_words):
print(f"index: {index}, word: {word}")
# send it to lowercase to get rid of capital letters
word = word.lower()
# if the current word is one of the full words on the board
if word in SpiritBoard.FULL_WORDS:
# if the word on the board has multiple points
if isinstance(SpiritBoard.LOCATIONS[word], list):
# loop over the points for the word
for location in SpiritBoard.LOCATIONS[word]:
print(f"sliding to: {location}")
# slide the planchette to each point
self.slide_planchette(location, delay=delay, step_size=step_size)
# pause at each point
time.sleep(0.25)
# if the word has only a single point
elif isinstance(SpiritBoard.LOCATIONS[word], tuple):
# slide the planchette to the point
self.slide_planchette(SpiritBoard.LOCATIONS[word],
delay=delay, step_size=step_size)
# pause at the point
time.sleep(0.5)
else: # the current word is not one of the full words
# go one character at a time
# loop over each character in the word
for character in word:
# if the character is in our locations mapping
if character in SpiritBoard.LOCATIONS:
# slide the planchette to the current characters location
self.slide_planchette(SpiritBoard.LOCATIONS[character],
delay=delay, step_size=step_size)
# pause after we arrive
time.sleep(0.5)
else:
# character is not in our mapping
print(f"Skipping '{character}', it's not on the board.")
# if we are not skipping spaces, and we are not done with the message
if not skip_spaces and index < len(message_words) - 1:
# handle the space
# slide the planchette to the empty space location.
self.slide_planchette(SpiritBoard.LOCATIONS[" "],
delay=delay, step_size=step_size)
# pause after we arrive
time.sleep(0.5)
# after we've shown the whole message
# slide the planchette back to it's home location
self.slide_planchette(SpiritBoard.LOCATIONS["home"], delay=delay, step_size=step_size)
@staticmethod
def sync_with_io(io) -> list:
"""
Fetch messages from AdafruitIO and store them in the context variable.
You must create the "SpiritBoard" feed object inside AdafruitIO for
this to succeed.
Will raise an exception if connecting or fetching failed.
:param io: The initialized adafruit IO object
:return: List of messages
"""
if io is None:
raise RuntimeError("No connection to AdafruitIO")
# fetch the latest data in the feed
incoming_message = io.receive_data("spiritboard")
# if it's multiple messages seperated by commas
if "," in incoming_message["value"]:
# split on the commas to seperate the messages
# and put them in context
messages = incoming_message["value"].split(",")
else: # it's only a single message
# set the single message into the context
messages = [incoming_message["value"]]
# print if successful
if len(messages) > 0:
print("io fetch success")
return messages
@staticmethod
def read_local_messages_file(shuffle=False) -> list:
"""
Read messages from the local spirit_messages.txt file on the CIRCUITPY drive.
Each message should be on its own line within that file.
:param boolean shuffle: Whether to shuffle the messages. Default is False
which will keep them in the same order they appear in the file.
:return: List of messages
"""
# if the spirit_messages.txt file exists
if "spirit_messages.txt" in os.listdir("/"):
# open the file
with open("/spirit_messages.txt", "r", encoding="utf-8") as f:
# split on newline and set the messages found into the context
messages = f.read().split("\n")
# if there are no messages
if len(messages) == 0:
# raise an error and tell the user to set some up
raise RuntimeError("Connection to adafruit.io failed, and there were "
"no messages in spirit_messages.txt. Enter your WIFI "
"credentials, aio username, and token in settings.toml, or add "
"messages to spirit_messages.txt.")
# if there are messages and we need to shuffle them
if shuffle:
# temporary list to hold them
shuffled_list = []
# while there are still messages in the context messages list
while len(messages) > 0:
# pop a randomly chosen message from the context and
# put it into the temporary list
shuffled_list.append(messages.pop(
random.randint(0, len(messages) - 1)))
# update the context list to the shuffled one
messages = shuffled_list
return messages
@staticmethod
def get_messages(io) -> list:
"""
Higher level get messages function. It will first attempt to
fetch the messages from Adafruit IO. If that doesn't work,
it will read them from the local spirit_messages.txt file.
:param io: The initialized adafruit IO object
:return: List of messages
"""
try:
return SpiritBoard.sync_with_io(io)
except (OSError, RuntimeError, AdafruitIO_RequestError) as e:
print(f"Caught Exception: {type(e)} - {e}.\nWill try again next time.\n"
"Falling back to spirit_messages.txt file.")
return SpiritBoard.read_local_messages_file()
Page last edited January 22, 2025
Text editor powered by tinymce.