diff --git a/adafruit_fruitjam/__init__.py b/adafruit_fruitjam/__init__.py index b13483d..6c2d970 100644 --- a/adafruit_fruitjam/__init__.py +++ b/adafruit_fruitjam/__init__.py @@ -15,7 +15,7 @@ **Hardware:** -* `Adafruit Fruit Jam `_" +* `Adafruit Fruit Jam `_ **Software and Dependencies:** @@ -29,13 +29,314 @@ __version__ = "0.0.0+auto.0" __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_FruitJam.git" +import gc +import os +import time + +import board +import busio +import supervisor +import terminalio +from adafruit_esp32spi import adafruit_esp32spi +from adafruit_portalbase import PortalBase +from digitalio import DigitalInOut + +from adafruit_fruitjam.graphics import Graphics +from adafruit_fruitjam.network import CONTENT_IMAGE, CONTENT_JSON, CONTENT_TEXT, Network from adafruit_fruitjam.peripherals import Peripherals -class FruitJam: - def __init__(self): +class FruitJam(PortalBase): + """Class representing the Adafruit Fruit Jam. + + :param url: The URL of your data source. Defaults to ``None``. + :param headers: The headers for authentication, typically used by Azure API's. + :param json_path: The list of json traversal to get data out of. Can be list of lists for + multiple data points. Defaults to ``None`` to not use json. + :param regexp_path: The list of regexp strings to get data out (use a single regexp group). Can + be list of regexps for multiple data points. Defaults to ``None`` to not + use regexp. + :param convert_image: Determine whether or not to use the AdafruitIO image converter service. + Set as False if your image is already resized. Defaults to True. + :param default_bg: The path to your default background image file or a hex color. + Defaults to 0x000000. + :param status_neopixel: The pin for the status NeoPixel. Use ``board.NEOPIXEL`` for the on-board + NeoPixel. Defaults to ``None``, not the status LED + :param str text_font: The path to your font file for your data text display. + :param text_position: The position of your extracted text on the display in an (x, y) tuple. + Can be a list of tuples for when there's a list of json_paths, for example + :param text_color: The color of the text, in 0xRRGGBB format. Can be a list of colors for when + there's multiple texts. Defaults to ``None``. + :param text_wrap: Whether or not to wrap text (for long text data chunks). Defaults to + ``False``, no wrapping. + :param text_maxlen: The max length of the text for text wrapping. Defaults to 0. + :param text_transform: A function that will be called on the text before display + :param int text_scale: The factor to scale the default size of the text by + :param json_transform: A function or a list of functions to call with the parsed JSON. + Changes and additions are permitted for the ``dict`` object. + :param image_json_path: The JSON traversal path for a background image to display. Defaults to + ``None``. + :param image_resize: What size to resize the image we got from the json_path, make this a tuple + of the width and height you want. Defaults to ``None``. + :param image_position: The position of the image on the display as an (x, y) tuple. Defaults to + ``None``. + :param image_dim_json_path: The JSON traversal path for the original dimensions of image tuple. + Used with fetch(). Defaults to ``None``. + :param success_callback: A function we'll call if you like, when we fetch data successfully. + Defaults to ``None``. + :param str caption_text: The text of your caption, a fixed text not changed by the data we get. + Defaults to ``None``. + :param str caption_font: The path to the font file for your caption. Defaults to ``None``. + :param caption_position: The position of your caption on the display as an (x, y) tuple. + Defaults to ``None``. + :param caption_color: The color of your caption. Must be a hex value, e.g. ``0x808000``. + :param image_url_path: The HTTP traversal path for a background image to display. + Defaults to ``None``. + :param esp: A passed ESP32 object, Can be used in cases where the ESP32 chip needs to be used + before calling the pyportal class. Defaults to ``None``. + :param busio.SPI external_spi: A previously declared spi object. Defaults to ``None``. + :param debug: Turn on debug print outs. Defaults to False. + + """ + + def __init__( # noqa: PLR0912,PLR0913,Too many branches,Too many arguments in function definition + self, + *, + url=None, + headers=None, + json_path=None, + regexp_path=None, + convert_image=True, + default_bg=0x000000, + status_neopixel=None, + text_font=terminalio.FONT, + text_position=None, + text_color=0x808080, + text_wrap=False, + text_maxlen=0, + text_transform=None, + text_scale=1, + json_transform=None, + image_json_path=None, + image_resize=None, + image_position=None, + image_dim_json_path=None, + caption_text=None, + caption_font=None, + caption_position=None, + caption_color=0x808080, + image_url_path=None, + success_callback=None, + esp=None, + external_spi=None, + debug=False, + secrets_data=None, + ): + graphics = Graphics( + default_bg=default_bg, + debug=debug, + ) + self._default_bg = default_bg + + spi = board.SPI() + + if image_json_path or image_url_path: + if debug: + print("Init image path") + if not image_position: + image_position = (0, 0) # default to top corner + if not image_resize: + image_resize = ( + self.display.width, + self.display.height, + ) # default to full screen + + if esp is None: + 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) + self.peripherals = Peripherals() + network = Network( + status_neopixel=self.peripherals.neopixels + if status_neopixel is None + else status_neopixel, + esp=esp, + external_spi=spi, + extract_values=False, + convert_image=convert_image, + image_url_path=image_url_path, + image_json_path=image_json_path, + image_resize=image_resize, + image_position=image_position, + image_dim_json_path=image_dim_json_path, + debug=debug, + ) + self.url = url + + super().__init__( + network, + graphics, + url=url, + headers=headers, + json_path=json_path, + regexp_path=regexp_path, + json_transform=json_transform, + success_callback=success_callback, + debug=debug, + ) + + # Convenience Shortcuts for compatibility + + # self.sd_check = self.peripherals.sd_check + # self.play_file = self.peripherals.play_file + # self.stop_play = self.peripherals.stop_play + + self.image_converter_url = self.network.image_converter_url + self.wget = self.network.wget + # self.show_QR = self.graphics.qrcode + # self.hide_QR = self.graphics.hide_QR + + if default_bg is not None: + self.graphics.set_background(default_bg) + + if self._debug: + print("Init caption") + if caption_font: + self._caption_font = self._load_font(caption_font) + self.set_caption(caption_text, caption_position, caption_color) + + if text_font: + if text_position is not None and isinstance(text_position[0], (list, tuple)): + num = len(text_position) + if not text_wrap: + text_wrap = [0] * num + if not text_maxlen: + text_maxlen = [0] * num + if not text_transform: + text_transform = [None] * num + if not isinstance(text_scale, (list, tuple)): + text_scale = [text_scale] * num + else: + num = 1 + text_position = (text_position,) + text_color = (text_color,) + text_wrap = (text_wrap,) + text_maxlen = (text_maxlen,) + text_transform = (text_transform,) + text_scale = (text_scale,) + for i in range(num): + self.add_text( + text_position=text_position[i], + text_font=text_font, + text_color=text_color[i], + text_wrap=text_wrap[i], + text_maxlen=text_maxlen[i], + text_transform=text_transform[i], + text_scale=text_scale[i], + ) + else: + self._text_font = None + self._text = None + + gc.collect() + + def set_caption(self, caption_text, caption_position, caption_color): + """A caption. Requires setting ``caption_font`` in init! + + :param caption_text: The text of the caption. + :param caption_position: The position of the caption text. + :param caption_color: The color of your caption text. Must be a hex value, e.g. + ``0x808000``. + """ + if self._debug: + print("Setting caption to", caption_text) + + if (not caption_text) or (not self._caption_font) or (not caption_position): + return # nothing to do! + + index = self.add_text( + text_position=caption_position, + text_font=self._caption_font, + text_color=caption_color, + is_data=False, + ) + self.set_text(caption_text, index) + + def fetch(self, refresh_url=None, timeout=10, force_content_type=None): # noqa: PLR0912 Too many branches + """Fetch data from the url we initialized with, perfom any parsing, + and display text or graphics. This function does pretty much everything + Optionally update the URL + """ + + if refresh_url: + self.url = refresh_url + + response = self.network.fetch(self.url, headers=self._headers, timeout=timeout) + + json_out = None + if not force_content_type: + content_type = self.network.check_response(response) + else: + content_type = force_content_type + json_path = self._json_path + + if content_type == CONTENT_JSON: + if json_path is not None: + # Drill down to the json path and set json_out as that node + if isinstance(json_path, (list, tuple)) and ( + not json_path or not isinstance(json_path[0], (list, tuple)) + ): + json_path = (json_path,) + try: + gc.collect() + json_out = response.json() + if self._debug: + print(json_out) + gc.collect() + except ValueError: # failed to parse? + print("Couldn't parse json: ", response.text) + raise + except MemoryError: + supervisor.reload() + if content_type == CONTENT_IMAGE: + try: + filename, position = self.network.process_image( + json_out, self.peripherals.sd_check() + ) + if filename and position is not None: + self.graphics.set_background(filename, position) + except ValueError as error: + print("Error displaying cached image. " + error.args[0]) + if self._default_bg is not None: + self.graphics.set_background(self._default_bg) + except KeyError as error: + print("Error finding image data. '" + error.args[0] + "' not found.") + self.set_background(self._default_bg) + + if content_type == CONTENT_JSON: + values = self.network.process_json(json_out, json_path) + elif content_type == CONTENT_TEXT: + values = self.network.process_text(response.text, self._regexp_path) + + # if we have a callback registered, call it now + if self._success_callback: + self._success_callback(values) + + self._fill_text_labels(values) + # Clean up + json_out = None + response = None + gc.collect() + + if len(values) == 1: + values = values[0] + + return values + @property def neopixels(self): return self.peripherals.neopixels diff --git a/adafruit_fruitjam/graphics.py b/adafruit_fruitjam/graphics.py new file mode 100644 index 0000000..fdb6fc5 --- /dev/null +++ b/adafruit_fruitjam/graphics.py @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams, written for Adafruit Industries +# SPDX-FileCopyrightText: 2025 Tim Cocks, written for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense +""" +`adafruit_fruitjam.graphics` +================================================================================ + +Graphics Helper library for the Adafruit Fruit Jam. + +* Author(s): Melissa LeBlanc-Williams, Tim Cocks + +Implementation Notes +-------------------- + +**Hardware:** + +* `Adafruit Fruit Jam `_ + +**Software and Dependencies:** + +* Adafruit CircuitPython firmware for the supported boards: + https://github.com/adafruit/circuitpython/releases + +""" + +import supervisor +from adafruit_portalbase.graphics import GraphicsBase + +from adafruit_fruitjam.peripherals import request_display_config + +__version__ = "0.0.0+auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_FruitJam.git" + + +class Graphics(GraphicsBase): + """Graphics Helper library for the Adafruit Fruit Jam. + + :param default_bg: The path to your default background image file or a hex color. + Defaults to 0x000000. + :param int width: The total width of the display(s) in Pixels. Defaults to 64. + :param int height: The total height of the display(s) in Pixels. Defaults to 32. + :param int bit_depth: The number of bits per color channel. Defaults to 2. + :param list alt_addr_pins: An alternate set of address pins to use. Defaults to None + :param string color_order: A string containing the letter "R", "G", and "B" in the + order you want. Defaults to "RGB" + :param bool Serpentine: Used when panels are arranged in a serpentine pattern rather + than a Z-pattern. Defaults to True. + :param int tiles_rows: Used to indicate the number of rows the panels are arranged in. + Defaults to 1. + :param debug: Turn on debug print outs. Defaults to False. + + """ + + def __init__( + self, + **kwargs, + ): + default_bg = 0x000000 + debug = False + if "default_bg" in kwargs: + default_bg = kwargs.pop("default_bg") + if "debug" in kwargs: + debug = kwargs.pop("debug") + + if supervisor.runtime.display is None: + request_display_config(640, 480) + super().__init__(supervisor.runtime.display, default_bg=default_bg, debug=debug) diff --git a/adafruit_fruitjam/network.py b/adafruit_fruitjam/network.py new file mode 100644 index 0000000..3391297 --- /dev/null +++ b/adafruit_fruitjam/network.py @@ -0,0 +1,212 @@ +# SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams, written for Adafruit Industries +# SPDX-FileCopyrightText: 2025 Tim Cocks, written for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense +""" +`adafruit_fruitjam.network` +================================================================================ + +CircuitPython PortalBase network driver for Adafruit Fruit Jam. + +* Author(s): Limor Fried, Kevin J. Walters, Melissa LeBlanc-Williams, Tim Cocks + +Implementation Notes +-------------------- + +**Hardware:** + +* `Adafruit Fruit Jam `_ + +**Software and Dependencies:** + +* Adafruit CircuitPython firmware for the supported boards: + https://github.com/adafruit/circuitpython/releases + +""" + +import gc + +import microcontroller +import neopixel +from adafruit_portalbase.network import ( + CONTENT_IMAGE, + CONTENT_JSON, + CONTENT_TEXT, + NetworkBase, +) +from adafruit_portalbase.wifi_coprocessor import WiFi + +__version__ = "0.0.0+auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_FruitJam.git" + +# you'll need to pass in an io username, width, height, format (bit depth), io key, and then url! +IMAGE_CONVERTER_SERVICE = ( + "https://io.adafruit.com/api/v2/%s/integrations/image-formatter?" + "x-aio-key=%s&width=%d&height=%d&output=BMP%d&url=%s" +) + + +class Network(NetworkBase): + """CircuitPython PortalBase network driver for Adafruit Fruit Jam. + + :param status_neopixel: The pin for the status NeoPixel. Use ``board.NEOPIXEL`` for the on-board + NeoPixel. Defaults to ``None``, not the status LED. Or pass an + instantiated NeoPixel object. + :param esp: A passed ESP32 object, Can be used in cases where the ESP32 chip needs to be used + before calling the fruitjam class. Defaults to ``None``. + :param busio.SPI external_spi: A previously declared spi object. Defaults to ``None``. + :param bool extract_values: If true, single-length fetched values are automatically extracted + from lists and tuples. Defaults to ``True``. + :param debug: Turn on debug print outs. Defaults to False. + :param convert_image: Determine whether or not to use the AdafruitIO image converter service. + Set as False if your image is already resized. Defaults to True. + :param image_url_path: The HTTP traversal path for a background image to display. + Defaults to ``None``. + :param image_json_path: The JSON traversal path for a background image to display. Defaults to + ``None``. + :param image_resize: What size to resize the image we got from the json_path, make this a tuple + of the width and height you want. Defaults to ``None``. + :param image_position: The position of the image on the display as an (x, y) tuple. Defaults to + ``None``. + :param image_dim_json_path: The JSON traversal path for the original dimensions of image tuple. + Used with fetch(). Defaults to ``None``. + + """ + + def __init__( # noqa: PLR0913 Too many arguments in function definition + self, + *, + status_neopixel=None, + esp=None, + external_spi=None, + extract_values=True, + debug=False, + convert_image=True, + image_url_path=None, + image_json_path=None, + image_resize=None, + image_position=None, + image_dim_json_path=None, + ): + print(f"status_neopixel", status_neopixel) + if isinstance(status_neopixel, microcontroller.Pin): + status_led = neopixel.NeoPixel(status_neopixel, 1, brightness=0.2) + elif isinstance(status_neopixel, neopixel.NeoPixel): + status_led = status_neopixel + else: + status_led = None + + wifi = WiFi(status_led=status_led, esp=esp, external_spi=external_spi) + + super().__init__( + wifi, + extract_values=extract_values, + debug=debug, + ) + + self._convert_image = convert_image + self._image_json_path = image_json_path + self._image_url_path = image_url_path + self._image_resize = image_resize + self._image_position = image_position + self._image_dim_json_path = image_dim_json_path + gc.collect() + + @property + def ip_address(self): + """Return the IP Address nicely formatted""" + return self._wifi.esp.pretty_ip(self._wifi.esp.ip_address) + + def image_converter_url(self, image_url, width, height, color_depth=16): + """Generate a converted image url from the url passed in, + with the given width and height. aio_username and aio_key must be + set in secrets.""" + try: + aio_username = self._get_setting("AIO_USERNAME") + aio_key = self._get_setting("AIO_KEY") + except KeyError as error: + raise KeyError( + "\n\nOur image converter service require a login/password to rate-limit. " + "Please register for a free adafruit.io account and place the user/key in " + "your secrets file under 'aio_username' and 'aio_key'" + ) from error + + return IMAGE_CONVERTER_SERVICE % ( + aio_username, + aio_key, + width, + height, + color_depth, + image_url, + ) + + def process_image(self, json_data, sd_card=False): # noqa: PLR0912 Too many branches + """ + Process image content + + :param json_data: The JSON data that we can pluck values from + :param bool sd_card: Whether or not we have an SD card inserted + + """ + filename = None + position = None + image_url = None + + if self._image_url_path: + image_url = self._image_url_path + + if self._image_json_path: + image_url = self.json_traverse(json_data, self._image_json_path) + + iwidth = 0 + iheight = 0 + if self._image_dim_json_path: + iwidth = int(self.json_traverse(json_data, self._image_dim_json_path[0])) + iheight = int(self.json_traverse(json_data, self._image_dim_json_path[1])) + print("image dim:", iwidth, iheight) + + if image_url: + print("original URL:", image_url) + if self._convert_image: + if iwidth < iheight: + image_url = self.image_converter_url( + image_url, + int(self._image_resize[1] * self._image_resize[1] / self._image_resize[0]), + self._image_resize[1], + ) + else: + image_url = self.image_converter_url( + image_url, self._image_resize[0], self._image_resize[1] + ) + + print("convert URL:", image_url) + # convert image to bitmap and cache + # print("**not actually wgetting**") + filename = "/cache.bmp" + chunk_size = 4096 # default chunk size is 12K (for QSPI) + if sd_card: + filename = "/sd" + filename + chunk_size = 512 # current bug in big SD writes -> stick to 1 block + try: + self.wget(image_url, filename, chunk_size=chunk_size) + except OSError as error: + raise OSError( + """\n\nNo writable filesystem found for saving datastream. + Insert an SD card or set internal filesystem to be unsafe by + setting 'disable_concurrent_write_protection' in the mount options in boot.py""" + ) from error + except RuntimeError as error: + raise RuntimeError("wget didn't write a complete file") from error + if iwidth < iheight: + pwidth = int(self._image_resize[1] * self._image_resize[1] / self._image_resize[0]) + position = ( + self._image_position[0] + int((self._image_resize[0] - pwidth) / 2), + self._image_position[1], + ) + else: + position = self._image_position + + image_url = None + gc.collect() + + return filename, position diff --git a/adafruit_fruitjam/peripherals.py b/adafruit_fruitjam/peripherals.py index 0e88ba0..76a47b1 100644 --- a/adafruit_fruitjam/peripherals.py +++ b/adafruit_fruitjam/peripherals.py @@ -15,7 +15,7 @@ **Hardware:** -* `Adafruit Fruit Jam `_" +* `Adafruit Fruit Jam `_ **Software and Dependencies:** diff --git a/docs/conf.py b/docs/conf.py index 86e8ebb..8fb2cba 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,7 +24,14 @@ # Uncomment the below if you use native CircuitPython modules such as # digitalio, micropython and busio. List the modules you use. Without it, the # autodoc module docs will fail to generate with a warning. -autodoc_mock_imports = ["displayio", "supervisor", "framebufferio", "picodvi", "audiobusio"] +autodoc_mock_imports = [ + "displayio", + "supervisor", + "framebufferio", + "picodvi", + "audiobusio", + "terminalio", +] autodoc_preserve_defaults = True diff --git a/examples/fruitjam_displaycheck.py b/examples/fruitjam_displaycheck.py new file mode 100644 index 0000000..e568139 --- /dev/null +++ b/examples/fruitjam_displaycheck.py @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +import supervisor + +from adafruit_fruitjam.peripherals import request_display_config + +print(f"Display is None ? {supervisor.runtime.display is None}") +print(f"size: {supervisor.runtime.display.width}, {supervisor.runtime.display.height}") +request_display_config(360, 200) +print(f"size: {supervisor.runtime.display.width}, {supervisor.runtime.display.height}") diff --git a/examples/fruitjam_simpletest.py b/examples/fruitjam_simpletest.py index e568139..167c03e 100644 --- a/examples/fruitjam_simpletest.py +++ b/examples/fruitjam_simpletest.py @@ -1,12 +1,27 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries +# SPDX-FileCopyrightText: 2025 Tim Cocks, written for Adafruit Industries # -# SPDX-License-Identifier: Unlicense +# SPDX-License-Identifier: MIT -import supervisor +# NOTE: Make sure you've created your settings.toml file before running this example +# https://learn.adafruit.com/adafruit-pyportal/create-your-settings-toml-file -from adafruit_fruitjam.peripherals import request_display_config +from adafruit_fruitjam import FruitJam -print(f"Display is None ? {supervisor.runtime.display is None}") -print(f"size: {supervisor.runtime.display.width}, {supervisor.runtime.display.height}") -request_display_config(360, 200) -print(f"size: {supervisor.runtime.display.width}, {supervisor.runtime.display.height}") +# Set a data source URL +TEXT_URL = "http://wifitest.adafruit.com/testwifi/index.html" + +# Create the PyPortal object +fruitjam = FruitJam(url=TEXT_URL, text_position=(10, 20)) +fruitjam.neopixels.brightness = 0.1 + +# Go get that data +print("Fetching text from", TEXT_URL) +data = fruitjam.fetch() + +# Print out what we got +print("-" * 40) +print(data) +print("-" * 40) + +while True: + pass diff --git a/requirements.txt b/requirements.txt index f49e7a3..331814a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,8 @@ Adafruit-Blinka adafruit-circuitpython-busdevice adafruit-circuitpython-tlv320 adafruit-circuitpython-neopixel +adafruit-circuitpython-portalbase +adafruit-circuitpython-esp32spi +adafruit-circuitpython-requests +adafruit-circuitpython-bitmap-font +adafruit-circuitpython-display-text diff --git a/ruff.toml b/ruff.toml index 970c292..0f3565b 100644 --- a/ruff.toml +++ b/ruff.toml @@ -93,6 +93,8 @@ ignore = [ "PLR2004", # magic-value-comparison "UP030", # format literals "PLW1514", # unspecified-encoding + "PLR0914", # Too many locals + "PLR0915", # Too many statements ]