Compare commits

...

59 commits
0.2.0 ... main

Author SHA1 Message Date
Scott Shawcroft
e67239b37b
Merge pull request #15 from FoamyGuy/play_mp3_file
Some checks failed
Build CI / test (push) Has been cancelled
play_mp3_file() function
2025-08-28 15:43:20 -07:00
foamyguy
b4f18f7d8f use MP3Decoder.open() 2025-08-28 12:59:41 -05:00
foamyguy
e8a2a77ac6 Merge branch 'refs/heads/main' into play_mp3_file 2025-08-28 12:51:35 -05:00
foamyguy
8ee337c686
Merge pull request #13 from FoamyGuy/volume_api
Some checks failed
Build CI / test (push) Has been cancelled
Volume and output interface API
2025-08-28 12:13:07 -05:00
foamyguy
7682a56c51 clarify wording in the volume warning 2025-08-28 08:44:48 -05:00
foamyguy
6f0204bf34 Merge remote-tracking branch 'foamyguy/volume_api' into volume_api 2025-08-28 08:40:59 -05:00
foamyguy
733ef1b925 use adafruit_simplemath instead of simpleio. Use multi-line string for warning message 2025-08-28 08:40:51 -05:00
foamyguy
45d7cc1232
simplified condition logic
Co-authored-by: Dan Halbert <halbert@halwitz.org>
2025-08-27 18:09:26 -05:00
foamyguy
c41e65bdeb lower default volume limit, update examples, change warning message 2025-08-27 10:46:26 -05:00
foamyguy
f2325f3811 lower default volume 2025-08-27 10:42:40 -05:00
foamyguy
5eabd3d955 Merge branch 'refs/heads/main' into volume_api
# Conflicts:
#	requirements.txt
2025-08-27 10:33:27 -05:00
foamyguy
3f33c18ddf fix _audio_output initialization error 2025-08-27 10:32:40 -05:00
foamyguy
9259a09330
Merge pull request #14 from mikeysklar/ntp-helper
Some checks failed
Build CI / test (push) Has been cancelled
Fruit Jam NTP module
2025-08-25 18:57:36 -05:00
foamyguy
1f46569c1d play_mp3_file() function 2025-08-25 18:50:43 -05:00
Mikey Sklar
cb5d1db622 settings.toml float quote, attribution, duplicate last_exc setting
settings.toml example with double quotes for floating point values

mikeysklar attribution

drop duplicate last_exc
2025-08-25 16:06:37 -07:00
Mikey Sklar
d2ae05eaed helpers for sync_time
ruff was getting upset about too many branches
2025-08-25 14:47:56 -07:00
Mikey Sklar
2e19356d9a ntp constructor dup / retry logic
ntp constructor removed.

dup rtc import had already been removed.

new NTP_RETRIES and NTP_DELAY_S user configurable settings.

```
NTP_RETRIES   = 8     # number of NTP fetch attempts
NTP_DELAY_S   = 1.0   # delay between attempts (seconds)
```

updated settings.toml example

updated docstring for sync_time with new NTP* configurables
2025-08-25 13:19:56 -07:00
Mikey Sklar
96e4575d2e retry logic for ETIMEOUT 2025-08-23 18:15:57 -07:00
Mikey Sklar
75e5fb7ac2 Update __init__.py
blank line missing.
2025-08-22 18:55:00 -07:00
Mikey Sklar
6b86bc95a6 rtc lib mock add 2025-08-22 18:49:03 -07:00
Mikey Sklar
fc388c5ccb mock imports / requirements
ntp and connection_manager libs
2025-08-22 18:37:32 -07:00
Mikey Sklar
d1d0bb0932 refactored to use fruitjam network module
Refactored and "works for me". I'll ask @b-blake to confirm functionality as well.

	•	Added Network.sync_time() method in network.py
	•	Uses adafruit_ntp + adafruit_connection_manager.
	•	Reads optional NTP_* keys from settings.toml.
	•	Sets rtc.RTC().datetime
	•	Added example examples/fruitjam_time_sync.py (sync once, print localtime).
	•	Added example examples/fruitjam_ntp_settings.toml
2025-08-22 18:30:11 -07:00
Mikey Sklar
544adaa447 ntp module
helper code to get time sync with minimal fuss on the Fruit Jam.
2025-08-21 17:41:36 -07:00
foamyguy
eb35d69e1b update printed volume label 2025-08-20 07:54:10 -05:00
foamyguy
4998356604 add synthio example. 2025-08-19 16:31:11 -05:00
foamyguy
a722ba1835 convenience accessors for volume and audio_output properties on main fruit_jam object. 2025-08-19 16:10:41 -05:00
foamyguy
9825076bcf update audio examples to use new volume API. 2025-08-19 16:07:54 -05:00
foamyguy
c332f74892 add simpleio to reqs 2025-08-19 11:32:11 -05:00
foamyguy
f35068b0d3 audio volume and interface APIs 2025-08-19 11:31:14 -05:00
foamyguy
a0327ac8e6
Merge pull request #12 from mikeysklar/headphone-speaker
Some checks failed
Build CI / test (push) Has been cancelled
headphone vs speaker usage
2025-08-14 16:55:05 -05:00
foamyguy
b8b1770da5 use .license files 2025-08-14 16:50:14 -05:00
Mikey Sklar
09af721cf7 ruff whitespace 2025-08-14 13:06:25 -07:00
Mikey Sklar
456851aabf wav file reuse 2025-08-14 13:04:19 -07:00
Mikey Sklar
e95edb3e3d license files for wavs 2025-08-14 12:55:17 -07:00
Mikey Sklar
0e0ab5431f headphone vs speaker usage
Short examples showing how to set volume and play WAV files for both headphone 3.5mm jack and mini speaker.
2025-08-14 12:32:14 -07:00
foamyguy
3aebe8dc9d
Merge pull request #11 from adafruit/TheKitty-patch-1
Some checks failed
Build CI / test (push) Has been cancelled
Update network.py for Issue #7
2025-08-14 10:46:16 -05:00
Anne Barela
d883e687bb
Update network.py
Delete blank line
2025-08-14 11:11:08 -04:00
Anne Barela
f3e13ff5c7
Update network.py per comment
Remove debug print from development
2025-08-14 11:04:54 -04:00
Anne Barela
153b177a3f
Update network.py for Issue #7
Code prints the status_neopixel to stdout when it should only, perhaps, do that on debug.  Fixes #7
2025-08-14 09:39:11 -04:00
foamyguy
9175c448f6
Merge pull request #9 from relic-se/request_display_config-default
Some checks failed
Build CI / test (push) Has been cancelled
Automatically configure display size based on `CIRCUITPY_DISPLAY_WIDTH`
2025-08-12 15:42:27 -05:00
Cooper Dalrymple
6224c6510d Update request_display_config docstring 2025-08-12 14:44:18 -05:00
foamyguy
71dbc2ae38
Merge pull request #8 from relic-se/any_button_pressed-fix
Some checks failed
Build CI / test (push) Has been cancelled
Invert `any_button_pressed` logic for pull-up
2025-08-12 14:12:23 -05:00
Cooper Dalrymple
3cd3c6e596 Fix formatting 2025-08-12 14:08:31 -05:00
Cooper Dalrymple
bfc6140e6d Optimize display height assignment 2025-08-12 13:59:59 -05:00
Cooper Dalrymple
f5b3367c2b Automatically configure display size based on CIRCUITPY_DISPLAY_WIDTH 2025-08-12 13:50:01 -05:00
Cooper Dalrymple
153e078870 Invert any_button_pressed logic for pull-up 2025-08-06 14:49:02 -05:00
foamyguy
17d3868ed6
Merge pull request #6 from FoamyGuy/more_portalbase_stuff
Some checks failed
Build CI / test (push) Has been cancelled
More portalbase APIs, fixes for status_led and caption text
2025-07-24 11:24:41 -05:00
foamyguy
2a5d7e81e7 fix docs build 2025-07-24 10:05:15 -05:00
foamyguy
5932bf058c add sd_check(), play_file(), stop_play(), show_QR(), hide_QR() portalbase APIs. Fix for status_led pin already in use. Fix for caption text is None 2025-07-24 09:01:55 -05:00
foamyguy
7de2ec3d14
Merge pull request #5 from FoamyGuy/implement_portalbase
Some checks failed
Build CI / test (push) Has been cancelled
Adding PortalBase support
2025-07-21 16:38:25 -05:00
foamyguy
666b515b80 code format 2025-07-21 16:36:23 -05:00
foamyguy
410c467492 add terminalio to mock docs list 2025-07-21 16:32:56 -05:00
foamyguy
cc4a293e22 update product links and copyright 2025-07-21 15:53:24 -05:00
foamyguy
6043362d3a extending portalbase, add example that uses network fetch 2025-07-21 15:51:14 -05:00
Anne Barela
76d4f22524
Merge pull request #4 from FoamyGuy/color_depth_support
Some checks failed
Build CI / test (push) Has been cancelled
support requesting color depth
2025-07-09 17:38:44 -04:00
foamyguy
332fe1e871 support requesting color depth. Dont re-init display if already in requested config. Update get_config docstring and try/except to account for no display initialized. 2025-07-09 14:23:27 -05:00
foamyguy
8d3f1ab83a
Merge pull request #3 from adafruit/TheKitty-patch-1
Update peripherals.py to include a new get_display_config function
2025-07-09 14:05:26 -05:00
foamyguy
d774cd952e access display and framebuffer properties for values, format code. 2025-07-09 13:58:19 -05:00
Anne Barela
9a34a9d5b0
Update peripherals.py 2025-07-09 10:26:23 -04:00
20 changed files with 1167 additions and 38 deletions

View file

@ -15,7 +15,7 @@ Implementation Notes
**Hardware:**
* `Adafruit Fruit Jam <url>`_"
* `Adafruit Fruit Jam <https://www.adafruit.com/product/6200>`_
**Software and Dependencies:**
@ -29,13 +29,338 @@ Implementation Notes
__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 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
def neopixels(self):
return self.peripherals.neopixels

View 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

View 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")

View file

@ -15,7 +15,7 @@ Implementation Notes
**Hardware:**
* `Adafruit Fruit Jam <url>`_"
* `Adafruit Fruit Jam <https://www.adafruit.com/product/6200>`_
**Software and Dependencies:**
@ -26,13 +26,21 @@ Implementation Notes
"""
import os
import adafruit_sdcard
import adafruit_tlv320
import audiobusio
import audiocore
import board
import busio
import digitalio
import displayio
import framebufferio
import picodvi
import storage
import supervisor
from adafruit_simplemath import map_range
from digitalio import DigitalInOut, Direction, Pull
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,
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``
:param width: The width of the display in pixels.
:param height: The height of the display in pixels.
:param width: The width of the display in pixels. Leave unspecified to default
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
"""
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}")
displayio.release_displays()
fb = picodvi.Framebuffer(
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_LUT[width],
)
supervisor.runtime.display = framebufferio.FramebufferDisplay(fb)
# if user does not specify height, use matching height
if height is None:
height = next((h for w, h in VALID_DISPLAY_SIZES if width == w))
# if user does not specify a requested color_depth
if color_depth is None:
# use the maximum color depth for given width
color_depth = COLOR_DEPTH_LUT[width]
requested_config = (width, height, color_depth)
if requested_config != get_display_config():
displayio.release_displays()
fb = picodvi.Framebuffer(
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:
"""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:
neopixels (NeoPxiels): The NeoPixels on the Fruit Jam board.
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._buttons = []
@ -105,11 +159,44 @@ class Peripherals:
# set sample rate & bit depth
self._dac.configure_clocks(sample_rate=11030, bit_depth=16)
# use headphones
self._dac.headphone_output = True
self._dac.headphone_volume = -15 # dB
self._audio_output = audio_output
self.audio_output = audio_output
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
def button1(self) -> bool:
@ -137,7 +224,7 @@ class Peripherals:
"""
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
def dac(self):
@ -146,3 +233,100 @@ class Peripherals:
@property
def audio(self):
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

View file

@ -24,7 +24,19 @@ extensions = [
# 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",
"audiocore",
"storage",
"terminalio",
"adafruit_connection_manager",
"adafruit_ntp",
"rtc",
]
autodoc_preserve_defaults = True

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

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

View 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)

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

View 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)

View 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)

View 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

Binary file not shown.

View 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

Binary file not shown.

View 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

Binary file not shown.

View file

@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: Copyright (c) 2025 Adafruit Industries
#
# SPDX-License-Identifier: CC-BY-4.0

View file

@ -7,3 +7,12 @@ 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
adafruit-circuitpython-sd
adafruit-circuitpython-ntp
adafruit-circuitpython-connectionmanager
adafruit-circuitpython-simplemath

View file

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