diff --git a/src/adafruit_circuitplayground/express.py b/src/adafruit_circuitplayground/express.py index d4af22980..cc6f16dae 100644 --- a/src/adafruit_circuitplayground/express.py +++ b/src/adafruit_circuitplayground/express.py @@ -48,7 +48,6 @@ def __init__(self): "shake": False, } self.__debug_mode = False - self.__abs_path_to_code_file = "" self.pixels = Pixel(self.__state, self.__debug_mode) @property @@ -169,7 +168,7 @@ def play_file(self, file_name): telemetry_py.send_telemetry(TelemetryEvent.CPX_API_PLAY_FILE) file_name = utils.remove_leading_slashes(file_name) abs_path_parent_dir = os.path.abspath( - os.path.join(self.__abs_path_to_code_file, os.pardir) + os.path.join(utils.abs_path_to_user_file, os.pardir) ) abs_path_wav_file = os.path.normpath( os.path.join(abs_path_parent_dir, file_name) diff --git a/src/base_circuitpython/base_cp_constants.py b/src/base_circuitpython/base_cp_constants.py index 67cea36e0..d741d810a 100644 --- a/src/base_circuitpython/base_cp_constants.py +++ b/src/base_circuitpython/base_cp_constants.py @@ -4,7 +4,14 @@ CLUE_PIN = "D18" +CLUE = "CLUE" +BASE_64 = "display_base64" IMG_DIR_NAME = "img" SCREEN_HEIGHT_WIDTH = 240 +BMP_IMG = "BMP" + +BMP_IMG_ENDING = ".bmp" + +NO_VALID_IMGS_ERR = "No valid images" EXPECTED_INPUT_BUTTONS = ["button_a", "button_b"] diff --git a/src/clue/adafruit_slideshow.py b/src/clue/adafruit_slideshow.py new file mode 100644 index 000000000..0a0411705 --- /dev/null +++ b/src/clue/adafruit_slideshow.py @@ -0,0 +1,317 @@ +from PIL import Image + +import os +import base64 +from io import BytesIO +from base_circuitpython import base_cp_constants as CONSTANTS +import time +import collections +from random import shuffle +from common import utils + +# taken from adafruit +# https://github.com/adafruit/Adafruit_CircuitPython_Slideshow/blob/master/adafruit_slideshow.py + + +class PlayBackOrder: + """Defines possible slideshow playback orders.""" + + # pylint: disable=too-few-public-methods + ALPHABETICAL = 0 + """Orders by alphabetical sort of filenames""" + + RANDOM = 1 + """Randomly shuffles the images""" + # pylint: enable=too-few-public-methods + + +class PlayBackDirection: + """Defines possible slideshow playback directions.""" + + # pylint: disable=too-few-public-methods + BACKWARD = -1 + """The next image is before the current image. When alphabetically sorted, this is towards A.""" + + FORWARD = 1 + """The next image is after the current image. When alphabetically sorted, this is towards Z.""" + # pylint: enable=too-few-public-methods + + +# custom +class SlideShow: + def __init__( + self, + display, + backlight_pwm=None, + *, + folder=".", + order=PlayBackOrder.ALPHABETICAL, + loop=True, + dwell=3, + fade_effect=True, + auto_advance=True, + direction=PlayBackDirection.FORWARD, + ): + self._BASE_DWELL = 0.3 + self._BASE_DWELL_DARK = 0.7 + self._NO_FADE_TRANSITION_INCREMENTS = 18 + + self.auto_advance = auto_advance + """Enable auto-advance based on dwell time. Set to ``False`` to manually control.""" + + self.loop = loop + """Specifies whether to loop through the images continuously or play through the list once. + ``True`` will continue to loop, ``False`` will play only once.""" + + self.fade_effect = fade_effect + """Whether to include the fade effect between images. ``True`` tells the code to fade the + backlight up and down between image display transitions. ``False`` maintains max + brightness on the backlight between image transitions.""" + + self.dwell = self._BASE_DWELL + dwell + """The number of seconds each image displays, in seconds.""" + + self.direction = direction + """Specify the playback direction. Default is ``PlayBackDirection.FORWARD``. Can also be + ``PlayBackDirection.BACKWARD``.""" + + self.advance = self._advance_with_fade + """Displays the next image. Returns True when a new image was displayed, False otherwise. + """ + + self.fade_frames = 8 + + # assign new advance method if fade is disabled + if not fade_effect: + self.advance = self._advance_no_fade + + self._img_start = None + + self.brightness = 1.0 + + # blank screen for start + self._curr_img_handle = Image.new( + "RGBA", (CONSTANTS.SCREEN_HEIGHT_WIDTH, CONSTANTS.SCREEN_HEIGHT_WIDTH) + ) + + # if path is relative, this makes sure that + # it's relative to the users's code file + abs_path_parent_dir = os.path.abspath( + os.path.join(utils.abs_path_to_user_file, os.pardir) + ) + abs_path_folder = os.path.normpath(os.path.join(abs_path_parent_dir, folder)) + + self.folder = abs_path_folder + + # get files within specified directory + self.dirs = os.listdir(self.folder) + + self._order = order + self._curr_img = "" + + # load images into main queue + self._load_images() + + # show the first working image + self.advance() + + @property + def current_image_name(self): + """Returns the current image name.""" + return self._curr_img + + @property + def order(self): + """Specifies the order in which the images are displayed. Options are random (``RANDOM``) or + alphabetical (``ALPHABETICAL``). Default is ``RANDOM``.""" + return self._order + + @order.setter + def order(self, order): + if order not in [PlayBackOrder.ALPHABETICAL, PlayBackOrder.RANDOM]: + raise ValueError("Order must be either 'RANDOM' or 'ALPHABETICAL'") + + self._order = order + self._load_images() + + @property + def brightness(self): + """Brightness of the backlight when an image is displaying. Clamps to 0 to 1.0""" + return self._brightness + + @brightness.setter + def brightness(self, brightness): + if brightness < 0: + brightness = 0 + elif brightness > 1.0: + brightness = 1.0 + self._brightness = brightness + + def update(self): + """Updates the slideshow to the next image.""" + now = time.monotonic() + if not self.auto_advance or now - self._img_start < self.dwell: + return True + + return self.advance() + + def _get_next_img(self): + + # handle empty queue + if not len(self.pic_queue): + if self.loop: + self._load_images() + else: + return "" + + if self.direction == PlayBackDirection.FORWARD: + return self.pic_queue.popleft() + else: + return self.pic_queue.pop() + + def _load_images(self): + dir_imgs = [] + for d in self.dirs: + try: + new_path = os.path.join(self.folder, d) + + # only add bmp imgs + if os.path.splitext(new_path)[1] == CONSTANTS.BMP_IMG_ENDING: + dir_imgs.append(new_path) + except Image.UnidentifiedImageError as e: + continue + + if not len(dir_imgs): + raise RuntimeError(CONSTANTS.NO_VALID_IMGS_ERR) + + if self._order == PlayBackOrder.RANDOM: + shuffle(dir_imgs) + else: + dir_imgs.sort() + + # convert list to queue + # (must be list beforehand for potential randomization) + self.pic_queue = collections.deque(dir_imgs) + + def _advance_with_fade(self): + + old_img = self._curr_img_handle + advance_sucessful = False + + while not advance_sucessful: + new_path = self._get_next_img() + if new_path == "": + return False + + try: + new_img = Image.open(new_path) + + new_img = new_img.convert("RGBA") + new_img.putalpha(255) + + new_img = new_img.crop( + (0, 0, CONSTANTS.SCREEN_HEIGHT_WIDTH, CONSTANTS.SCREEN_HEIGHT_WIDTH) + ) + + if new_img.size[0] < 240 or new_img.size[1] < 240: + black_overlay = Image.new( + "RGBA", + CONSTANTS.SCREEN_HEIGHT_WIDTH, + CONSTANTS.SCREEN_HEIGHT_WIDTH, + ) + black_overlay.paste(new_img) + new_img = black_overlay + + black_overlay = Image.new("RGBA", new_img.size) + advance_sucessful = True + except Image.UnidentifiedImageError as e: + pass + + # fade out old photo + for i in range(self.fade_frames, -1, -1): + sendable_img = Image.blend( + black_overlay, old_img, i * self.brightness / self.fade_frames + ) + self._send(sendable_img) + + time.sleep(self._BASE_DWELL_DARK) + + # fade in new photo + for i in range(self.fade_frames + 1): + sendable_img = Image.blend( + black_overlay, new_img, i * self.brightness / self.fade_frames + ) + self._send(sendable_img) + + self._curr_img_handle = new_img + self._curr_img = new_path + self._img_start = time.monotonic() + return True + + def _advance_no_fade(self): + + old_img = self._curr_img_handle + + advance_sucessful = False + + while not advance_sucessful: + new_path = self._get_next_img() + if new_path == "": + return False + + try: + new_img = Image.open(new_path) + + new_img = new_img.crop( + (0, 0, CONSTANTS.SCREEN_HEIGHT_WIDTH, CONSTANTS.SCREEN_HEIGHT_WIDTH) + ) + + if ( + new_img.size[0] < CONSTANTS.SCREEN_HEIGHT_WIDTH + or new_img.size[1] < CONSTANTS.SCREEN_HEIGHT_WIDTH + ): + black_overlay = Image.new( + "RGBA", + CONSTANTS.SCREEN_HEIGHT_WIDTH, + CONSTANTS.SCREEN_HEIGHT_WIDTH, + ) + black_overlay.paste(new_img) + new_img = black_overlay + + self._curr_img = new_path + + new_img = new_img.convert("RGBA") + new_img.putalpha(255) + advance_sucessful = True + except Image.UnidentifiedImageError as e: + pass + + if self.brightness < 1.0: + black_overlay = Image.new("RGBA", new_img.size) + new_img = Image.blend(black_overlay, new_img, self.brightness) + + # gradually scroll new img over old img + for i in range(self._NO_FADE_TRANSITION_INCREMENTS + 1): + curr_y = ( + i * CONSTANTS.SCREEN_HEIGHT_WIDTH / self._NO_FADE_TRANSITION_INCREMENTS + ) + img_piece = new_img.crop((0, 0, CONSTANTS.SCREEN_HEIGHT_WIDTH, curr_y)) + old_img.paste(img_piece) + self._send(old_img) + + self._curr_img_handle = new_img + self._curr_img = new_path + self._img_start = time.monotonic() + return True + + def _send(self, img): + # sends current bmp_img to the frontend + buffered = BytesIO() + img.save(buffered, format=CONSTANTS.BMP_IMG) + byte_base64 = base64.b64encode(buffered.getvalue()) + + # only send the base_64 string contents + img_str = str(byte_base64)[2:-1] + + sendable_json = {CONSTANTS.BASE_64: img_str} + utils.send_to_simulator(sendable_json, CONSTANTS.CLUE) diff --git a/src/clue/test/slideshow_pics/pic_1.bmp b/src/clue/test/slideshow_pics/pic_1.bmp new file mode 100644 index 000000000..0d1555007 Binary files /dev/null and b/src/clue/test/slideshow_pics/pic_1.bmp differ diff --git a/src/clue/test/slideshow_pics/pic_2.bmp b/src/clue/test/slideshow_pics/pic_2.bmp new file mode 100644 index 000000000..345d5820e Binary files /dev/null and b/src/clue/test/slideshow_pics/pic_2.bmp differ diff --git a/src/clue/test/slideshow_pics/pic_3.bmp b/src/clue/test/slideshow_pics/pic_3.bmp new file mode 100644 index 000000000..78ef7b8a2 Binary files /dev/null and b/src/clue/test/slideshow_pics/pic_3.bmp differ diff --git a/src/clue/test/slideshow_pics/pic_4.bmp b/src/clue/test/slideshow_pics/pic_4.bmp new file mode 100644 index 000000000..44233003c Binary files /dev/null and b/src/clue/test/slideshow_pics/pic_4.bmp differ diff --git a/src/clue/test/slideshow_pics/pic_5.bmp b/src/clue/test/slideshow_pics/pic_5.bmp new file mode 100644 index 000000000..e61a19752 Binary files /dev/null and b/src/clue/test/slideshow_pics/pic_5.bmp differ diff --git a/src/clue/test/slideshow_pics/pic_6.bmp b/src/clue/test/slideshow_pics/pic_6.bmp new file mode 100644 index 000000000..d3e254039 Binary files /dev/null and b/src/clue/test/slideshow_pics/pic_6.bmp differ diff --git a/src/clue/test/slideshow_pics/pic_7.bmp b/src/clue/test/slideshow_pics/pic_7.bmp new file mode 100644 index 000000000..7ae133efb Binary files /dev/null and b/src/clue/test/slideshow_pics/pic_7.bmp differ diff --git a/src/clue/test/slideshow_pics/pic_8.bmp b/src/clue/test/slideshow_pics/pic_8.bmp new file mode 100644 index 000000000..2746a30f8 Binary files /dev/null and b/src/clue/test/slideshow_pics/pic_8.bmp differ diff --git a/src/clue/test/test_adafruit_slideshow.py b/src/clue/test/test_adafruit_slideshow.py new file mode 100644 index 000000000..649e3ccb4 --- /dev/null +++ b/src/clue/test/test_adafruit_slideshow.py @@ -0,0 +1,100 @@ +from ..adafruit_slideshow import SlideShow, PlayBackDirection, PlayBackOrder +import board +import pathlib +import os + +from PIL import Image +from .test_helpers import helper +from base_circuitpython import base_cp_constants as CONSTANTS + +from unittest import mock + +from common import utils + + +class TestAdafruitSlideShow(object): + def setup_method(self): + self.abs_path = pathlib.Path(__file__).parent.absolute() + + # Create a new black (default) image + self.main_img = Image.new( + "RGBA", + (CONSTANTS.SCREEN_HEIGHT_WIDTH, CONSTANTS.SCREEN_HEIGHT_WIDTH), + (0, 0, 0, 0), + ) + + utils.send_to_simulator = mock.Mock() + + def test_slideshow(self): + + pic_dir = os.path.join(self.abs_path, "slideshow_pics") + slideshow_images = [] + for i in range(8): + img = Image.open(os.path.join(pic_dir, f"pic_{i+1}.bmp")) + img = img.convert("RGBA") + img.putalpha(255) + + img = img.crop( + (0, 0, CONSTANTS.SCREEN_HEIGHT_WIDTH, CONSTANTS.SCREEN_HEIGHT_WIDTH) + ) + + if img.size[0] < 240 or img.size[1] < 240: + black_overlay = Image.new( + "RGBA", + CONSTANTS.SCREEN_HEIGHT_WIDTH, + CONSTANTS.SCREEN_HEIGHT_WIDTH, + ) + black_overlay.paste(img) + img = black_overlay + + slideshow_images.append(img) + + # Create the slideshow object that plays through once alphabetically. + slideshow = SlideShow( + board.DISPLAY, + dwell=3, + folder=pic_dir, + loop=True, + fade_effect=True, + auto_advance=True, + order=PlayBackOrder.ALPHABETICAL, + direction=PlayBackDirection.FORWARD, + ) + + slideshow._send = self._send_helper + + # first image's appear time is unstable,since it fades/scrolls in + # can only predict following ones... + + for i in range(1, 8): + slideshow.advance() + helper._Helper__test_image_equality( + self.main_img.load(), slideshow_images[i].load() + ) + + # Create the slideshow object that plays through once backwards. + slideshow2 = SlideShow( + board.DISPLAY, + dwell=3, + folder=pic_dir, + loop=True, + fade_effect=False, + auto_advance=True, + order=PlayBackOrder.ALPHABETICAL, + direction=PlayBackDirection.BACKWARD, + ) + + slideshow2._send = self._send_helper + + helper._Helper__test_image_equality( + self.main_img.load(), slideshow_images[7].load() + ) + + for i in range(6, -1, -1): + slideshow2.advance() + helper._Helper__test_image_equality( + self.main_img.load(), slideshow_images[i].load() + ) + + def _send_helper(self, image): + self.main_img = image diff --git a/src/clue/test/test_helpers.py b/src/clue/test/test_helpers.py index 5c4a99be6..429965aaf 100644 --- a/src/clue/test/test_helpers.py +++ b/src/clue/test/test_helpers.py @@ -7,7 +7,26 @@ def __test_image_equality(self, image_1, image_2): for j in range(CONSTANTS.SCREEN_HEIGHT_WIDTH): pixel_1 = image_1[j, i] pixel_2 = image_2[j, i] - assert pixel_1 == pixel_2 + + if not isinstance(pixel_1, tuple): + pixel_1 = self.hex2rgba(pixel_1) + + if not isinstance(pixel_2, tuple): + pixel_2 = self.hex2rgba(pixel_2) + assert pixel_1[0:3] == pixel_2[0:3] + + def hex2rgba(self, curr_colour): + + ret_list = [] + + for i in range(3, -1, -1): + val = (curr_colour >> (2 ** (i + 1))) & 255 + if val == 0: + ret_list.append(0) + else: + ret_list.append(val) + + return tuple(ret_list) helper = Helper() diff --git a/src/common/utils.py b/src/common/utils.py index dbd144835..f78dda44d 100644 --- a/src/common/utils.py +++ b/src/common/utils.py @@ -9,6 +9,8 @@ previous_state = {} +abs_path_to_user_file = "" + def update_state_with_device_name(state, device_name): updated_state = dict(state) diff --git a/src/debug_user_code.py b/src/debug_user_code.py index cd3fd0602..93b6ba50f 100644 --- a/src/debug_user_code.py +++ b/src/debug_user_code.py @@ -7,6 +7,7 @@ from pathlib import Path import python_constants as CONSTANTS import check_python_dependencies +from common import utils # will propagate errors if dependencies aren't sufficient check_python_dependencies.check_for_dependencies() @@ -50,7 +51,7 @@ debugger_communication_client.init_connection(server_port) # Init API variables -cpx._Express__abs_path_to_code_file = abs_path_to_code_file +utils.abs_path_to_user_file = abs_path_to_code_file cpx._Express__debug_mode = True cpx.pixels._Pixel__set_debug_mode(True) mb._MicrobitModel__set_debug_mode(True) diff --git a/src/process_user_code.py b/src/process_user_code.py index f8ee94b42..c07f1ddd6 100644 --- a/src/process_user_code.py +++ b/src/process_user_code.py @@ -43,7 +43,7 @@ # This import must happen after the sys.path is modified from common.telemetry import telemetry_py - +from common import utils from adafruit_circuitplayground.express import cpx from adafruit_circuitplayground.constants import CPX @@ -100,7 +100,7 @@ def handle_user_prints(): # Execute User Code Thread def execute_user_code(abs_path_to_code_file): - cpx._Express__abs_path_to_code_file = abs_path_to_code_file + utils.abs_path_to_user_file = abs_path_to_code_file # Execute the user's code.py file with open(abs_path_to_code_file, encoding="utf8") as user_code_file: user_code = user_code_file.read()