Compare commits

..

No commits in common. "main" and "0.4.0" have entirely different histories.
main ... 0.4.0

17 changed files with 18 additions and 496 deletions

View file

@ -162,7 +162,7 @@ class FruitJam(PortalBase):
network = Network( network = Network(
status_neopixel=self.peripherals.neopixels status_neopixel=self.peripherals.neopixels
if status_neopixel is None or status_neopixel == board.NEOPIXEL if status_neopixel is None
else status_neopixel, else status_neopixel,
esp=esp, esp=esp,
external_spi=spi, external_spi=spi,
@ -191,17 +191,14 @@ class FruitJam(PortalBase):
# Convenience Shortcuts for compatibility # Convenience Shortcuts for compatibility
self.sd_check = self.peripherals.sd_check # self.sd_check = self.peripherals.sd_check
self.play_file = self.peripherals.play_file # self.play_file = self.peripherals.play_file
self.play_mp3_file = self.peripherals.play_mp3_file # self.stop_play = self.peripherals.stop_play
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.image_converter_url = self.network.image_converter_url
self.wget = self.network.wget self.wget = self.network.wget
self.show_QR = self.graphics.qrcode # self.show_QR = self.graphics.qrcode
self.hide_QR = self.graphics.hide_QR # self.hide_QR = self.graphics.hide_QR
if default_bg is not None: if default_bg is not None:
self.graphics.set_background(default_bg) self.graphics.set_background(default_bg)
@ -210,7 +207,6 @@ class FruitJam(PortalBase):
print("Init caption") print("Init caption")
if caption_font: if caption_font:
self._caption_font = self._load_font(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) self.set_caption(caption_text, caption_position, caption_color)
if text_font: if text_font:
@ -248,26 +244,6 @@ class FruitJam(PortalBase):
gc.collect() 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): def set_caption(self, caption_text, caption_position, caption_color):
"""A caption. Requires setting ``caption_font`` in init! """A caption. Requires setting ``caption_font`` in init!

View file

@ -66,34 +66,3 @@ class Graphics(GraphicsBase):
if supervisor.runtime.display is None: if supervisor.runtime.display is None:
request_display_config(640, 480) request_display_config(640, 480)
super().__init__(supervisor.runtime.display, default_bg=default_bg, debug=debug) 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

@ -1,6 +1,5 @@
# SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams, written for Adafruit Industries # SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams, written for Adafruit Industries
# SPDX-FileCopyrightText: 2025 Tim Cocks, 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 # SPDX-License-Identifier: Unlicense
""" """
@ -26,14 +25,9 @@ Implementation Notes
""" """
import gc import gc
import os
import time
import adafruit_connection_manager as acm
import adafruit_ntp
import microcontroller import microcontroller
import neopixel import neopixel
import rtc
from adafruit_portalbase.network import ( from adafruit_portalbase.network import (
CONTENT_IMAGE, CONTENT_IMAGE,
CONTENT_JSON, CONTENT_JSON,
@ -94,6 +88,7 @@ class Network(NetworkBase):
image_position=None, image_position=None,
image_dim_json_path=None, image_dim_json_path=None,
): ):
print(f"status_neopixel", status_neopixel)
if isinstance(status_neopixel, microcontroller.Pin): if isinstance(status_neopixel, microcontroller.Pin):
status_led = neopixel.NeoPixel(status_neopixel, 1, brightness=0.2) status_led = neopixel.NeoPixel(status_neopixel, 1, brightness=0.2)
elif isinstance(status_neopixel, neopixel.NeoPixel): elif isinstance(status_neopixel, neopixel.NeoPixel):
@ -215,133 +210,3 @@ class Network(NetworkBase):
gc.collect() gc.collect()
return filename, position 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

@ -26,21 +26,13 @@ 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
@ -56,7 +48,7 @@ COLOR_DEPTH_LUT = {
} }
def request_display_config(width=None, height=None, color_depth=None): def request_display_config(width, height, 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
@ -64,11 +56,8 @@ def request_display_config(width=None, height=None, color_depth=None):
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. Leave unspecified to default :param width: The width of the display in pixels.
to the ``CIRCUITPY_DISPLAY_WIDTH`` environmental variable if provided. Otherwise, :param height: The height of the display in pixels.
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. :param color_depth: The color depth of the display in bits.
Valid values are 1, 2, 4, 8, 16, 32. Larger resolutions must use Valid values are 1, 2, 4, 8, 16, 32. Larger resolutions must use
smaller color_depths due to RAM limitations. Default color_depth for smaller color_depths due to RAM limitations. Default color_depth for
@ -76,20 +65,9 @@ def request_display_config(width=None, height=None, color_depth=None):
is 16. is 16.
:return: None :return: None
""" """
# if user does not specify width, use default configuration if (width, height) not in VALID_DISPLAY_SIZES:
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}")
# 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 user does not specify a requested color_depth
if color_depth is None: if color_depth is None:
# use the maximum color depth for given width # use the maximum color depth for given width
@ -134,16 +112,13 @@ def get_display_config():
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, audio_output="headphone", safe_volume_limit=12): def __init__(self):
self.neopixels = NeoPixel(board.NEOPIXEL, 5) self.neopixels = NeoPixel(board.NEOPIXEL, 5)
self._buttons = [] self._buttons = []
@ -159,44 +134,11 @@ 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)
self._audio_output = audio_output # use headphones
self.audio_output = audio_output self._dac.headphone_output = True
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:
@ -224,7 +166,7 @@ class Peripherals:
""" """
Return whether any button is pressed Return whether any button is pressed
""" """
return True in [not button.value for (i, button) in enumerate(self._buttons)] return True in [button.value for (i, button) in enumerate(self._buttons)]
@property @property
def dac(self): def dac(self):
@ -233,100 +175,3 @@ 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

View file

@ -30,12 +30,7 @@ autodoc_mock_imports = [
"framebufferio", "framebufferio",
"picodvi", "picodvi",
"audiobusio", "audiobusio",
"audiocore",
"storage",
"terminalio", "terminalio",
"adafruit_connection_manager",
"adafruit_ntp",
"rtc",
] ]
autodoc_preserve_defaults = True autodoc_preserve_defaults = True

View file

@ -1,22 +0,0 @@
# 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

@ -1,33 +0,0 @@
# 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,22 +0,0 @@
# 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

@ -1,27 +0,0 @@
# 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

@ -1,11 +0,0 @@
# 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())

Binary file not shown.

View file

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

Binary file not shown.

View file

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

Binary file not shown.

View file

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

View file

@ -12,7 +12,3 @@ adafruit-circuitpython-esp32spi
adafruit-circuitpython-requests adafruit-circuitpython-requests
adafruit-circuitpython-bitmap-font adafruit-circuitpython-bitmap-font
adafruit-circuitpython-display-text adafruit-circuitpython-display-text
adafruit-circuitpython-sd
adafruit-circuitpython-ntp
adafruit-circuitpython-connectionmanager
adafruit-circuitpython-simplemath