diff --git a/TFT_Spirit_Board/esp32s3_s2_tft_featherwing_480x320/anchored_tilegrid.py b/TFT_Spirit_Board/esp32s3_s2_tft_featherwing_480x320/anchored_tilegrid.py deleted file mode 120000 index 138e4f880..000000000 --- a/TFT_Spirit_Board/esp32s3_s2_tft_featherwing_480x320/anchored_tilegrid.py +++ /dev/null @@ -1 +0,0 @@ -../shared/anchored_tilegrid.py \ No newline at end of file diff --git a/TFT_Spirit_Board/esp32s3_s2_tft_featherwing_480x320/code.py b/TFT_Spirit_Board/esp32s3_s2_tft_featherwing_480x320/code.py index b2fccb9ab..dc5c778cd 100644 --- a/TFT_Spirit_Board/esp32s3_s2_tft_featherwing_480x320/code.py +++ b/TFT_Spirit_Board/esp32s3_s2_tft_featherwing_480x320/code.py @@ -42,19 +42,23 @@ i2c = board.I2C() tsc = adafruit_tsc2007.TSC2007(i2c, irq=None) -# Initialize a requests session -pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) -ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) -requests = adafruit_requests.Session(pool, ssl_context) - -# Set your Adafruit IO Username and Key in secrets.py -# (visit io.adafruit.com if you need to create an account, -# or if you need your Adafruit IO key.) -aio_username = os.getenv("AIO_USERNAME") -aio_key = os.getenv("AIO_KEY") - -# Initialize an Adafruit IO HTTP API object -io = IO_HTTP(aio_username, aio_key, requests) +try: + # Initialize a requests session + pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) + ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) + requests = adafruit_requests.Session(pool, ssl_context) + + # Set your Adafruit IO Username and Key in secrets.py + # (visit io.adafruit.com if you need to create an account, + # or if you need your Adafruit IO key.) + aio_username = os.getenv("AIO_USERNAME") + aio_key = os.getenv("AIO_KEY") + + # Initialize an Adafruit IO HTTP API object + io = IO_HTTP(aio_username, aio_key, requests) +except (RuntimeError, TypeError) as e: + print("could not connect to AP or AdafruitIO: ", e) + io = None # initialize the SpiritBoard class spirit_board = SpiritBoard(display) diff --git a/TFT_Spirit_Board/esp32s3_s2_tft_featherwing_480x320/spirit_board.py b/TFT_Spirit_Board/esp32s3_s2_tft_featherwing_480x320/spirit_board.py deleted file mode 120000 index a41e6a6d5..000000000 --- a/TFT_Spirit_Board/esp32s3_s2_tft_featherwing_480x320/spirit_board.py +++ /dev/null @@ -1 +0,0 @@ -../shared/spirit_board.py \ No newline at end of file diff --git a/TFT_Spirit_Board/esp32s3_s2_tft_featherwing_480x320/spirit_board.py b/TFT_Spirit_Board/esp32s3_s2_tft_featherwing_480x320/spirit_board.py new file mode 100644 index 000000000..b7fe08a42 --- /dev/null +++ b/TFT_Spirit_Board/esp32s3_s2_tft_featherwing_480x320/spirit_board.py @@ -0,0 +1,388 @@ +# 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 + + +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.Display 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): + """ + + :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 + :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}") + + # 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=0.02, 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=0.02, 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: + # slide the planchette to the current characters location + self.slide_planchette(SpiritBoard.LOCATIONS[character], + delay=0.02, step_size=step_size) + + # pause after we arrive + time.sleep(0.5) + + # 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=0.02, 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=0.02, step_size=6) + + @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): + print("Caught OSError. Will try again next time.\n" + "Falling back to spirit_messages.txt file.") + return SpiritBoard.read_local_messages_file() diff --git a/TFT_Spirit_Board/pyportal/code.py b/TFT_Spirit_Board/pyportal/code.py new file mode 100644 index 000000000..441a4733d --- /dev/null +++ b/TFT_Spirit_Board/pyportal/code.py @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: 2024 Tim Cocks +# +# SPDX-License-Identifier: MIT +""" +SpiritBoard code.py +Standard PyPortal w/ 320x240 pixel display + +Receive and display messages from the spirits. +""" +# pylint: disable=import-error, invalid-name + +import os +import board +from digitalio import DigitalInOut +import adafruit_connection_manager +from adafruit_esp32spi import adafruit_esp32spi +import adafruit_touchscreen +import adafruit_requests +from adafruit_io.adafruit_io import IO_HTTP + +from spirit_board import SpiritBoard + +display = board.DISPLAY + +# Initialize the touch overlay +touchscreen = adafruit_touchscreen.Touchscreen( + board.TOUCH_XL, + board.TOUCH_XR, + board.TOUCH_YD, + board.TOUCH_YU, + calibration=((6584, 59861), (9505, 57492)), + size=(board.DISPLAY.width, board.DISPLAY.height), +) + +# Initialize the ES32SPI Coprocessor +esp32_cs = DigitalInOut(board.ESP_CS) +esp32_ready = DigitalInOut(board.ESP_BUSY) +esp32_reset = DigitalInOut(board.ESP_RESET) +spi = board.SPI() +esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) + +# connect to wifi network defined in settings.toml +print("Connecting to AP...") + +try: + esp.connect_AP(os.getenv("CIRCUITPY_WIFI_SSID"), os.getenv("CIRCUITPY_WIFI_PASSWORD")) + + # Initialize a requests session + pool = adafruit_connection_manager.get_radio_socketpool(esp) + ssl_context = adafruit_connection_manager.get_radio_ssl_context(esp) + requests = adafruit_requests.Session(pool, ssl_context) + + # Set your Adafruit IO Username and Key in secrets.py + # (visit io.adafruit.com if you need to create an account, + # or if you need your Adafruit IO key.) + aio_username = os.getenv("AIO_USERNAME") + aio_key = os.getenv("AIO_KEY") + + # Initialize an Adafruit IO HTTP API object + io = IO_HTTP(aio_username, aio_key, requests) +except (RuntimeError, TypeError) as e: + print("could not connect to AP or AdafruitIO: ", e) + io = None + +# initialize the SpiritBoard class +spirit_board = SpiritBoard(display) + +# get messages from io or the local file +messages = spirit_board.get_messages(io) + +# The planchette is already at the home position. +# Slide it to home again to make it jump, in order +# indicate the message is ready to be received. +spirit_board.slide_planchette(SpiritBoard.LOCATIONS["home"], delay=0.02, step_size=6) + +# current message index +message_index = 0 +while True: + # read the touch screen + p = touchscreen.touch_point + + # if the display was touched + if p: + # write the message at the current index + spirit_board.write_message(messages[message_index], step_size=8) + + # if there are more messages in the list inside of context + if message_index < len(messages) - 1: + # increment the message index + message_index += 1 + + else: # there are no more messages in the list + # reset the index to 0 + message_index = 0 + print("fetching next") + + # fetch new messages + messages = spirit_board.get_messages(io) + + # make the planchette jump to indicate messages are ready to display + spirit_board.slide_planchette(SpiritBoard.LOCATIONS["home"], + delay=0.02, step_size=6) diff --git a/TFT_Spirit_Board/pyportal/planchette_v1_sm.bmp b/TFT_Spirit_Board/pyportal/planchette_v1_sm.bmp new file mode 100644 index 000000000..c9c5db964 Binary files /dev/null and b/TFT_Spirit_Board/pyportal/planchette_v1_sm.bmp differ diff --git a/TFT_Spirit_Board/shared/spirit_board.py b/TFT_Spirit_Board/pyportal/spirit_board.py similarity index 91% rename from TFT_Spirit_Board/shared/spirit_board.py rename to TFT_Spirit_Board/pyportal/spirit_board.py index 9211d03de..b7fe08a42 100644 --- a/TFT_Spirit_Board/shared/spirit_board.py +++ b/TFT_Spirit_Board/pyportal/spirit_board.py @@ -11,7 +11,7 @@ import displayio # pylint: disable=import-error -from anchored_tilegrid import AnchoredTileGrid +from adafruit_anchored_tilegrid import AnchoredTileGrid class SpiritBoard(displayio.Group): @@ -50,14 +50,22 @@ def __init__(self, display): super().__init__() # board image file - self.spirit_board_odb = displayio.OnDiskBitmap("spirit_board_480x320.bmp") + 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 - self.planchette_odb = displayio.OnDiskBitmap("planchette_v1.bmp") + 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) @@ -75,6 +83,21 @@ def __init__(self, display): # 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): """ @@ -279,6 +302,8 @@ def sync_with_io(io) -> list: :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") @@ -357,7 +382,7 @@ def get_messages(io) -> list: """ try: return SpiritBoard.sync_with_io(io) - except OSError: + except (OSError, RuntimeError): print("Caught OSError. Will try again next time.\n" "Falling back to spirit_messages.txt file.") return SpiritBoard.read_local_messages_file() diff --git a/TFT_Spirit_Board/pyportal/spirit_board_320x240.bmp b/TFT_Spirit_Board/pyportal/spirit_board_320x240.bmp new file mode 100644 index 000000000..37f8d3b4d Binary files /dev/null and b/TFT_Spirit_Board/pyportal/spirit_board_320x240.bmp differ diff --git a/TFT_Spirit_Board/pyportal_titano/code.py b/TFT_Spirit_Board/pyportal_titano/code.py new file mode 100644 index 000000000..68b77d401 --- /dev/null +++ b/TFT_Spirit_Board/pyportal_titano/code.py @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: 2024 Tim Cocks +# +# SPDX-License-Identifier: MIT +""" +SpiritBoard code.py +PyPortal w/ 480x320 pixel display + +Receive and display messages from the spirits. +""" +# pylint: disable=import-error, invalid-name + +import os +import board +from digitalio import DigitalInOut +import adafruit_connection_manager +from adafruit_esp32spi import adafruit_esp32spi +import adafruit_touchscreen +import adafruit_requests +from adafruit_io.adafruit_io import IO_HTTP + +from spirit_board import SpiritBoard + +display = board.DISPLAY + +# Initialize the touch overlay +touchscreen = adafruit_touchscreen.Touchscreen( + board.TOUCH_XL, + board.TOUCH_XR, + board.TOUCH_YD, + board.TOUCH_YU, + calibration=((6584, 59861), (9505, 57492)), + size=(board.DISPLAY.width, board.DISPLAY.height), +) + +# Initialize the ES32SPI Coprocessor +esp32_cs = DigitalInOut(board.ESP_CS) +esp32_ready = DigitalInOut(board.ESP_BUSY) +esp32_reset = DigitalInOut(board.ESP_RESET) +spi = board.SPI() +esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) + +# connect to wifi network defined in settings.toml +print("Connecting to AP...") + +try: + esp.connect_AP(os.getenv("CIRCUITPY_WIFI_SSID"), os.getenv("CIRCUITPY_WIFI_PASSWORD")) + + # Initialize a requests session + pool = adafruit_connection_manager.get_radio_socketpool(esp) + ssl_context = adafruit_connection_manager.get_radio_ssl_context(esp) + requests = adafruit_requests.Session(pool, ssl_context) + + # Set your Adafruit IO Username and Key in secrets.py + # (visit io.adafruit.com if you need to create an account, + # or if you need your Adafruit IO key.) + aio_username = os.getenv("AIO_USERNAME") + aio_key = os.getenv("AIO_KEY") + + # Initialize an Adafruit IO HTTP API object + io = IO_HTTP(aio_username, aio_key, requests) +except (RuntimeError, TypeError) as e: + print("could not connect to AP or AdafruitIO: ", e) + io = None + +# initialize the SpiritBoard class +spirit_board = SpiritBoard(display) + +# get messages from io or the local file +messages = spirit_board.get_messages(io) + +# The planchette is already at the home position. +# Slide it to home again to make it jump, in order +# indicate the message is ready to be received. +spirit_board.slide_planchette(SpiritBoard.LOCATIONS["home"], delay=0.02, step_size=6) + +# current message index +message_index = 0 +while True: + # read the touch screen + p = touchscreen.touch_point + + # if the display was touched + if p: + # write the message at the current index + spirit_board.write_message(messages[message_index], step_size=8) + + # if there are more messages in the list inside of context + if message_index < len(messages) - 1: + # increment the message index + message_index += 1 + + else: # there are no more messages in the list + # reset the index to 0 + message_index = 0 + print("fetching next") + + # fetch new messages + messages = spirit_board.get_messages(io) + + # make the planchette jump to indicate messages are ready to display + spirit_board.slide_planchette(SpiritBoard.LOCATIONS["home"], + delay=0.02, step_size=6) diff --git a/TFT_Spirit_Board/pyportal_titano/planchette_v1.bmp b/TFT_Spirit_Board/pyportal_titano/planchette_v1.bmp new file mode 100644 index 000000000..b36ff9c82 Binary files /dev/null and b/TFT_Spirit_Board/pyportal_titano/planchette_v1.bmp differ diff --git a/TFT_Spirit_Board/pyportal_titano/spirit_board.py b/TFT_Spirit_Board/pyportal_titano/spirit_board.py new file mode 100644 index 000000000..b7fe08a42 --- /dev/null +++ b/TFT_Spirit_Board/pyportal_titano/spirit_board.py @@ -0,0 +1,388 @@ +# 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 + + +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.Display 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): + """ + + :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 + :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}") + + # 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=0.02, 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=0.02, 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: + # slide the planchette to the current characters location + self.slide_planchette(SpiritBoard.LOCATIONS[character], + delay=0.02, step_size=step_size) + + # pause after we arrive + time.sleep(0.5) + + # 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=0.02, 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=0.02, step_size=6) + + @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): + print("Caught OSError. Will try again next time.\n" + "Falling back to spirit_messages.txt file.") + return SpiritBoard.read_local_messages_file() diff --git a/TFT_Spirit_Board/pyportal_titano/spirit_board_480x320.bmp b/TFT_Spirit_Board/pyportal_titano/spirit_board_480x320.bmp new file mode 100644 index 000000000..1848c9506 Binary files /dev/null and b/TFT_Spirit_Board/pyportal_titano/spirit_board_480x320.bmp differ diff --git a/TFT_Spirit_Board/shared/anchored_tilegrid.py b/TFT_Spirit_Board/shared/anchored_tilegrid.py deleted file mode 100644 index 908386464..000000000 --- a/TFT_Spirit_Board/shared/anchored_tilegrid.py +++ /dev/null @@ -1,63 +0,0 @@ -# SPDX-FileCopyrightText: 2024 Tim Cocks -# -# SPDX-License-Identifier: MIT -""" -AnchoredTilegrid helper class -""" -try: - from typing import Tuple -except ImportError: - pass - -from displayio import TileGrid - - -class AnchoredTileGrid(TileGrid): - """ - AnchoredTileGrid extends TileGrid and allows placing the TileGrid - relative to an arbitrary anchor point. - """ - def __init__(self, bitmap, **kwargs): - super().__init__(bitmap, **kwargs) - self._anchor_point = (0, 0) - - self._anchored_position = ( - 0 if "x" not in kwargs else kwargs["x"], - 0 if "y" not in kwargs else kwargs["y"] - ) - - @property - def anchor_point(self): - """ - The anchor point. tuple containing x and y values ranging - from 0 to 1. - """ - return self._anchor_point - - @anchor_point.setter - def anchor_point(self, new_anchor_point: Tuple[float, float]) -> None: - self._anchor_point = new_anchor_point - # update the anchored_position using setter - self.anchored_position = self._anchored_position - - @property - def anchored_position(self) -> Tuple[int, int]: - """Position relative to the anchor_point. Tuple containing x,y - pixel coordinates.""" - return self._anchored_position - - @anchored_position.setter - def anchored_position(self, new_position: Tuple[int, int]) -> None: - self._anchored_position = new_position - - if (self._anchor_point is not None) and (self._anchored_position is not None): - # Calculate (x,y) position - self.x = int( - new_position[0] - - round(self._anchor_point[0] * (self.tile_width * self.width)) - ) - - self.y = int( - new_position[1] - - round(self._anchor_point[1] * (self.tile_height * self.height)) - )