Skip to content

Commit e8a2a77

Browse files
committed
Merge branch 'refs/heads/main' into play_mp3_file
2 parents 1f46569 + 8ee337c commit e8a2a77

File tree

10 files changed

+315
-25
lines changed

10 files changed

+315
-25
lines changed

adafruit_fruitjam/__init__.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@ def __init__( # noqa: PLR0912,PLR0913,Too many branches,Too many arguments in f
195195
self.play_file = self.peripherals.play_file
196196
self.play_mp3_file = self.peripherals.play_mp3_file
197197
self.stop_play = self.peripherals.stop_play
198+
self.volume = self.peripherals.volume
199+
self.audio_output = self.peripherals.audio_output
198200

199201
self.image_converter_url = self.network.image_converter_url
200202
self.wget = self.network.wget
@@ -246,6 +248,26 @@ def __init__( # noqa: PLR0912,PLR0913,Too many branches,Too many arguments in f
246248

247249
gc.collect()
248250

251+
def sync_time(self, **kwargs):
252+
"""Set the system RTC via NTP using this FruitJam's Network.
253+
254+
This is a convenience wrapper for ``self.network.sync_time(...)``.
255+
256+
:param str server: Override NTP host (defaults to ``NTP_SERVER`` or
257+
``"pool.ntp.org"`` if unset). (Pass via ``server=...`` in kwargs.)
258+
:param float tz_offset: Override hours from UTC (defaults to ``NTP_TZ``;
259+
``NTP_DST`` is still added). (Pass via ``tz_offset=...``.)
260+
:param dict tuning: Advanced options dict (optional). Supported keys:
261+
``timeout`` (float, socket timeout seconds; defaults to ``NTP_TIMEOUT`` or 5.0),
262+
``cache_seconds`` (int; defaults to ``NTP_CACHE_SECONDS`` or 0),
263+
``require_year`` (int; defaults to ``NTP_REQUIRE_YEAR`` or 2022).
264+
(Pass via ``tuning={...}``.)
265+
266+
:returns: Synced time
267+
:rtype: time.struct_time
268+
"""
269+
return self.network.sync_time(**kwargs)
270+
249271
def set_caption(self, caption_text, caption_position, caption_color):
250272
"""A caption. Requires setting ``caption_font`` in init!
251273

adafruit_fruitjam/network.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams, written for Adafruit Industries
22
# SPDX-FileCopyrightText: 2025 Tim Cocks, written for Adafruit Industries
3+
# SPDX-FileCopyrightText: 2025 Mikey Sklar, written for Adafruit Industries
34
#
45
# SPDX-License-Identifier: Unlicense
56
"""
@@ -25,9 +26,14 @@
2526
"""
2627

2728
import gc
29+
import os
30+
import time
2831

32+
import adafruit_connection_manager as acm
33+
import adafruit_ntp
2934
import microcontroller
3035
import neopixel
36+
import rtc
3137
from adafruit_portalbase.network import (
3238
CONTENT_IMAGE,
3339
CONTENT_JSON,
@@ -209,3 +215,133 @@ def process_image(self, json_data, sd_card=False): # noqa: PLR0912 Too many bra
209215
gc.collect()
210216

211217
return filename, position
218+
219+
def sync_time(self, server=None, tz_offset=None, tuning=None):
220+
"""
221+
Set the system RTC via NTP using this Network's Wi-Fi connection.
222+
223+
Reads optional settings from settings.toml:
224+
225+
NTP_SERVER – NTP host (default: "pool.ntp.org")
226+
NTP_TZ – timezone offset in hours (float, default: 0)
227+
NTP_DST – extra offset for daylight saving (0=no, 1=yes; default: 0)
228+
NTP_INTERVAL – re-sync interval in seconds (default: 3600, not used internally)
229+
230+
NTP_TIMEOUT – socket timeout per attempt (seconds, default: 5.0)
231+
NTP_CACHE_SECONDS – cache results, 0 = always fetch fresh (default: 0)
232+
NTP_REQUIRE_YEAR – minimum acceptable year (default: 2022)
233+
234+
NTP_RETRIES – number of NTP fetch attempts on timeout (default: 8)
235+
NTP_DELAY_S – delay between retries in seconds (default: 1.0)
236+
237+
Keyword args:
238+
server (str) – override NTP_SERVER
239+
tz_offset (float) – override NTP_TZ (+ NTP_DST still applied)
240+
tuning (dict) – override tuning knobs, e.g.:
241+
{
242+
"timeout": 5.0,
243+
"cache_seconds": 0,
244+
"require_year": 2022,
245+
"retries": 8,
246+
"retry_delay": 1.0,
247+
}
248+
249+
Returns:
250+
time.struct_time
251+
"""
252+
# Ensure Wi-Fi up
253+
self.connect()
254+
255+
# Socket pool
256+
pool = acm.get_radio_socketpool(self._wifi.esp)
257+
258+
# Settings & overrides
259+
server = server or os.getenv("NTP_SERVER") or "pool.ntp.org"
260+
tz = tz_offset if tz_offset is not None else _combined_tz_offset(0.0)
261+
t = tuning or {}
262+
263+
timeout = float(t.get("timeout", _get_float_env("NTP_TIMEOUT", 5.0)))
264+
cache_seconds = int(t.get("cache_seconds", _get_int_env("NTP_CACHE_SECONDS", 0)))
265+
require_year = int(t.get("require_year", _get_int_env("NTP_REQUIRE_YEAR", 2022)))
266+
ntp_retries = int(t.get("retries", _get_int_env("NTP_RETRIES", 8)))
267+
ntp_delay_s = float(t.get("retry_delay", _get_float_env("NTP_DELAY_S", 1.0)))
268+
269+
# NTP client
270+
ntp = adafruit_ntp.NTP(
271+
pool,
272+
server=server,
273+
tz_offset=tz,
274+
socket_timeout=timeout,
275+
cache_seconds=cache_seconds,
276+
)
277+
278+
# Attempt fetch (retries on timeout)
279+
now = _ntp_get_datetime(
280+
ntp,
281+
connect_cb=self.connect,
282+
retries=ntp_retries,
283+
delay_s=ntp_delay_s,
284+
debug=getattr(self, "_debug", False),
285+
)
286+
287+
# Sanity check & commit
288+
if now.tm_year < require_year:
289+
raise RuntimeError("NTP returned an unexpected year; not setting RTC")
290+
291+
rtc.RTC().datetime = now
292+
return now
293+
294+
295+
# ---- Internal helpers to keep sync_time() small and Ruff-friendly ----
296+
297+
298+
def _get_float_env(name, default):
299+
v = os.getenv(name)
300+
try:
301+
return float(v) if v not in {None, ""} else float(default)
302+
except Exception:
303+
return float(default)
304+
305+
306+
def _get_int_env(name, default):
307+
v = os.getenv(name)
308+
if v in {None, ""}:
309+
return int(default)
310+
try:
311+
return int(v)
312+
except Exception:
313+
try:
314+
return int(float(v)) # tolerate "5.0"
315+
except Exception:
316+
return int(default)
317+
318+
319+
def _combined_tz_offset(base_default):
320+
"""Return tz offset hours including DST via env (NTP_TZ + NTP_DST)."""
321+
tz = _get_float_env("NTP_TZ", base_default)
322+
dst = _get_float_env("NTP_DST", 0)
323+
return tz + dst
324+
325+
326+
def _ntp_get_datetime(ntp, connect_cb, retries, delay_s, debug=False):
327+
"""Fetch ntp.datetime with limited retries on timeout; re-connect between tries."""
328+
for i in range(retries):
329+
last_exc = None
330+
try:
331+
return ntp.datetime # struct_time
332+
except OSError as e:
333+
last_exc = e
334+
is_timeout = (getattr(e, "errno", None) == 116) or ("ETIMEDOUT" in str(e))
335+
if not is_timeout:
336+
break
337+
if debug:
338+
print(f"NTP timeout, attempt {i + 1}/{retries}")
339+
connect_cb() # re-assert Wi-Fi using existing policy
340+
time.sleep(delay_s)
341+
continue
342+
except Exception as e:
343+
last_exc = e
344+
break
345+
if last_exc:
346+
raise last_exc
347+
raise RuntimeError("NTP sync failed")

adafruit_fruitjam/peripherals.py

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
import picodvi
4141
import storage
4242
import supervisor
43+
from adafruit_simplemath import map_range
4344
from digitalio import DigitalInOut, Direction, Pull
4445
from neopixel import NeoPixel
4546

@@ -133,13 +134,16 @@ def get_display_config():
133134
class Peripherals:
134135
"""Peripherals Helper Class for the FruitJam Library
135136
137+
:param audio_output: The audio output interface to use 'speaker' or 'headphone'
138+
:param safe_volume_limit: The maximum volume allowed for the audio output. Default is 15
139+
Using higher values can damage some speakers, change at your own risk.
136140
137141
Attributes:
138142
neopixels (NeoPxiels): The NeoPixels on the Fruit Jam board.
139143
See https://circuitpython.readthedocs.io/projects/neopixel/en/latest/api.html
140144
"""
141145

142-
def __init__(self):
146+
def __init__(self, audio_output="headphone", safe_volume_limit=12):
143147
self.neopixels = NeoPixel(board.NEOPIXEL, 5)
144148

145149
self._buttons = []
@@ -155,11 +159,14 @@ def __init__(self):
155159
# set sample rate & bit depth
156160
self._dac.configure_clocks(sample_rate=11030, bit_depth=16)
157161

158-
# use headphones
159-
self._dac.headphone_output = True
160-
self._dac.headphone_volume = -15 # dB
161-
162+
self._audio_output = audio_output
163+
self.audio_output = audio_output
162164
self._audio = audiobusio.I2SOut(board.I2S_BCLK, board.I2S_WS, board.I2S_DIN)
165+
if safe_volume_limit < 1 or safe_volume_limit > 20:
166+
raise ValueError("safe_volume_limit must be between 1 and 20")
167+
self.safe_volume_limit = safe_volume_limit
168+
self._volume = 7
169+
self._apply_volume()
163170

164171
self._sd_mounted = False
165172
sd_pins_in_use = False
@@ -266,3 +273,61 @@ def stop_play(self):
266273
self.audio.stop()
267274
if self.wavfile is not None:
268275
self.wavfile.close()
276+
277+
@property
278+
def volume(self) -> int:
279+
"""
280+
The volume level of the Fruit Jam audio output. Valid values are 1-20.
281+
"""
282+
return self._volume
283+
284+
@volume.setter
285+
def volume(self, volume_level: int) -> None:
286+
"""
287+
:param volume_level: new volume level 1-20
288+
:return: None
289+
"""
290+
if not (1 <= volume_level <= 20):
291+
raise ValueError("Volume level must be between 1 and 20")
292+
293+
if volume_level > self.safe_volume_limit:
294+
raise ValueError(
295+
f"""Volume level must be less than or equal to
296+
safe_volume_limit: {self.safe_volume_limit}. Using higher values could damage speakers.
297+
To override this limitation set a larger value than {self.safe_volume_limit}
298+
for the safe_volume_limit with the constructor or property."""
299+
)
300+
301+
self._volume = volume_level
302+
self._apply_volume()
303+
304+
@property
305+
def audio_output(self) -> str:
306+
"""
307+
The audio output interface. 'speaker' or 'headphone'
308+
:return:
309+
"""
310+
return self._audio_output
311+
312+
@audio_output.setter
313+
def audio_output(self, audio_output: str) -> None:
314+
"""
315+
316+
:param audio_output: The audio interface to use 'speaker' or 'headphone'.
317+
:return: None
318+
"""
319+
if audio_output == "headphone":
320+
self._dac.headphone_output = True
321+
self._dac.speaker_output = False
322+
elif audio_output == "speaker":
323+
self._dac.headphone_output = False
324+
self._dac.speaker_output = True
325+
else:
326+
raise ValueError("audio_output must be either 'headphone' or 'speaker'")
327+
328+
def _apply_volume(self) -> None:
329+
"""
330+
Map the basic volume level to a db value and set it on the DAC.
331+
"""
332+
db_val = map_range(self._volume, 1, 20, -63, 23)
333+
self._dac.dac_volume = db_val

docs/conf.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
"audiocore",
3434
"storage",
3535
"terminalio",
36+
"adafruit_connection_manager",
37+
"adafruit_ntp",
38+
"rtc",
3639
]
3740

3841
autodoc_preserve_defaults = True

examples/fruitjam_headphone.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,16 @@
55

66
import adafruit_fruitjam
77

8-
pobj = adafruit_fruitjam.peripherals.Peripherals()
9-
dac = pobj.dac # use Fruit Jam's codec
10-
11-
# Route once for headphones
12-
dac.headphone_output = True
13-
dac.speaker_output = False
8+
pobj = adafruit_fruitjam.peripherals.Peripherals(audio_output="headphone")
149

1510
FILES = ["beep.wav", "dip.wav", "rise.wav"]
16-
VOLUMES_DB = [12, 6, 0, -6, -12]
11+
VOLUMES = [5, 7, 10, 11, 12]
1712

1813
while True:
1914
print("\n=== Headphones Test ===")
20-
for vol in VOLUMES_DB:
21-
dac.dac_volume = vol
22-
print(f"Headphones volume: {vol} dB")
15+
for vol in VOLUMES:
16+
pobj.volume = vol
17+
print(f"Headphones volume: {vol}")
2318
for f in FILES:
2419
print(f" -> {f}")
2520
pobj.play_file(f)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025 Mikey Sklar for Adafruit Industries
2+
#
3+
# SPDX-License-Identifier: MIT
4+
# Wi-Fi credentials
5+
CIRCUITPY_WIFI_SSID = "YourSSID"
6+
CIRCUITPY_WIFI_PASSWORD = "YourPassword"
7+
8+
# NTP settings
9+
# Common UTC offsets (hours):
10+
# 0 UTC / Zulu
11+
# 1 CET (Central Europe)
12+
# 2 EET (Eastern Europe)
13+
# 3 FET (Further Eastern Europe)
14+
# -5 EST (Eastern US)
15+
# -6 CST (Central US)
16+
# -7 MST (Mountain US)
17+
# -8 PST (Pacific US)
18+
# -9 AKST (Alaska)
19+
# -10 HST (Hawaii, no DST)
20+
21+
NTP_SERVER = "pool.ntp.org" # NTP host (default pool.ntp.org)
22+
NTP_TZ = -5 # timezone offset in hours
23+
NTP_DST = 1 # daylight saving (0=no, 1=yes)
24+
NTP_INTERVAL = 3600 # re-sync interval (seconds)
25+
26+
# Optional tuning
27+
NTP_TIMEOUT = "1.0" # socket timeout in seconds
28+
NTP_CACHE_SECONDS = 0 # cache results (0 = always fetch)
29+
NTP_REQUIRE_YEAR = 2022 # sanity check minimum year
30+
31+
# Retries
32+
NTP_RETRIES = 8 # number of NTP fetch attempts
33+
NTP_DELAY_S = "1.5" # delay between attempts (seconds)

examples/fruitjam_speaker.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,16 @@
55

66
import adafruit_fruitjam
77

8-
pobj = adafruit_fruitjam.peripherals.Peripherals()
9-
dac = pobj.dac # use Fruit Jam's codec
10-
11-
# Route once for speaker
12-
dac.headphone_output = False
13-
dac.speaker_output = True
8+
pobj = adafruit_fruitjam.peripherals.Peripherals(audio_output="speaker")
149

1510
FILES = ["beep.wav", "dip.wav", "rise.wav"]
16-
VOLUMES_DB = [12, 6, 0, -6, -12]
11+
VOLUMES = [5, 7, 10, 11, 12]
1712

1813
while True:
1914
print("\n=== Speaker Test ===")
20-
for vol in VOLUMES_DB:
21-
dac.dac_volume = vol
22-
print(f"Speaker volume: {vol} dB")
15+
for vol in VOLUMES:
16+
pobj.volume = vol
17+
print(f"Speaker volume: {vol}")
2318
for f in FILES:
2419
print(f" -> {f}")
2520
pobj.play_file(f)

0 commit comments

Comments
 (0)