Compare commits
59 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e67239b37b | ||
|
|
b4f18f7d8f | ||
|
|
e8a2a77ac6 | ||
|
|
8ee337c686 | ||
|
|
7682a56c51 | ||
|
|
6f0204bf34 | ||
|
|
733ef1b925 | ||
|
|
45d7cc1232 | ||
|
|
c41e65bdeb | ||
|
|
f2325f3811 | ||
|
|
5eabd3d955 | ||
|
|
3f33c18ddf | ||
|
|
9259a09330 | ||
|
|
1f46569c1d | ||
|
|
cb5d1db622 | ||
|
|
d2ae05eaed | ||
|
|
2e19356d9a | ||
|
|
96e4575d2e | ||
|
|
75e5fb7ac2 | ||
|
|
6b86bc95a6 | ||
|
|
fc388c5ccb | ||
|
|
d1d0bb0932 | ||
|
|
544adaa447 | ||
|
|
eb35d69e1b | ||
|
|
4998356604 | ||
|
|
a722ba1835 | ||
|
|
9825076bcf | ||
|
|
c332f74892 | ||
|
|
f35068b0d3 | ||
|
|
a0327ac8e6 | ||
|
|
b8b1770da5 | ||
|
|
09af721cf7 | ||
|
|
456851aabf | ||
|
|
e95edb3e3d | ||
|
|
0e0ab5431f | ||
|
|
3aebe8dc9d | ||
|
|
d883e687bb | ||
|
|
f3e13ff5c7 | ||
|
|
153b177a3f | ||
|
|
9175c448f6 | ||
|
|
6224c6510d | ||
|
|
71dbc2ae38 | ||
|
|
3cd3c6e596 | ||
|
|
bfc6140e6d | ||
|
|
f5b3367c2b | ||
|
|
153e078870 | ||
|
|
17d3868ed6 | ||
|
|
2a5d7e81e7 | ||
|
|
5932bf058c | ||
|
|
7de2ec3d14 | ||
|
|
666b515b80 | ||
|
|
410c467492 | ||
|
|
cc4a293e22 | ||
|
|
6043362d3a | ||
|
|
76d4f22524 | ||
|
|
332fe1e871 | ||
|
|
8d3f1ab83a | ||
|
|
d774cd952e | ||
|
|
9a34a9d5b0 |
20 changed files with 1167 additions and 38 deletions
|
|
@ -15,7 +15,7 @@ Implementation Notes
|
||||||
|
|
||||||
**Hardware:**
|
**Hardware:**
|
||||||
|
|
||||||
* `Adafruit Fruit Jam <url>`_"
|
* `Adafruit Fruit Jam <https://www.adafruit.com/product/6200>`_
|
||||||
|
|
||||||
**Software and Dependencies:**
|
**Software and Dependencies:**
|
||||||
|
|
||||||
|
|
@ -29,13 +29,338 @@ 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 or status_neopixel == board.NEOPIXEL
|
||||||
|
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.play_mp3_file = self.peripherals.play_mp3_file
|
||||||
|
self.stop_play = self.peripherals.stop_play
|
||||||
|
self.volume = self.peripherals.volume
|
||||||
|
self.audio_output = self.peripherals.audio_output
|
||||||
|
|
||||||
|
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)
|
||||||
|
if caption_text is not None:
|
||||||
|
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 sync_time(self, **kwargs):
|
||||||
|
"""Set the system RTC via NTP using this FruitJam's Network.
|
||||||
|
|
||||||
|
This is a convenience wrapper for ``self.network.sync_time(...)``.
|
||||||
|
|
||||||
|
:param str server: Override NTP host (defaults to ``NTP_SERVER`` or
|
||||||
|
``"pool.ntp.org"`` if unset). (Pass via ``server=...`` in kwargs.)
|
||||||
|
:param float tz_offset: Override hours from UTC (defaults to ``NTP_TZ``;
|
||||||
|
``NTP_DST`` is still added). (Pass via ``tz_offset=...``.)
|
||||||
|
:param dict tuning: Advanced options dict (optional). Supported keys:
|
||||||
|
``timeout`` (float, socket timeout seconds; defaults to ``NTP_TIMEOUT`` or 5.0),
|
||||||
|
``cache_seconds`` (int; defaults to ``NTP_CACHE_SECONDS`` or 0),
|
||||||
|
``require_year`` (int; defaults to ``NTP_REQUIRE_YEAR`` or 2022).
|
||||||
|
(Pass via ``tuning={...}``.)
|
||||||
|
|
||||||
|
:returns: Synced time
|
||||||
|
:rtype: time.struct_time
|
||||||
|
"""
|
||||||
|
return self.network.sync_time(**kwargs)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
|
||||||
99
adafruit_fruitjam/graphics.py
Normal file
99
adafruit_fruitjam/graphics.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
# 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 <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)
|
||||||
|
|
||||||
|
def qrcode(self, qr_data, *, qr_size=1, x=0, y=0, hide_background=False): # noqa: PLR0913 Too many arguments in function definition
|
||||||
|
"""Display a QR code
|
||||||
|
|
||||||
|
:param qr_data: The data for the QR code.
|
||||||
|
:param int qr_size: The scale of the QR code.
|
||||||
|
:param x: The x position of upper left corner of the QR code on the display.
|
||||||
|
:param y: The y position of upper left corner of the QR code on the display.
|
||||||
|
:param hide_background: Hide the background while showing the QR code.
|
||||||
|
|
||||||
|
"""
|
||||||
|
super().qrcode(
|
||||||
|
qr_data,
|
||||||
|
qr_size=qr_size,
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
)
|
||||||
|
if hide_background:
|
||||||
|
self.display.root_group = self._qr_group
|
||||||
|
self._qr_only = hide_background
|
||||||
|
|
||||||
|
def hide_QR(self):
|
||||||
|
"""Clear any QR codes that are currently on the screen"""
|
||||||
|
|
||||||
|
if self._qr_only:
|
||||||
|
self.display.root_group = self.root_group
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self._qr_group.pop()
|
||||||
|
except (IndexError, AttributeError): # later test if empty
|
||||||
|
pass
|
||||||
347
adafruit_fruitjam/network.py
Normal file
347
adafruit_fruitjam/network.py
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
# SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams, written for Adafruit Industries
|
||||||
|
# SPDX-FileCopyrightText: 2025 Tim Cocks, written for Adafruit Industries
|
||||||
|
# SPDX-FileCopyrightText: 2025 Mikey Sklar, 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 os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import adafruit_connection_manager as acm
|
||||||
|
import adafruit_ntp
|
||||||
|
import microcontroller
|
||||||
|
import neopixel
|
||||||
|
import rtc
|
||||||
|
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,
|
||||||
|
):
|
||||||
|
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
|
||||||
|
|
||||||
|
def sync_time(self, server=None, tz_offset=None, tuning=None):
|
||||||
|
"""
|
||||||
|
Set the system RTC via NTP using this Network's Wi-Fi connection.
|
||||||
|
|
||||||
|
Reads optional settings from settings.toml:
|
||||||
|
|
||||||
|
NTP_SERVER – NTP host (default: "pool.ntp.org")
|
||||||
|
NTP_TZ – timezone offset in hours (float, default: 0)
|
||||||
|
NTP_DST – extra offset for daylight saving (0=no, 1=yes; default: 0)
|
||||||
|
NTP_INTERVAL – re-sync interval in seconds (default: 3600, not used internally)
|
||||||
|
|
||||||
|
NTP_TIMEOUT – socket timeout per attempt (seconds, default: 5.0)
|
||||||
|
NTP_CACHE_SECONDS – cache results, 0 = always fetch fresh (default: 0)
|
||||||
|
NTP_REQUIRE_YEAR – minimum acceptable year (default: 2022)
|
||||||
|
|
||||||
|
NTP_RETRIES – number of NTP fetch attempts on timeout (default: 8)
|
||||||
|
NTP_DELAY_S – delay between retries in seconds (default: 1.0)
|
||||||
|
|
||||||
|
Keyword args:
|
||||||
|
server (str) – override NTP_SERVER
|
||||||
|
tz_offset (float) – override NTP_TZ (+ NTP_DST still applied)
|
||||||
|
tuning (dict) – override tuning knobs, e.g.:
|
||||||
|
{
|
||||||
|
"timeout": 5.0,
|
||||||
|
"cache_seconds": 0,
|
||||||
|
"require_year": 2022,
|
||||||
|
"retries": 8,
|
||||||
|
"retry_delay": 1.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
time.struct_time
|
||||||
|
"""
|
||||||
|
# Ensure Wi-Fi up
|
||||||
|
self.connect()
|
||||||
|
|
||||||
|
# Socket pool
|
||||||
|
pool = acm.get_radio_socketpool(self._wifi.esp)
|
||||||
|
|
||||||
|
# Settings & overrides
|
||||||
|
server = server or os.getenv("NTP_SERVER") or "pool.ntp.org"
|
||||||
|
tz = tz_offset if tz_offset is not None else _combined_tz_offset(0.0)
|
||||||
|
t = tuning or {}
|
||||||
|
|
||||||
|
timeout = float(t.get("timeout", _get_float_env("NTP_TIMEOUT", 5.0)))
|
||||||
|
cache_seconds = int(t.get("cache_seconds", _get_int_env("NTP_CACHE_SECONDS", 0)))
|
||||||
|
require_year = int(t.get("require_year", _get_int_env("NTP_REQUIRE_YEAR", 2022)))
|
||||||
|
ntp_retries = int(t.get("retries", _get_int_env("NTP_RETRIES", 8)))
|
||||||
|
ntp_delay_s = float(t.get("retry_delay", _get_float_env("NTP_DELAY_S", 1.0)))
|
||||||
|
|
||||||
|
# NTP client
|
||||||
|
ntp = adafruit_ntp.NTP(
|
||||||
|
pool,
|
||||||
|
server=server,
|
||||||
|
tz_offset=tz,
|
||||||
|
socket_timeout=timeout,
|
||||||
|
cache_seconds=cache_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attempt fetch (retries on timeout)
|
||||||
|
now = _ntp_get_datetime(
|
||||||
|
ntp,
|
||||||
|
connect_cb=self.connect,
|
||||||
|
retries=ntp_retries,
|
||||||
|
delay_s=ntp_delay_s,
|
||||||
|
debug=getattr(self, "_debug", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sanity check & commit
|
||||||
|
if now.tm_year < require_year:
|
||||||
|
raise RuntimeError("NTP returned an unexpected year; not setting RTC")
|
||||||
|
|
||||||
|
rtc.RTC().datetime = now
|
||||||
|
return now
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Internal helpers to keep sync_time() small and Ruff-friendly ----
|
||||||
|
|
||||||
|
|
||||||
|
def _get_float_env(name, default):
|
||||||
|
v = os.getenv(name)
|
||||||
|
try:
|
||||||
|
return float(v) if v not in {None, ""} else float(default)
|
||||||
|
except Exception:
|
||||||
|
return float(default)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_int_env(name, default):
|
||||||
|
v = os.getenv(name)
|
||||||
|
if v in {None, ""}:
|
||||||
|
return int(default)
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
return int(float(v)) # tolerate "5.0"
|
||||||
|
except Exception:
|
||||||
|
return int(default)
|
||||||
|
|
||||||
|
|
||||||
|
def _combined_tz_offset(base_default):
|
||||||
|
"""Return tz offset hours including DST via env (NTP_TZ + NTP_DST)."""
|
||||||
|
tz = _get_float_env("NTP_TZ", base_default)
|
||||||
|
dst = _get_float_env("NTP_DST", 0)
|
||||||
|
return tz + dst
|
||||||
|
|
||||||
|
|
||||||
|
def _ntp_get_datetime(ntp, connect_cb, retries, delay_s, debug=False):
|
||||||
|
"""Fetch ntp.datetime with limited retries on timeout; re-connect between tries."""
|
||||||
|
for i in range(retries):
|
||||||
|
last_exc = None
|
||||||
|
try:
|
||||||
|
return ntp.datetime # struct_time
|
||||||
|
except OSError as e:
|
||||||
|
last_exc = e
|
||||||
|
is_timeout = (getattr(e, "errno", None) == 116) or ("ETIMEDOUT" in str(e))
|
||||||
|
if not is_timeout:
|
||||||
|
break
|
||||||
|
if debug:
|
||||||
|
print(f"NTP timeout, attempt {i + 1}/{retries}")
|
||||||
|
connect_cb() # re-assert Wi-Fi using existing policy
|
||||||
|
time.sleep(delay_s)
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
last_exc = e
|
||||||
|
break
|
||||||
|
if last_exc:
|
||||||
|
raise last_exc
|
||||||
|
raise RuntimeError("NTP sync failed")
|
||||||
|
|
@ -15,7 +15,7 @@ Implementation Notes
|
||||||
|
|
||||||
**Hardware:**
|
**Hardware:**
|
||||||
|
|
||||||
* `Adafruit Fruit Jam <url>`_"
|
* `Adafruit Fruit Jam <https://www.adafruit.com/product/6200>`_
|
||||||
|
|
||||||
**Software and Dependencies:**
|
**Software and Dependencies:**
|
||||||
|
|
||||||
|
|
@ -26,13 +26,21 @@ Implementation Notes
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import adafruit_sdcard
|
||||||
import adafruit_tlv320
|
import adafruit_tlv320
|
||||||
import audiobusio
|
import audiobusio
|
||||||
|
import audiocore
|
||||||
import board
|
import board
|
||||||
|
import busio
|
||||||
|
import digitalio
|
||||||
import displayio
|
import displayio
|
||||||
import framebufferio
|
import framebufferio
|
||||||
import picodvi
|
import picodvi
|
||||||
|
import storage
|
||||||
import supervisor
|
import supervisor
|
||||||
|
from adafruit_simplemath import map_range
|
||||||
from digitalio import DigitalInOut, Direction, Pull
|
from digitalio import DigitalInOut, Direction, Pull
|
||||||
from neopixel import NeoPixel
|
from neopixel import NeoPixel
|
||||||
|
|
||||||
|
|
@ -48,7 +56,7 @@ COLOR_DEPTH_LUT = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def request_display_config(width, height):
|
def request_display_config(width=None, height=None, color_depth=None):
|
||||||
"""
|
"""
|
||||||
Request a display size configuration. If the display is un-initialized,
|
Request a display size configuration. If the display is un-initialized,
|
||||||
or is currently using a different configuration it will be initialized
|
or is currently using a different configuration it will be initialized
|
||||||
|
|
@ -56,40 +64,86 @@ def request_display_config(width, height):
|
||||||
|
|
||||||
This function will set the initialized display to ``supervisor.runtime.display``
|
This function will set the initialized display to ``supervisor.runtime.display``
|
||||||
|
|
||||||
:param width: The width of the display in pixels.
|
:param width: The width of the display in pixels. Leave unspecified to default
|
||||||
:param height: The height of the display in pixels.
|
to the ``CIRCUITPY_DISPLAY_WIDTH`` environmental variable if provided. Otherwise,
|
||||||
|
a ``ValueError`` exception will be thrown.
|
||||||
|
:param height: The height of the display in pixels. Leave unspecified to default
|
||||||
|
to the appropriate height for the provided width.
|
||||||
|
:param color_depth: The color depth of the display in bits.
|
||||||
|
Valid values are 1, 2, 4, 8, 16, 32. Larger resolutions must use
|
||||||
|
smaller color_depths due to RAM limitations. Default color_depth for
|
||||||
|
720 and 640 width is 8, and default color_depth for 320 and 360 width
|
||||||
|
is 16.
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
if (width, height) not in VALID_DISPLAY_SIZES:
|
# if user does not specify width, use default configuration
|
||||||
|
if width is None and (width := os.getenv("CIRCUITPY_DISPLAY_WIDTH")) is None:
|
||||||
|
raise ValueError("No CIRCUITPY_DISPLAY_WIDTH specified in settings.toml.")
|
||||||
|
|
||||||
|
# check that we have a valid display size
|
||||||
|
if (height is not None and (width, height) not in VALID_DISPLAY_SIZES) or (
|
||||||
|
height is None and width not in [size[0] for size in VALID_DISPLAY_SIZES]
|
||||||
|
):
|
||||||
raise ValueError(f"Invalid display size. Must be one of: {VALID_DISPLAY_SIZES}")
|
raise ValueError(f"Invalid display size. Must be one of: {VALID_DISPLAY_SIZES}")
|
||||||
|
|
||||||
displayio.release_displays()
|
# if user does not specify height, use matching height
|
||||||
fb = picodvi.Framebuffer(
|
if height is None:
|
||||||
width,
|
height = next((h for w, h in VALID_DISPLAY_SIZES if width == w))
|
||||||
height,
|
|
||||||
clk_dp=board.CKP,
|
# if user does not specify a requested color_depth
|
||||||
clk_dn=board.CKN,
|
if color_depth is None:
|
||||||
red_dp=board.D0P,
|
# use the maximum color depth for given width
|
||||||
red_dn=board.D0N,
|
color_depth = COLOR_DEPTH_LUT[width]
|
||||||
green_dp=board.D1P,
|
|
||||||
green_dn=board.D1N,
|
requested_config = (width, height, color_depth)
|
||||||
blue_dp=board.D2P,
|
|
||||||
blue_dn=board.D2N,
|
if requested_config != get_display_config():
|
||||||
color_depth=COLOR_DEPTH_LUT[width],
|
displayio.release_displays()
|
||||||
)
|
fb = picodvi.Framebuffer(
|
||||||
supervisor.runtime.display = framebufferio.FramebufferDisplay(fb)
|
width,
|
||||||
|
height,
|
||||||
|
clk_dp=board.CKP,
|
||||||
|
clk_dn=board.CKN,
|
||||||
|
red_dp=board.D0P,
|
||||||
|
red_dn=board.D0N,
|
||||||
|
green_dp=board.D1P,
|
||||||
|
green_dn=board.D1N,
|
||||||
|
blue_dp=board.D2P,
|
||||||
|
blue_dn=board.D2N,
|
||||||
|
color_depth=color_depth,
|
||||||
|
)
|
||||||
|
supervisor.runtime.display = framebufferio.FramebufferDisplay(fb)
|
||||||
|
|
||||||
|
|
||||||
|
def get_display_config():
|
||||||
|
"""
|
||||||
|
Get the current display size configuration.
|
||||||
|
|
||||||
|
:return: display_config: Tuple containing the width, height, and color_depth of the display
|
||||||
|
in pixels and bits respectively.
|
||||||
|
"""
|
||||||
|
|
||||||
|
display = supervisor.runtime.display
|
||||||
|
if display is not None:
|
||||||
|
display_config = (display.width, display.height, display.framebuffer.color_depth)
|
||||||
|
return display_config
|
||||||
|
else:
|
||||||
|
return (None, None, None)
|
||||||
|
|
||||||
|
|
||||||
class Peripherals:
|
class Peripherals:
|
||||||
"""Peripherals Helper Class for the FruitJam Library
|
"""Peripherals Helper Class for the FruitJam Library
|
||||||
|
|
||||||
|
:param audio_output: The audio output interface to use 'speaker' or 'headphone'
|
||||||
|
:param safe_volume_limit: The maximum volume allowed for the audio output. Default is 15
|
||||||
|
Using higher values can damage some speakers, change at your own risk.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
neopixels (NeoPxiels): The NeoPixels on the Fruit Jam board.
|
neopixels (NeoPxiels): The NeoPixels on the Fruit Jam board.
|
||||||
See https://circuitpython.readthedocs.io/projects/neopixel/en/latest/api.html
|
See https://circuitpython.readthedocs.io/projects/neopixel/en/latest/api.html
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, audio_output="headphone", safe_volume_limit=12):
|
||||||
self.neopixels = NeoPixel(board.NEOPIXEL, 5)
|
self.neopixels = NeoPixel(board.NEOPIXEL, 5)
|
||||||
|
|
||||||
self._buttons = []
|
self._buttons = []
|
||||||
|
|
@ -105,11 +159,44 @@ class Peripherals:
|
||||||
# set sample rate & bit depth
|
# set sample rate & bit depth
|
||||||
self._dac.configure_clocks(sample_rate=11030, bit_depth=16)
|
self._dac.configure_clocks(sample_rate=11030, bit_depth=16)
|
||||||
|
|
||||||
# use headphones
|
self._audio_output = audio_output
|
||||||
self._dac.headphone_output = True
|
self.audio_output = audio_output
|
||||||
self._dac.headphone_volume = -15 # dB
|
|
||||||
|
|
||||||
self._audio = audiobusio.I2SOut(board.I2S_BCLK, board.I2S_WS, board.I2S_DIN)
|
self._audio = audiobusio.I2SOut(board.I2S_BCLK, board.I2S_WS, board.I2S_DIN)
|
||||||
|
if safe_volume_limit < 1 or safe_volume_limit > 20:
|
||||||
|
raise ValueError("safe_volume_limit must be between 1 and 20")
|
||||||
|
self.safe_volume_limit = safe_volume_limit
|
||||||
|
self._volume = 7
|
||||||
|
self._apply_volume()
|
||||||
|
|
||||||
|
self._sd_mounted = False
|
||||||
|
sd_pins_in_use = False
|
||||||
|
SD_CS = board.SD_CS
|
||||||
|
# try to Connect to the sdcard card and mount the filesystem.
|
||||||
|
try:
|
||||||
|
# initialze CS pin
|
||||||
|
cs = digitalio.DigitalInOut(SD_CS)
|
||||||
|
except ValueError:
|
||||||
|
# likely the SDCard was auto-initialized by the core
|
||||||
|
sd_pins_in_use = True
|
||||||
|
|
||||||
|
# if placeholder.txt file does not exist
|
||||||
|
if "placeholder.txt" not in os.listdir("/sd/"):
|
||||||
|
self._sd_mounted = True
|
||||||
|
|
||||||
|
if not sd_pins_in_use:
|
||||||
|
try:
|
||||||
|
# if sd CS pin was not in use
|
||||||
|
# try to initialize and mount the SDCard
|
||||||
|
sdcard = adafruit_sdcard.SDCard(
|
||||||
|
busio.SPI(board.SD_SCK, board.SD_MOSI, board.SD_MISO), cs
|
||||||
|
)
|
||||||
|
vfs = storage.VfsFat(sdcard)
|
||||||
|
storage.mount(vfs, "/sd")
|
||||||
|
self._sd_mounted = True
|
||||||
|
except OSError:
|
||||||
|
# sdcard init or mounting failed
|
||||||
|
self._sd_mounted = False
|
||||||
|
self._mp3_decoder = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def button1(self) -> bool:
|
def button1(self) -> bool:
|
||||||
|
|
@ -137,7 +224,7 @@ class Peripherals:
|
||||||
"""
|
"""
|
||||||
Return whether any button is pressed
|
Return whether any button is pressed
|
||||||
"""
|
"""
|
||||||
return True in [button.value for (i, button) in enumerate(self._buttons)]
|
return True in [not button.value for (i, button) in enumerate(self._buttons)]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dac(self):
|
def dac(self):
|
||||||
|
|
@ -146,3 +233,100 @@ class Peripherals:
|
||||||
@property
|
@property
|
||||||
def audio(self):
|
def audio(self):
|
||||||
return self._audio
|
return self._audio
|
||||||
|
|
||||||
|
def sd_check(self):
|
||||||
|
return self._sd_mounted
|
||||||
|
|
||||||
|
def play_file(self, file_name, wait_to_finish=True):
|
||||||
|
"""Play a wav file.
|
||||||
|
|
||||||
|
:param str file_name: The name of the wav file to play on the speaker.
|
||||||
|
:param bool wait_to_finish: flag to determine if this is a blocking call
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# can't use `with` because we need wavefile to remain open after return
|
||||||
|
self.wavfile = open(file_name, "rb")
|
||||||
|
wavedata = audiocore.WaveFile(self.wavfile)
|
||||||
|
self.audio.play(wavedata)
|
||||||
|
if not wait_to_finish:
|
||||||
|
return
|
||||||
|
while self.audio.playing:
|
||||||
|
pass
|
||||||
|
self.wavfile.close()
|
||||||
|
|
||||||
|
def play_mp3_file(self, filename):
|
||||||
|
if self._mp3_decoder is None:
|
||||||
|
from audiomp3 import MP3Decoder # noqa: PLC0415, import outside top-level
|
||||||
|
|
||||||
|
self._mp3_decoder = MP3Decoder(filename)
|
||||||
|
else:
|
||||||
|
self._mp3_decoder.open(filename)
|
||||||
|
|
||||||
|
self.audio.play(self._mp3_decoder)
|
||||||
|
while self.audio.playing:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stop_play(self):
|
||||||
|
"""Stops playing a wav file."""
|
||||||
|
self.audio.stop()
|
||||||
|
if self.wavfile is not None:
|
||||||
|
self.wavfile.close()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def volume(self) -> int:
|
||||||
|
"""
|
||||||
|
The volume level of the Fruit Jam audio output. Valid values are 1-20.
|
||||||
|
"""
|
||||||
|
return self._volume
|
||||||
|
|
||||||
|
@volume.setter
|
||||||
|
def volume(self, volume_level: int) -> None:
|
||||||
|
"""
|
||||||
|
:param volume_level: new volume level 1-20
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
if not (1 <= volume_level <= 20):
|
||||||
|
raise ValueError("Volume level must be between 1 and 20")
|
||||||
|
|
||||||
|
if volume_level > self.safe_volume_limit:
|
||||||
|
raise ValueError(
|
||||||
|
f"""Volume level must be less than or equal to
|
||||||
|
safe_volume_limit: {self.safe_volume_limit}. Using higher values could damage speakers.
|
||||||
|
To override this limitation set a larger value than {self.safe_volume_limit}
|
||||||
|
for the safe_volume_limit with the constructor or property."""
|
||||||
|
)
|
||||||
|
|
||||||
|
self._volume = volume_level
|
||||||
|
self._apply_volume()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def audio_output(self) -> str:
|
||||||
|
"""
|
||||||
|
The audio output interface. 'speaker' or 'headphone'
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return self._audio_output
|
||||||
|
|
||||||
|
@audio_output.setter
|
||||||
|
def audio_output(self, audio_output: str) -> None:
|
||||||
|
"""
|
||||||
|
|
||||||
|
:param audio_output: The audio interface to use 'speaker' or 'headphone'.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
if audio_output == "headphone":
|
||||||
|
self._dac.headphone_output = True
|
||||||
|
self._dac.speaker_output = False
|
||||||
|
elif audio_output == "speaker":
|
||||||
|
self._dac.headphone_output = False
|
||||||
|
self._dac.speaker_output = True
|
||||||
|
else:
|
||||||
|
raise ValueError("audio_output must be either 'headphone' or 'speaker'")
|
||||||
|
|
||||||
|
def _apply_volume(self) -> None:
|
||||||
|
"""
|
||||||
|
Map the basic volume level to a db value and set it on the DAC.
|
||||||
|
"""
|
||||||
|
db_val = map_range(self._volume, 1, 20, -63, 23)
|
||||||
|
self._dac.dac_volume = db_val
|
||||||
|
|
|
||||||
14
docs/conf.py
14
docs/conf.py
|
|
@ -24,7 +24,19 @@ extensions = [
|
||||||
# Uncomment the below if you use native CircuitPython modules such as
|
# Uncomment the below if you use native CircuitPython modules such as
|
||||||
# digitalio, micropython and busio. List the modules you use. Without it, the
|
# digitalio, micropython and busio. List the modules you use. Without it, the
|
||||||
# autodoc module docs will fail to generate with a warning.
|
# 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",
|
||||||
|
"audiocore",
|
||||||
|
"storage",
|
||||||
|
"terminalio",
|
||||||
|
"adafruit_connection_manager",
|
||||||
|
"adafruit_ntp",
|
||||||
|
"rtc",
|
||||||
|
]
|
||||||
|
|
||||||
autodoc_preserve_defaults = True
|
autodoc_preserve_defaults = True
|
||||||
|
|
||||||
|
|
|
||||||
12
examples/fruitjam_displaycheck.py
Normal file
12
examples/fruitjam_displaycheck.py
Normal 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}")
|
||||||
22
examples/fruitjam_headphone.py
Normal file
22
examples/fruitjam_headphone.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
import time
|
||||||
|
|
||||||
|
import adafruit_fruitjam
|
||||||
|
|
||||||
|
pobj = adafruit_fruitjam.peripherals.Peripherals(audio_output="headphone")
|
||||||
|
|
||||||
|
FILES = ["beep.wav", "dip.wav", "rise.wav"]
|
||||||
|
VOLUMES = [5, 7, 10, 11, 12]
|
||||||
|
|
||||||
|
while True:
|
||||||
|
print("\n=== Headphones Test ===")
|
||||||
|
for vol in VOLUMES:
|
||||||
|
pobj.volume = vol
|
||||||
|
print(f"Headphones volume: {vol}")
|
||||||
|
for f in FILES:
|
||||||
|
print(f" -> {f}")
|
||||||
|
pobj.play_file(f)
|
||||||
|
time.sleep(0.2)
|
||||||
|
time.sleep(1.0)
|
||||||
33
examples/fruitjam_ntp_settings.toml
Normal file
33
examples/fruitjam_ntp_settings.toml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# SPDX-FileCopyrightText: Copyright (c) 2025 Mikey Sklar for Adafruit Industries
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Wi-Fi credentials
|
||||||
|
CIRCUITPY_WIFI_SSID = "YourSSID"
|
||||||
|
CIRCUITPY_WIFI_PASSWORD = "YourPassword"
|
||||||
|
|
||||||
|
# NTP settings
|
||||||
|
# Common UTC offsets (hours):
|
||||||
|
# 0 UTC / Zulu
|
||||||
|
# 1 CET (Central Europe)
|
||||||
|
# 2 EET (Eastern Europe)
|
||||||
|
# 3 FET (Further Eastern Europe)
|
||||||
|
# -5 EST (Eastern US)
|
||||||
|
# -6 CST (Central US)
|
||||||
|
# -7 MST (Mountain US)
|
||||||
|
# -8 PST (Pacific US)
|
||||||
|
# -9 AKST (Alaska)
|
||||||
|
# -10 HST (Hawaii, no DST)
|
||||||
|
|
||||||
|
NTP_SERVER = "pool.ntp.org" # NTP host (default pool.ntp.org)
|
||||||
|
NTP_TZ = -5 # timezone offset in hours
|
||||||
|
NTP_DST = 1 # daylight saving (0=no, 1=yes)
|
||||||
|
NTP_INTERVAL = 3600 # re-sync interval (seconds)
|
||||||
|
|
||||||
|
# Optional tuning
|
||||||
|
NTP_TIMEOUT = "1.0" # socket timeout in seconds
|
||||||
|
NTP_CACHE_SECONDS = 0 # cache results (0 = always fetch)
|
||||||
|
NTP_REQUIRE_YEAR = 2022 # sanity check minimum year
|
||||||
|
|
||||||
|
# Retries
|
||||||
|
NTP_RETRIES = 8 # number of NTP fetch attempts
|
||||||
|
NTP_DELAY_S = "1.5" # delay between attempts (seconds)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
22
examples/fruitjam_speaker.py
Normal file
22
examples/fruitjam_speaker.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
import time
|
||||||
|
|
||||||
|
import adafruit_fruitjam
|
||||||
|
|
||||||
|
pobj = adafruit_fruitjam.peripherals.Peripherals(audio_output="speaker")
|
||||||
|
|
||||||
|
FILES = ["beep.wav", "dip.wav", "rise.wav"]
|
||||||
|
VOLUMES = [5, 7, 10, 11, 12]
|
||||||
|
|
||||||
|
while True:
|
||||||
|
print("\n=== Speaker Test ===")
|
||||||
|
for vol in VOLUMES:
|
||||||
|
pobj.volume = vol
|
||||||
|
print(f"Speaker volume: {vol}")
|
||||||
|
for f in FILES:
|
||||||
|
print(f" -> {f}")
|
||||||
|
pobj.play_file(f)
|
||||||
|
time.sleep(0.2)
|
||||||
|
time.sleep(1.0)
|
||||||
27
examples/fruitjam_synthio_speaker.py
Normal file
27
examples/fruitjam_synthio_speaker.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
import time
|
||||||
|
|
||||||
|
import synthio
|
||||||
|
|
||||||
|
import adafruit_fruitjam
|
||||||
|
|
||||||
|
pobj = adafruit_fruitjam.peripherals.Peripherals(audio_output="headphone")
|
||||||
|
|
||||||
|
synth = synthio.Synthesizer(sample_rate=44100)
|
||||||
|
pobj.audio.play(synth)
|
||||||
|
VOLUMES = [5, 7, 10, 11, 12]
|
||||||
|
C_major_scale = [60, 62, 64, 65, 67, 69, 71, 72, 71, 69, 67, 65, 64, 62, 60]
|
||||||
|
while True:
|
||||||
|
print("\n=== Synthio Test ===")
|
||||||
|
for vol in VOLUMES:
|
||||||
|
pobj.volume = vol
|
||||||
|
print(f"Volume: {vol}")
|
||||||
|
for note in C_major_scale:
|
||||||
|
synth.press(note)
|
||||||
|
time.sleep(0.1)
|
||||||
|
synth.release(note)
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
time.sleep(1.0)
|
||||||
11
examples/fruitjam_time_sync.py
Normal file
11
examples/fruitjam_time_sync.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# SPDX-FileCopyrightText: Copyright (c) 2025 Mikey Sklar for Adafruit Industries
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
import time
|
||||||
|
|
||||||
|
from adafruit_fruitjam import FruitJam
|
||||||
|
|
||||||
|
fj = FruitJam()
|
||||||
|
now = fj.sync_time()
|
||||||
|
print("RTC set:", now)
|
||||||
|
print("Localtime:", time.localtime())
|
||||||
BIN
examples/wav/beep.wav
Executable file
BIN
examples/wav/beep.wav
Executable file
Binary file not shown.
3
examples/wav/beep.wav.license
Normal file
3
examples/wav/beep.wav.license
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# SPDX-FileCopyrightText: Copyright (c) 2025 Adafruit Industries
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: CC-BY-4.0
|
||||||
BIN
examples/wav/dip.wav
Executable file
BIN
examples/wav/dip.wav
Executable file
Binary file not shown.
3
examples/wav/dip.wav.license
Normal file
3
examples/wav/dip.wav.license
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# SPDX-FileCopyrightText: Copyright (c) 2025 Adafruit Industries
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: CC-BY-4.0
|
||||||
BIN
examples/wav/rise.wav
Executable file
BIN
examples/wav/rise.wav
Executable file
Binary file not shown.
3
examples/wav/rise.wav.license
Normal file
3
examples/wav/rise.wav.license
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# SPDX-FileCopyrightText: Copyright (c) 2025 Adafruit Industries
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: CC-BY-4.0
|
||||||
|
|
@ -7,3 +7,12 @@ 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
|
||||||
|
adafruit-circuitpython-sd
|
||||||
|
adafruit-circuitpython-ntp
|
||||||
|
adafruit-circuitpython-connectionmanager
|
||||||
|
adafruit-circuitpython-simplemath
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue