Compare commits
No commits in common. "main" and "0.4.0" have entirely different histories.
17 changed files with 18 additions and 496 deletions
|
|
@ -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,8 +207,7 @@ 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:
|
||||||
if text_position is not None and isinstance(text_position[0], (list, tuple)):
|
if text_position is not None and isinstance(text_position[0], (list, tuple)):
|
||||||
|
|
@ -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!
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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.
|
|
@ -1,3 +0,0 @@
|
||||||
# SPDX-FileCopyrightText: Copyright (c) 2025 Adafruit Industries
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: CC-BY-4.0
|
|
||||||
Binary file not shown.
|
|
@ -1,3 +0,0 @@
|
||||||
# SPDX-FileCopyrightText: Copyright (c) 2025 Adafruit Industries
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: CC-BY-4.0
|
|
||||||
Binary file not shown.
|
|
@ -1,3 +0,0 @@
|
||||||
# SPDX-FileCopyrightText: Copyright (c) 2025 Adafruit Industries
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: CC-BY-4.0
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue