extending portalbase, add example that uses network fetch

This commit is contained in:
foamyguy 2025-07-21 15:51:14 -05:00
parent 76d4f22524
commit 6043362d3a
7 changed files with 624 additions and 10 deletions

View file

@ -29,13 +29,314 @@ Implementation Notes
__version__ = "0.0.0+auto.0" __version__ = "0.0.0+auto.0"
__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_FruitJam.git" __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 from adafruit_fruitjam.peripherals import Peripherals
class FruitJam: class FruitJam(PortalBase):
def __init__(self): """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() 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 @property
def neopixels(self): def neopixels(self):
return self.peripherals.neopixels return self.peripherals.neopixels

View file

@ -0,0 +1,67 @@
# SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams, 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 <https://www.adafruit.com/product/6200>`_
**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)

View file

@ -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 <https://www.adafruit.com/product/6200>`_
**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

View file

@ -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}")

View file

@ -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}") # Set a data source URL
print(f"size: {supervisor.runtime.display.width}, {supervisor.runtime.display.height}") TEXT_URL = "http://wifitest.adafruit.com/testwifi/index.html"
request_display_config(360, 200)
print(f"size: {supervisor.runtime.display.width}, {supervisor.runtime.display.height}") # 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

View file

@ -7,3 +7,8 @@ Adafruit-Blinka
adafruit-circuitpython-busdevice adafruit-circuitpython-busdevice
adafruit-circuitpython-tlv320 adafruit-circuitpython-tlv320
adafruit-circuitpython-neopixel adafruit-circuitpython-neopixel
adafruit-circuitpython-portalbase
adafruit-circuitpython-esp32spi
adafruit-circuitpython-requests
adafruit-circuitpython-bitmap-font
adafruit-circuitpython-display-text

View file

@ -93,6 +93,8 @@ ignore = [
"PLR2004", # magic-value-comparison "PLR2004", # magic-value-comparison
"UP030", # format literals "UP030", # format literals
"PLW1514", # unspecified-encoding "PLW1514", # unspecified-encoding
"PLR0914", # Too many locals
"PLR0915", # Too many statements
] ]