diff --git a/src/microbit/model/constants.py b/src/microbit/model/constants.py index 5c3f6a79d..ebe061a18 100644 --- a/src/microbit/model/constants.py +++ b/src/microbit/model/constants.py @@ -95,6 +95,14 @@ ], } +# 5x5 Alphabet +# Taken from https://raw.githubusercontent.com/micropython/micropython/264d80c84e034541bd6e4b461bfece4443ffd0ac/ports/nrf/boards/microbit/modules/microbitfont.h +ALPHABET = b"\x00\x00\x00\x00\x00\x08\x08\x08\x00\x08\x0a\x4a\x40\x00\x00\x0a\x5f\xea\x5f\xea\x0e\xd9\x2e\xd3\x6e\x19\x32\x44\x89\x33\x0c\x92\x4c\x92\x4d\x08\x08\x00\x00\x00\x04\x88\x08\x08\x04\x08\x04\x84\x84\x88\x00\x0a\x44\x8a\x40\x00\x04\x8e\xc4\x80\x00\x00\x00\x04\x88\x00\x00\x0e\xc0\x00\x00\x00\x00\x08\x00\x01\x22\x44\x88\x10\x0c\x92\x52\x52\x4c\x04\x8c\x84\x84\x8e\x1c\x82\x4c\x90\x1e\x1e\xc2\x44\x92\x4c\x06\xca\x52\x5f\xe2\x1f\xf0\x1e\xc1\x3e\x02\x44\x8e\xd1\x2e\x1f\xe2\x44\x88\x10\x0e\xd1\x2e\xd1\x2e\x0e\xd1\x2e\xc4\x88\x00\x08\x00\x08\x00\x00\x04\x80\x04\x88\x02\x44\x88\x04\x82\x00\x0e\xc0\x0e\xc0\x08\x04\x82\x44\x88\x0e\xd1\x26\xc0\x04\x0e\xd1\x35\xb3\x6c\x0c\x92\x5e\xd2\x52\x1c\x92\x5c\x92\x5c\x0e\xd0\x10\x10\x0e\x1c\x92\x52\x52\x5c\x1e\xd0\x1c\x90\x1e\x1e\xd0\x1c\x90\x10\x0e\xd0\x13\x71\x2e\x12\x52\x5e\xd2\x52\x1c\x88\x08\x08\x1c\x1f\xe2\x42\x52\x4c\x12\x54\x98\x14\x92\x10\x10\x10\x10\x1e\x11\x3b\x75\xb1\x31\x11\x39\x35\xb3\x71\x0c\x92\x52\x52\x4c\x1c\x92\x5c\x90\x10\x0c\x92\x52\x4c\x86\x1c\x92\x5c\x92\x51\x0e\xd0\x0c\x82\x5c\x1f\xe4\x84\x84\x84\x12\x52\x52\x52\x4c\x11\x31\x31\x2a\x44\x11\x31\x35\xbb\x71\x12\x52\x4c\x92\x52\x11\x2a\x44\x84\x84\x1e\xc4\x88\x10\x1e\x0e\xc8\x08\x08\x0e\x10\x08\x04\x82\x41\x0e\xc2\x42\x42\x4e\x04\x8a\x40\x00\x00\x00\x00\x00\x00\x1f\x08\x04\x80\x00\x00\x00\x0e\xd2\x52\x4f\x10\x10\x1c\x92\x5c\x00\x0e\xd0\x10\x0e\x02\x42\x4e\xd2\x4e\x0c\x92\x5c\x90\x0e\x06\xc8\x1c\x88\x08\x0e\xd2\x4e\xc2\x4c\x10\x10\x1c\x92\x52\x08\x00\x08\x08\x08\x02\x40\x02\x42\x4c\x10\x14\x98\x14\x92\x08\x08\x08\x08\x06\x00\x1b\x75\xb1\x31\x00\x1c\x92\x52\x52\x00\x0c\x92\x52\x4c\x00\x1c\x92\x5c\x90\x00\x0e\xd2\x4e\xc2\x00\x0e\xd0\x10\x10\x00\x06\xc8\x04\x98\x08\x08\x0e\xc8\x07\x00\x12\x52\x52\x4f\x00\x11\x31\x2a\x44\x00\x11\x31\x35\xbb\x00\x12\x4c\x8c\x92\x00\x11\x2a\x44\x98\x00\x1e\xc4\x88\x1e\x06\xc4\x8c\x84\x86\x08\x08\x08\x08\x08\x18\x08\x0c\x88\x18\x00\x00\x0c\x83\x60" +# We support ASCII characters between these indexes on the microbit +ASCII_START = 32 +ASCII_END = 126 +SPACE_BETWEEN_LETTERS_WIDTH = 1 +WHITESPACE_WIDTH = 3 # numerical LED values LED_HEIGHT = 5 diff --git a/src/microbit/model/display.py b/src/microbit/model/display.py new file mode 100644 index 000000000..82784ba91 --- /dev/null +++ b/src/microbit/model/display.py @@ -0,0 +1,269 @@ +import copy +import time +import threading + +from . import constants as CONSTANTS +from .image import Image +from .. import shim + + +class Display: + # The implementation based off of https://github.com/bbcmicrobit/micropython/blob/master/docs/display.rst. + + def __init__(self): + self.__image = Image() + self.__on = True + self.__current_pid = None + self.__blank_image = Image() + + self.__lock = threading.Lock() + + def scroll(self, value, delay=150, wait=True, loop=False, monospace=False): + if not wait: + thread = threading.Thread( + target=self.scroll, args=(value, delay, True, loop, monospace) + ) + thread.start() + return + + # Set current_pid to the thread's identifier + self.__lock.acquire() + self.__current_pid = threading.get_ident() + self.__lock.release() + + if isinstance(value, (str, int, float)): + value = str(value) + else: + raise TypeError(f"can't convert {type(value)} object to str implicitly") + + letters = [] + for c in value: + if monospace: + letters.append(Display.__get_image_from_char(c)) + letters.append( + Image(CONSTANTS.SPACE_BETWEEN_LETTERS_WIDTH, CONSTANTS.LED_HEIGHT) + ) + else: + if c == " ": + letters.append( + Image(CONSTANTS.WHITESPACE_WIDTH, CONSTANTS.LED_HEIGHT) + ) + else: + letters.append( + Display.__strip_unlit_columns(Display.__get_image_from_char(c)) + ) + letters.append( + Image( + CONSTANTS.SPACE_BETWEEN_LETTERS_WIDTH, CONSTANTS.LED_HEIGHT, + ) + ) + appended_image = Display.__create_scroll_image(letters) + + while True: + # Show the scrolled image one square at a time. + for x in range(appended_image.width() - CONSTANTS.LED_WIDTH + 1): + self.__lock.acquire() + + # If show or scroll is called again, there will be a different pid and break + if self.__current_pid != threading.get_ident(): + self.__lock.release() + break + + self.__image.blit( + appended_image, x, 0, CONSTANTS.LED_WIDTH, CONSTANTS.LED_HEIGHT + ) + self.__lock.release() + + Display.sleep_ms(delay) + + if not loop: + break + + def show(self, value, delay=400, wait=True, loop=False, clear=False): + if not wait: + thread = threading.Thread( + target=self.show, args=(value, delay, True, loop, clear) + ) + thread.start() + return + + # Set current_pid to the thread's identifier + self.__lock.acquire() + self.__current_pid = threading.get_ident() + self.__lock.release() + + images = [] + use_delay = False + if isinstance(value, Image): + images.append(value.crop(0, 0, CONSTANTS.LED_WIDTH, CONSTANTS.LED_HEIGHT)) + elif isinstance(value, (str, int, float)): + chars = list(str(value)) + for c in chars: + images.append(Display.__get_image_from_char(c)) + if len(chars) > 1: + use_delay = True + else: + # Check if iterable + try: + _ = iter(value) + except TypeError as e: + raise e + + for elem in value: + if isinstance(elem, Image): + images.append( + elem.crop(0, 0, CONSTANTS.LED_WIDTH, CONSTANTS.LED_HEIGHT) + ) + elif isinstance(elem, str) and len(elem) == 1: + images.append(Display.__get_image_from_char(elem)) + # If elem is not char or image, break without iterating through rest of list + else: + break + use_delay = True + + while True: + for image in images: + self.__lock.acquire() + + # If show or scroll is called again, there will be a different pid and break + if self.__current_pid != threading.get_ident(): + self.__lock.release() + break + + self.__image = image + self.__lock.release() + + if use_delay: + Display.sleep_ms(delay) + + if not loop: + break + if clear: + self.clear() + + def get_pixel(self, x, y): + self.__lock.acquire() + pixel = self.__image.get_pixel(x, y) + self.__lock.release() + return pixel + + def set_pixel(self, x, y, value): + self.__lock.acquire() + self.__image.set_pixel(x, y, value) + self.__lock.release() + + def clear(self): + self.__lock.acquire() + self.__image = Image() + self.__lock.release() + + def on(self): + self.__on = True + + def off(self): + self.__on = False + + def is_on(self): + return self.__on + + def read_light_level(self): + raise NotImplementedError(CONSTANTS.NOT_IMPLEMENTED_ERROR) + + # Helpers + + def __get_array(self): + if self.is_on(): + self.__lock.acquire() + leds = copy.deepcopy(self.__image._Image__LED) + self.__lock.release() + return leds + else: + return self.__blank_image._Image__LED + + @staticmethod + def __get_image_from_char(c): + # If c is not between the ASCII alphabet we cover, make it a question mark + if ord(c) < CONSTANTS.ASCII_START or ord(c) > CONSTANTS.ASCII_END: + c = "?" + offset = (ord(c) - CONSTANTS.ASCII_START) * CONSTANTS.LED_WIDTH + representative_bytes = CONSTANTS.ALPHABET[ + offset : offset + CONSTANTS.LED_HEIGHT + ] + return Image(Display.__convert_bytearray_to_image_str(representative_bytes)) + + # Removes columns that are not lit + @staticmethod + def __strip_unlit_columns(image): + min_index = CONSTANTS.LED_WIDTH - 1 + max_index = 0 + for row in image._Image__LED: + for index, bit in enumerate(row): + if bit > 0: + min_index = min(min_index, index) + max_index = max(max_index, index) + return image.crop(min_index, 0, max_index - min_index + 1, CONSTANTS.LED_HEIGHT) + + # This method is different from Image's __bytes_to_array. + # This one requires a conversion from binary of the ALPHABET constant to an image. + @staticmethod + def __convert_bytearray_to_image_str(byte_array): + arr = [] + for b in byte_array: + # Convert byte to binary + b_as_bits = str(bin(b))[2:] + sub_arr = [] + while len(sub_arr) < 5: + # Iterate throught bits + # If there is a 1 at b, then the pixel at column b is lit + for bit in b_as_bits[::-1]: + if len(sub_arr) < 5: + sub_arr.insert(0, int(bit) * CONSTANTS.BRIGHTNESS_MAX) + else: + break + # Add 0s to the front until the list is 5 long + while len(sub_arr) < 5: + sub_arr.insert(0, 0) + arr.append(sub_arr) + image_str = "" + for row in arr: + for elem in row: + image_str += str(elem) + image_str += ":" + return image_str + + @staticmethod + def __insert_blank_column(image): + for row in image._Image__LED: + row.append(0) + + @staticmethod + def __create_scroll_image(images): + blank_5x5_image = Image() + front_of_scroll_image = Image(4, 5) + images.insert(0, front_of_scroll_image) + + scroll_image = Image._Image__append_images(images) + end_of_scroll_image = Image() + # Insert columns of 0s until the ending is a 5x5 blank + end_of_scroll_image.blit( + scroll_image, + scroll_image.width() - CONSTANTS.LED_WIDTH, + 0, + CONSTANTS.LED_WIDTH, + CONSTANTS.LED_HEIGHT, + ) + while not Image._Image__same_image(end_of_scroll_image, blank_5x5_image): + Display.__insert_blank_column(scroll_image) + end_of_scroll_image.blit( + scroll_image, + scroll_image.width() - CONSTANTS.LED_WIDTH, + 0, + CONSTANTS.LED_WIDTH, + CONSTANTS.LED_HEIGHT, + ) + + return scroll_image + + @staticmethod + def sleep_ms(ms): + time.sleep(ms / 1000) diff --git a/src/microbit/model/image.py b/src/microbit/model/image.py index a407c7ab0..18d75cd1d 100644 --- a/src/microbit/model/image.py +++ b/src/microbit/model/image.py @@ -216,6 +216,24 @@ def __mul__(self, other): return res + def __repr__(self): + ret_str = "Image('" + for index_y in range(self.height()): + ret_str += self.__row_to_str(index_y) + + ret_str += "')" + + return ret_str + + def __str__(self): + ret_str = "Image('\n" + for index_y in range(self.height()): + ret_str += "\t" + self.__row_to_str(index_y) + "\n" + + ret_str += "')" + + return ret_str + # HELPER FUNCTIONS # This create 2D array of off LEDs with @@ -338,23 +356,29 @@ def __row_to_str(self, y): return new_str - def __repr__(self): - ret_str = "Image('" - for index_y in range(self.height()): - ret_str += self.__row_to_str(index_y) - - ret_str += "')" - - return ret_str - - def __str__(self): - ret_str = "Image('\n" - for index_y in range(self.height()): - ret_str += "\t" + self.__row_to_str(index_y) + "\n" - - ret_str += "')" + @staticmethod + def __append_images(images): + width = 0 + height = 0 + for image in images: + width += image.width() + height = max(height, image.height()) + res = Image(width, height) + x_ind = 0 + for image in images: + res.blit(image, 0, 0, image.width(), image.height(), xdest=x_ind) + x_ind += image.width() + return res - return ret_str + @staticmethod + def __same_image(i1, i2): + if i1.width() != i2.width() or i1.height() != i2.height(): + return False + for y in range(i1.height()): + for x in range(i1.width()): + if i1.get_pixel(x, y) != i2.get_pixel(x, y): + return False + return True # This is for generating functions like Image.HEART diff --git a/src/microbit/model/microbit_model.py b/src/microbit/model/microbit_model.py index f8e2572da..521f8def1 100644 --- a/src/microbit/model/microbit_model.py +++ b/src/microbit/model/microbit_model.py @@ -1,6 +1,7 @@ import time from .button import Button +from .display import Display class MicrobitModel: @@ -9,6 +10,7 @@ def __init__(self): self.button_a = Button() self.button_b = Button() self.__start_time = time.time() + self.display = Display() def sleep(self, n): time.sleep(n / 1000) diff --git a/src/microbit/test/test_display.py b/src/microbit/test/test_display.py new file mode 100644 index 000000000..23f3abdda --- /dev/null +++ b/src/microbit/test/test_display.py @@ -0,0 +1,162 @@ +import pytest +import threading +from unittest import mock + +from ..model import constants as CONSTANTS +from ..model.display import Display +from ..model.image import Image +from .. import shim + + +STR_A = "09900:90090:99990:90090:90090" +STR_QUESTION_MARK = "09990:90009:00990:00000:00900" +STR_EXCLAMATION_MARK = "09000:09000:09000:00000:09000:" +STR_SIX = "00090:00900:09990:90009:09990" + + +class TestDisplay(object): + def setup_method(self): + self.display = Display() + + @pytest.mark.parametrize("x, y, brightness", [(1, 1, 4), (2, 3, 6), (4, 4, 9)]) + def test_set_and_get_pixel(self, x, y, brightness): + self.display.set_pixel(x, y, brightness) + assert brightness == self.display.get_pixel(x, y) + + @pytest.mark.parametrize("x, y", [(5, 0), (0, -1), (0, 5)]) + def test_get_pixel_error(self, x, y): + with pytest.raises(ValueError, match=CONSTANTS.INDEX_ERR): + self.display.get_pixel(x, y) + + @pytest.mark.parametrize( + "x, y, brightness, err_msg", + [ + (5, 0, 0, CONSTANTS.INDEX_ERR), + (0, -1, 0, CONSTANTS.INDEX_ERR), + (0, 0, -1, CONSTANTS.BRIGHTNESS_ERR), + ], + ) + def test_set_pixel_error(self, x, y, brightness, err_msg): + with pytest.raises(ValueError, match=err_msg): + self.display.set_pixel(x, y, brightness) + + def test_clear(self): + self.display.set_pixel(2, 3, 7) + self.display.set_pixel(3, 4, 6) + self.display.set_pixel(4, 4, 9) + assert not self.__is_clear() + self.display.clear() + assert self.__is_clear() + + def test_on_off(self): + self.display.on() + assert self.display.is_on() + self.display.off() + assert not self.display.is_on() + + def test_show_one_image(self): + img = Image() + img.set_pixel(0, 0, 8) + img.set_pixel(0, 1, 9) + img.set_pixel(0, 2, 7) + img.set_pixel(2, 2, 6) + self.display.show(img, delay=0) + assert Image._Image__same_image(img, self.display._Display__image) + + def test_show_different_size_image(self): + img = Image(3, 7) + img.set_pixel(1, 1, 9) + img.set_pixel(2, 6, 9) # Will not be on display + expected = Image(5, 5) + expected.set_pixel(1, 1, 9) + self.display.show(img, delay=0) + assert Image._Image__same_image(expected, self.display._Display__image) + + def test_show_smaller_image(self): + img = Image(2, 2) + img.set_pixel(1, 1, 9) + expected = Image(5, 5) + expected.set_pixel(1, 1, 9) + self.display.show(img, delay=0) + assert Image._Image__same_image(expected, self.display._Display__image) + + @pytest.mark.parametrize( + "value, expected_str", + [ + ("!", STR_EXCLAMATION_MARK), + ("A", STR_A), + (" ", CONSTANTS.BLANK_5X5), + (6, STR_SIX), + ("\x7F", STR_QUESTION_MARK), # Character is out of our ASCII range + ], + ) + def test_show_char(self, value, expected_str): + expected = Image(expected_str) + self.display.show(value, delay=0) + assert Image._Image__same_image(expected, self.display._Display__image) + + def test_show_char_with_clear(self): + image = Image(STR_EXCLAMATION_MARK) + self.display.show(image, delay=0, clear=True) + assert self.__is_clear() + + def test_show_iterable(self): + expected = Image(STR_A) + value = [Image(STR_EXCLAMATION_MARK), "A", "ab"] + self.display.show(value, delay=0) + assert Image._Image__same_image(expected, self.display._Display__image) + + def test_show_non_iterable(self): + with pytest.raises(TypeError): + self.display.show(TestDisplay()) + + def test_scroll(self): + self.display.scroll("a b") + self.__is_clear() + + def test_scroll_type_error(self): + with pytest.raises(TypeError): + self.display.scroll(["a", 1]) + + # Should change these threaded tests to test behaviour in the future + def test_show_threaded(self): + threading.Thread = mock.Mock() + self.display.show("a", delay=0, wait=False) + threading.Thread.assert_called_once() + + def test_scroll_threaded(self): + threading.Thread = mock.Mock() + self.display.scroll("test", delay=0, wait=False) + threading.Thread.assert_called_once() + + def test_get_array(self): + self.display.set_pixel(3, 3, 3) + self.display.off() + assert self.display._Display__get_array() == [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + ] + + self.display.on() + assert self.display._Display__get_array() == [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 3, 0], + [0, 0, 0, 0, 0], + ] + + # The second show call should immedaitely stop the first show call. + # Therefore the final result of display should be 6. + def test_async_tests(self): + self.display.show("MMMMMMMMMMMMMM", delay=100, wait=False) + self.display.show("6", delay=0) + assert Image._Image__same_image(Image(STR_SIX), self.display._Display__image) + + # Helpers + def __is_clear(self): + i = Image(CONSTANTS.BLANK_5X5) + return Image._Image__same_image(i, self.display._Display__image)