From 544adaa447342367493fe723a577025f2adf5fa3 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Thu, 21 Aug 2025 17:41:36 -0700 Subject: [PATCH 1/9] ntp module helper code to get time sync with minimal fuss on the Fruit Jam. --- adafruit_fruitjam/ntp.py | 164 +++++++++++++++++++++++++++++++++++++++ examples/fruitjam_ntp.py | 10 +++ examples/settings.toml | 23 ++++++ 3 files changed, 197 insertions(+) create mode 100644 adafruit_fruitjam/ntp.py create mode 100644 examples/fruitjam_ntp.py create mode 100644 examples/settings.toml diff --git a/adafruit_fruitjam/ntp.py b/adafruit_fruitjam/ntp.py new file mode 100644 index 0000000..94b9159 --- /dev/null +++ b/adafruit_fruitjam/ntp.py @@ -0,0 +1,164 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +Fruit Jam NTP helper (one-shot) +- Reads Wi-Fi creds (CIRCUITPY_WIFI_SSID/PASSWORD) +- Reads optional NTP_* settings (server, tz, dst, interval, timeout, etc.) +- Connects AirLift, queries NTP, sets rtc.RTC().datetime +- Returns (now, next_sync) where next_sync is None if NTP_INTERVAL is 0/absent +""" + +import os +import time + +import adafruit_connection_manager as acm +import adafruit_ntp +import board +import rtc +from adafruit_esp32spi import adafruit_esp32spi +from digitalio import DigitalInOut + + +class _State: + """Mutable holder to avoid module-level 'global' updates (ruff PLW0603).""" + + def __init__(self): + self.spi = None + self.cs = None + self.rdy = None + self.rst = None + self.esp = None + self.pool = None + + +_state = _State() + + +def _ensure_radio(): + if _state.esp and _state.pool: + return _state.esp, _state.pool + + if _state.spi is None: + _state.spi = board.SPI() + + if _state.cs is None: + _state.cs = DigitalInOut(board.ESP_CS) + if _state.rdy is None: + _state.rdy = DigitalInOut(board.ESP_BUSY) + if _state.rst is None: + _state.rst = DigitalInOut(board.ESP_RESET) + + if _state.esp is None: + _state.esp = adafruit_esp32spi.ESP_SPIcontrol(_state.spi, _state.cs, _state.rdy, _state.rst) + + if _state.pool is None: + _state.pool = acm.get_radio_socketpool(_state.esp) + + return _state.esp, _state.pool + + +def _env_float(name, default): + try: + v = os.getenv(name) + return float(v) if v not in {None, ""} else float(default) + except Exception: + return float(default) + + +def _env_int(name, default): + try: + v = os.getenv(name) + return int(v) if v not in {None, ""} else int(default) + except Exception: + return int(default) + + +def sync_time(*, server=None, tz_offset=None, tuning=None): + """ + One-call NTP sync. Small public API to satisfy ruff PLR0913. + server: override NTP_SERVER + tz_offset: override NTP_TZ (+ NTP_DST is still applied) + tuning: optional dict to override timeouts/retries/cache/year check, e.g.: + {"timeout": 5.0, "retries": 2, "retry_delay": 1.0, + "cache_seconds": 0, "require_year": 2022} + + Returns (now, next_sync). next_sync is None if NTP_INTERVAL is disabled. + """ + # Wi-Fi creds (required) + ssid = os.getenv("CIRCUITPY_WIFI_SSID") + pw = os.getenv("CIRCUITPY_WIFI_PASSWORD") + if not ssid or not pw: + raise RuntimeError("Add CIRCUITPY_WIFI_SSID/PASSWORD to settings.toml") + + # NTP config (env defaults, overridable by parameters) + server = server or os.getenv("NTP_SERVER") or "pool.ntp.org" + if tz_offset is None: + tz_offset = _env_float("NTP_TZ", 0.0) + tz_offset += _env_float("NTP_DST", 0.0) + + # Tuning knobs + t = tuning or {} + timeout = float(t.get("timeout", _env_float("NTP_TIMEOUT", 5.0))) + retries = int(t.get("retries", _env_int("NTP_RETRIES", 2))) + retry_delay = float(t.get("retry_delay", _env_float("NTP_DELAY_S", 1.0))) + cache_seconds = int(t.get("cache_seconds", _env_int("NTP_CACHE_SECONDS", 0))) + require_year = int(t.get("require_year", 2022)) + interval = _env_int("NTP_INTERVAL", 0) + + esp, pool = _ensure_radio() + + # Connect with light retries + for attempt in range(retries + 1): + try: + if not esp.is_connected: + esp.connect_AP(ssid, pw) + break + except Exception: + if attempt >= retries: + raise + try: + esp.reset() + except Exception: + pass + time.sleep(retry_delay) + + ntp = adafruit_ntp.NTP( + pool, + tz_offset=tz_offset, + server=server, + socket_timeout=timeout, + cache_seconds=cache_seconds, + ) + + now = ntp.datetime + if now.tm_year < require_year: + raise RuntimeError("NTP returned an unexpected year; not setting RTC") + + rtc.RTC().datetime = now + next_sync = time.time() + interval if interval > 0 else None + return now, next_sync + + +def release_pins(): + """Free pins if hot-reloading during development.""" + try: + for pin in (_state.cs, _state.rdy, _state.rst): + if pin: + pin.deinit() + finally: + _state.spi = _state.cs = _state.rdy = _state.rst = _state.esp = _state.pool = None + + +def setup_ntp(): + """Retry wrapper that prints status; useful while developing.""" + print("Fetching time via NTP.") + while True: + try: + now, next_sync = sync_time() + break + except Exception as ex: + print("Exception:", ex) + time.sleep(1) + print("NTP OK, localtime:", time.localtime()) + return now, next_sync diff --git a/examples/fruitjam_ntp.py b/examples/fruitjam_ntp.py new file mode 100644 index 0000000..53e570a --- /dev/null +++ b/examples/fruitjam_ntp.py @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT +# +# see examples/settings.toml for NTP_ options +# +from adafruit_fruitjam.ntp import sync_time + +now, next_sync = sync_time() +print("RTC set:", now) diff --git a/examples/settings.toml b/examples/settings.toml new file mode 100644 index 0000000..ca9ba87 --- /dev/null +++ b/examples/settings.toml @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT +# Wi-Fi settings (required) +CIRCUITPY_WIFI_SSID = "YourSSID" +CIRCUITPY_WIFI_PASSWORD = "YourPassword" + +# Time zone offset in hours relative to UTC (default 0 if not set) +# Examples: +# 0 = UTC (Zulu) +# 1 = CET (Central European Time) +# 2 = EET (Eastern European Time) +# 3 = FET (Further Eastern European Time) +# -5 = EST (Eastern Standard Time) +# -6 = CST (Central Standard Time) +# -7 = MST (Mountain Standard Time) +# -8 = PST (Pacific Standard Time) +# -9 = AKST (Alaska Standard Time) +# -10 = HST (Hawaii Standard Time, no DST) +NTP_SERVER = "pool.ntp.org" +NTP_TZ = -5 +NTP_DST = 1 +NTP_INTERVAL = 3600 From d1d0bb0932e9e4a4eb8bc3963df0610d1109d1f2 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Fri, 22 Aug 2025 18:30:11 -0700 Subject: [PATCH 2/9] refactored to use fruitjam network module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- adafruit_fruitjam/__init__.py | 17 ++ adafruit_fruitjam/network.py | 87 ++++++++++ adafruit_fruitjam/ntp.py | 164 ------------------ examples/fruitjam_ntp_settings.toml | 29 ++++ ...{fruitjam_ntp.py => fruitjam_time_sync.py} | 11 +- examples/settings.toml | 23 --- 6 files changed, 139 insertions(+), 192 deletions(-) delete mode 100644 adafruit_fruitjam/ntp.py create mode 100644 examples/fruitjam_ntp_settings.toml rename examples/{fruitjam_ntp.py => fruitjam_time_sync.py} (51%) delete mode 100644 examples/settings.toml diff --git a/adafruit_fruitjam/__init__.py b/adafruit_fruitjam/__init__.py index 57240b9..7039a71 100644 --- a/adafruit_fruitjam/__init__.py +++ b/adafruit_fruitjam/__init__.py @@ -245,6 +245,23 @@ def __init__( # noqa: PLR0912,PLR0913,Too many branches,Too many arguments in f 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(...)`` so + user code can simply call ``fruitjam.sync_time()``. + + Keyword args are passed through to ``Network.sync_time``: + - server (str) : NTP host (default from NTP_SERVER or pool.ntp.org) + - tz_offset (float) : hours from UTC (default from NTP_TZ + NTP_DST) + - timeout (float) : socket timeout seconds (default from NTP_TIMEOUT or 5.0) + - cache_seconds (int) : NTP cache seconds (default from NTP_CACHE_SECONDS or 0) + - require_year (int) : sanity check lower bound (default 2022) + Returns: + 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! diff --git a/adafruit_fruitjam/network.py b/adafruit_fruitjam/network.py index 8c73b98..a69557b 100644 --- a/adafruit_fruitjam/network.py +++ b/adafruit_fruitjam/network.py @@ -25,9 +25,13 @@ """ import gc +import os +import adafruit_connection_manager as acm +import adafruit_ntp import microcontroller import neopixel +import rtc from adafruit_portalbase.network import ( CONTENT_IMAGE, CONTENT_JSON, @@ -209,3 +213,86 @@ def process_image(self, json_data, sd_card=False): # noqa: PLR0912 Too many bra 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 (default "pool.ntp.org") + NTP_TZ (float hours from UTC, default 0) + NTP_DST (additional offset, usually 0 or 1) + NTP_TIMEOUT (seconds, default 5.0) + NTP_CACHE_SECONDS (default 0 = always fetch fresh) + NTP_REQUIRE_YEAR (minimum acceptable year, default 2022) + + Keyword args: + server (str) – override NTP_SERVER + tz_offset (float) – override NTP_TZ (+ NTP_DST still applied) + tuning (dict) – override other knobs: + {"timeout": 5.0, + "cache_seconds": 0, + "require_year": 2022} + + Returns: + time.struct_time + """ + # Bring up Wi-Fi using the existing flow. + self.connect() + + # Build a socket pool from the existing ESP interface. + pool = acm.get_radio_socketpool(self._wifi.esp) + + # Settings with environment fallbacks. + server = server or os.getenv("NTP_SERVER") or "pool.ntp.org" + + if tz_offset is None: + tz_env = os.getenv("NTP_TZ") + try: + tz_offset = float(tz_env) if tz_env not in {None, ""} else 0.0 + except Exception: + tz_offset = 0.0 + + # Simple DST additive offset (no IANA time zone logic). + try: + dst = float(os.getenv("NTP_DST") or 0) + except Exception: + dst = 0.0 + tz_offset += dst + + # Optional tuning (env can override passed defaults). + t = tuning or {} + + def _f(name, default): + v = os.getenv(name) + try: + return float(v) if v not in {None, ""} else float(default) + except Exception: + return float(default) + + def _i(name, default): + v = os.getenv(name) + try: + return int(v) if v not in {None, ""} else int(default) + except Exception: + return int(default) + + timeout = float(t.get("timeout", _f("NTP_TIMEOUT", 5.0))) + cache_seconds = int(t.get("cache_seconds", _i("NTP_CACHE_SECONDS", 0))) + require_year = int(t.get("require_year", _i("NTP_REQUIRE_YEAR", 2022))) + + # Query NTP and set the system RTC. + ntp = adafruit_ntp.NTP( + pool, + server=server, + tz_offset=tz_offset, + socket_timeout=timeout, + cache_seconds=cache_seconds, + ) + now = ntp.datetime # struct_time + + if now.tm_year < require_year: + raise RuntimeError("NTP returned an unexpected year; not setting RTC") + + rtc.RTC().datetime = now + return now diff --git a/adafruit_fruitjam/ntp.py b/adafruit_fruitjam/ntp.py deleted file mode 100644 index 94b9159..0000000 --- a/adafruit_fruitjam/ntp.py +++ /dev/null @@ -1,164 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries -# -# SPDX-License-Identifier: MIT -""" -Fruit Jam NTP helper (one-shot) -- Reads Wi-Fi creds (CIRCUITPY_WIFI_SSID/PASSWORD) -- Reads optional NTP_* settings (server, tz, dst, interval, timeout, etc.) -- Connects AirLift, queries NTP, sets rtc.RTC().datetime -- Returns (now, next_sync) where next_sync is None if NTP_INTERVAL is 0/absent -""" - -import os -import time - -import adafruit_connection_manager as acm -import adafruit_ntp -import board -import rtc -from adafruit_esp32spi import adafruit_esp32spi -from digitalio import DigitalInOut - - -class _State: - """Mutable holder to avoid module-level 'global' updates (ruff PLW0603).""" - - def __init__(self): - self.spi = None - self.cs = None - self.rdy = None - self.rst = None - self.esp = None - self.pool = None - - -_state = _State() - - -def _ensure_radio(): - if _state.esp and _state.pool: - return _state.esp, _state.pool - - if _state.spi is None: - _state.spi = board.SPI() - - if _state.cs is None: - _state.cs = DigitalInOut(board.ESP_CS) - if _state.rdy is None: - _state.rdy = DigitalInOut(board.ESP_BUSY) - if _state.rst is None: - _state.rst = DigitalInOut(board.ESP_RESET) - - if _state.esp is None: - _state.esp = adafruit_esp32spi.ESP_SPIcontrol(_state.spi, _state.cs, _state.rdy, _state.rst) - - if _state.pool is None: - _state.pool = acm.get_radio_socketpool(_state.esp) - - return _state.esp, _state.pool - - -def _env_float(name, default): - try: - v = os.getenv(name) - return float(v) if v not in {None, ""} else float(default) - except Exception: - return float(default) - - -def _env_int(name, default): - try: - v = os.getenv(name) - return int(v) if v not in {None, ""} else int(default) - except Exception: - return int(default) - - -def sync_time(*, server=None, tz_offset=None, tuning=None): - """ - One-call NTP sync. Small public API to satisfy ruff PLR0913. - server: override NTP_SERVER - tz_offset: override NTP_TZ (+ NTP_DST is still applied) - tuning: optional dict to override timeouts/retries/cache/year check, e.g.: - {"timeout": 5.0, "retries": 2, "retry_delay": 1.0, - "cache_seconds": 0, "require_year": 2022} - - Returns (now, next_sync). next_sync is None if NTP_INTERVAL is disabled. - """ - # Wi-Fi creds (required) - ssid = os.getenv("CIRCUITPY_WIFI_SSID") - pw = os.getenv("CIRCUITPY_WIFI_PASSWORD") - if not ssid or not pw: - raise RuntimeError("Add CIRCUITPY_WIFI_SSID/PASSWORD to settings.toml") - - # NTP config (env defaults, overridable by parameters) - server = server or os.getenv("NTP_SERVER") or "pool.ntp.org" - if tz_offset is None: - tz_offset = _env_float("NTP_TZ", 0.0) - tz_offset += _env_float("NTP_DST", 0.0) - - # Tuning knobs - t = tuning or {} - timeout = float(t.get("timeout", _env_float("NTP_TIMEOUT", 5.0))) - retries = int(t.get("retries", _env_int("NTP_RETRIES", 2))) - retry_delay = float(t.get("retry_delay", _env_float("NTP_DELAY_S", 1.0))) - cache_seconds = int(t.get("cache_seconds", _env_int("NTP_CACHE_SECONDS", 0))) - require_year = int(t.get("require_year", 2022)) - interval = _env_int("NTP_INTERVAL", 0) - - esp, pool = _ensure_radio() - - # Connect with light retries - for attempt in range(retries + 1): - try: - if not esp.is_connected: - esp.connect_AP(ssid, pw) - break - except Exception: - if attempt >= retries: - raise - try: - esp.reset() - except Exception: - pass - time.sleep(retry_delay) - - ntp = adafruit_ntp.NTP( - pool, - tz_offset=tz_offset, - server=server, - socket_timeout=timeout, - cache_seconds=cache_seconds, - ) - - now = ntp.datetime - if now.tm_year < require_year: - raise RuntimeError("NTP returned an unexpected year; not setting RTC") - - rtc.RTC().datetime = now - next_sync = time.time() + interval if interval > 0 else None - return now, next_sync - - -def release_pins(): - """Free pins if hot-reloading during development.""" - try: - for pin in (_state.cs, _state.rdy, _state.rst): - if pin: - pin.deinit() - finally: - _state.spi = _state.cs = _state.rdy = _state.rst = _state.esp = _state.pool = None - - -def setup_ntp(): - """Retry wrapper that prints status; useful while developing.""" - print("Fetching time via NTP.") - while True: - try: - now, next_sync = sync_time() - break - except Exception as ex: - print("Exception:", ex) - time.sleep(1) - print("NTP OK, localtime:", time.localtime()) - return now, next_sync diff --git a/examples/fruitjam_ntp_settings.toml b/examples/fruitjam_ntp_settings.toml new file mode 100644 index 0000000..b570eba --- /dev/null +++ b/examples/fruitjam_ntp_settings.toml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks 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 = 5 # socket timeout in seconds +NTP_CACHE_SECONDS = 0 # cache results (0 = always fetch) +NTP_REQUIRE_YEAR = 2022 # sanity check minimum year diff --git a/examples/fruitjam_ntp.py b/examples/fruitjam_time_sync.py similarity index 51% rename from examples/fruitjam_ntp.py rename to examples/fruitjam_time_sync.py index 53e570a..1ddb57e 100644 --- a/examples/fruitjam_ntp.py +++ b/examples/fruitjam_time_sync.py @@ -1,10 +1,11 @@ # SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries # # SPDX-License-Identifier: MIT -# -# see examples/settings.toml for NTP_ options -# -from adafruit_fruitjam.ntp import sync_time +import time + +from adafruit_fruitjam import FruitJam -now, next_sync = sync_time() +fj = FruitJam() +now = fj.sync_time() print("RTC set:", now) +print("Localtime:", time.localtime()) diff --git a/examples/settings.toml b/examples/settings.toml deleted file mode 100644 index ca9ba87..0000000 --- a/examples/settings.toml +++ /dev/null @@ -1,23 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries -# -# SPDX-License-Identifier: MIT -# Wi-Fi settings (required) -CIRCUITPY_WIFI_SSID = "YourSSID" -CIRCUITPY_WIFI_PASSWORD = "YourPassword" - -# Time zone offset in hours relative to UTC (default 0 if not set) -# Examples: -# 0 = UTC (Zulu) -# 1 = CET (Central European Time) -# 2 = EET (Eastern European Time) -# 3 = FET (Further Eastern European Time) -# -5 = EST (Eastern Standard Time) -# -6 = CST (Central Standard Time) -# -7 = MST (Mountain Standard Time) -# -8 = PST (Pacific Standard Time) -# -9 = AKST (Alaska Standard Time) -# -10 = HST (Hawaii Standard Time, no DST) -NTP_SERVER = "pool.ntp.org" -NTP_TZ = -5 -NTP_DST = 1 -NTP_INTERVAL = 3600 From fc388c5ccbd2e31353f0ac98d7d043ad6a729f79 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Fri, 22 Aug 2025 18:37:32 -0700 Subject: [PATCH 3/9] mock imports / requirements ntp and connection_manager libs --- docs/conf.py | 2 ++ requirements.txt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 5afc3cc..f51668f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,6 +33,8 @@ "audiocore", "storage", "terminalio", + "adafruit_connection_manager", + "adafruit_ntp", ] autodoc_preserve_defaults = True diff --git a/requirements.txt b/requirements.txt index d26fdd0..1ff2196 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,5 @@ adafruit-circuitpython-requests adafruit-circuitpython-bitmap-font adafruit-circuitpython-display-text adafruit-circuitpython-sd +adafruit-circuitpython-ntp +adafruit-circuitpython-connectionmanager From 6b86bc95a626232c0741fc994a6f2324f251cf65 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Fri, 22 Aug 2025 18:49:03 -0700 Subject: [PATCH 4/9] rtc lib mock add --- docs/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/conf.py b/docs/conf.py index f51668f..f51f557 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,6 +35,7 @@ "terminalio", "adafruit_connection_manager", "adafruit_ntp", + "rtc", ] autodoc_preserve_defaults = True From 75e5fb7ac20105dfa86bb17d359f7f784134c0dc Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Fri, 22 Aug 2025 18:55:00 -0700 Subject: [PATCH 5/9] Update __init__.py blank line missing. --- adafruit_fruitjam/__init__.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/adafruit_fruitjam/__init__.py b/adafruit_fruitjam/__init__.py index 7039a71..79d902e 100644 --- a/adafruit_fruitjam/__init__.py +++ b/adafruit_fruitjam/__init__.py @@ -248,17 +248,20 @@ def __init__( # noqa: PLR0912,PLR0913,Too many branches,Too many arguments in f 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(...)`` so - user code can simply call ``fruitjam.sync_time()``. - - Keyword args are passed through to ``Network.sync_time``: - - server (str) : NTP host (default from NTP_SERVER or pool.ntp.org) - - tz_offset (float) : hours from UTC (default from NTP_TZ + NTP_DST) - - timeout (float) : socket timeout seconds (default from NTP_TIMEOUT or 5.0) - - cache_seconds (int) : NTP cache seconds (default from NTP_CACHE_SECONDS or 0) - - require_year (int) : sanity check lower bound (default 2022) - Returns: - time.struct_time + 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) From 96e4575d2e8cc88d0aac79b7e393e6f4c0f2bf5f Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 23 Aug 2025 18:15:57 -0700 Subject: [PATCH 6/9] retry logic for ETIMEOUT --- adafruit_fruitjam/network.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/adafruit_fruitjam/network.py b/adafruit_fruitjam/network.py index a69557b..2bf4171 100644 --- a/adafruit_fruitjam/network.py +++ b/adafruit_fruitjam/network.py @@ -289,7 +289,26 @@ def _i(name, default): socket_timeout=timeout, cache_seconds=cache_seconds, ) - now = ntp.datetime # struct_time + + # Query NTP and set the system RTC. + ntp = adafruit_ntp.NTP( + pool, + server=server, + tz_offset=tz_offset, + socket_timeout=timeout, + cache_seconds=cache_seconds, + ) + + try: + now = ntp.datetime # struct_time + except OSError as e: + # Retry once in case of transient ETIMEDOUT, after forcing reconnect. + if getattr(e, "errno", None) == 116 or "ETIMEDOUT" in str(e): + # Ensure radio is up again + self.connect() + now = ntp.datetime + else: + raise if now.tm_year < require_year: raise RuntimeError("NTP returned an unexpected year; not setting RTC") From 2e19356d9a77beb32244c89793a26d0a7ca52e22 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Mon, 25 Aug 2025 13:19:56 -0700 Subject: [PATCH 7/9] 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 --- adafruit_fruitjam/network.py | 73 ++++++++++++++++++----------- examples/fruitjam_ntp_settings.toml | 4 ++ 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/adafruit_fruitjam/network.py b/adafruit_fruitjam/network.py index 2bf4171..4008ba0 100644 --- a/adafruit_fruitjam/network.py +++ b/adafruit_fruitjam/network.py @@ -26,6 +26,7 @@ import gc import os +import time import adafruit_connection_manager as acm import adafruit_ntp @@ -219,20 +220,31 @@ 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 (default "pool.ntp.org") - NTP_TZ (float hours from UTC, default 0) - NTP_DST (additional offset, usually 0 or 1) - NTP_TIMEOUT (seconds, default 5.0) - NTP_CACHE_SECONDS (default 0 = always fetch fresh) - NTP_REQUIRE_YEAR (minimum acceptable year, default 2022) + + 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, + but available for user loop scheduling) + + 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 other knobs: - {"timeout": 5.0, - "cache_seconds": 0, - "require_year": 2022} + 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 @@ -290,25 +302,30 @@ def _i(name, default): cache_seconds=cache_seconds, ) - # Query NTP and set the system RTC. - ntp = adafruit_ntp.NTP( - pool, - server=server, - tz_offset=tz_offset, - socket_timeout=timeout, - cache_seconds=cache_seconds, - ) + # Multiple reply attempts on transient timeouts + ntp_retries = int(t.get("retries", _i("NTP_RETRIES", 8))) + ntp_delay_s = float(t.get("retry_delay", _f("NTP_DELAY_S", 1.0))) - try: - now = ntp.datetime # struct_time - except OSError as e: - # Retry once in case of transient ETIMEDOUT, after forcing reconnect. - if getattr(e, "errno", None) == 116 or "ETIMEDOUT" in str(e): - # Ensure radio is up again - self.connect() - now = ntp.datetime - else: - raise + last_exc = None + for attempt in range(ntp_retries): + try: + now = ntp.datetime # struct_time + break # success + except OSError as e: + last_exc = e + # Only retry on timeout-like errors + if getattr(e, "errno", None) == 116 or "ETIMEDOUT" in str(e): + # Reassert Wi-Fi via existing policy, then wait a bit + self.connect() + if self._debug: + print("NTP timeout, retry", attempt + 1, "of", ntp_retries) + time.sleep(ntp_delay_s) + continue + # Non-timeout: don't spin + break + + if last_exc and "now" not in locals(): + raise last_exc if now.tm_year < require_year: raise RuntimeError("NTP returned an unexpected year; not setting RTC") diff --git a/examples/fruitjam_ntp_settings.toml b/examples/fruitjam_ntp_settings.toml index b570eba..fe9c23a 100644 --- a/examples/fruitjam_ntp_settings.toml +++ b/examples/fruitjam_ntp_settings.toml @@ -27,3 +27,7 @@ NTP_INTERVAL = 3600 # re-sync interval (seconds) NTP_TIMEOUT = 5 # 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.0 # delay between attempts (seconds) From d2ae05eaed76a8370233fe1342076be7a58eba02 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Mon, 25 Aug 2025 14:47:56 -0700 Subject: [PATCH 8/9] helpers for sync_time ruff was getting upset about too many branches --- adafruit_fruitjam/network.py | 141 +++++++++++++++++++---------------- 1 file changed, 77 insertions(+), 64 deletions(-) diff --git a/adafruit_fruitjam/network.py b/adafruit_fruitjam/network.py index 4008ba0..f54c189 100644 --- a/adafruit_fruitjam/network.py +++ b/adafruit_fruitjam/network.py @@ -224,8 +224,7 @@ def sync_time(self, server=None, tz_offset=None, tuning=None): 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, - but available for user loop scheduling) + 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) @@ -249,86 +248,100 @@ def sync_time(self, server=None, tz_offset=None, tuning=None): Returns: time.struct_time """ - # Bring up Wi-Fi using the existing flow. + # Ensure Wi-Fi up self.connect() - # Build a socket pool from the existing ESP interface. + # Socket pool pool = acm.get_radio_socketpool(self._wifi.esp) - # Settings with environment fallbacks. + # Settings & overrides server = server or os.getenv("NTP_SERVER") or "pool.ntp.org" - - if tz_offset is None: - tz_env = os.getenv("NTP_TZ") - try: - tz_offset = float(tz_env) if tz_env not in {None, ""} else 0.0 - except Exception: - tz_offset = 0.0 - - # Simple DST additive offset (no IANA time zone logic). - try: - dst = float(os.getenv("NTP_DST") or 0) - except Exception: - dst = 0.0 - tz_offset += dst - - # Optional tuning (env can override passed defaults). + tz = tz_offset if tz_offset is not None else _combined_tz_offset(0.0) t = tuning or {} - def _f(name, default): - v = os.getenv(name) - try: - return float(v) if v not in {None, ""} else float(default) - except Exception: - return float(default) - - def _i(name, default): - v = os.getenv(name) - try: - return int(v) if v not in {None, ""} else int(default) - except Exception: - return int(default) - - timeout = float(t.get("timeout", _f("NTP_TIMEOUT", 5.0))) - cache_seconds = int(t.get("cache_seconds", _i("NTP_CACHE_SECONDS", 0))) - require_year = int(t.get("require_year", _i("NTP_REQUIRE_YEAR", 2022))) + 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))) - # Query NTP and set the system RTC. + # NTP client ntp = adafruit_ntp.NTP( pool, server=server, - tz_offset=tz_offset, + tz_offset=tz, socket_timeout=timeout, cache_seconds=cache_seconds, ) - # Multiple reply attempts on transient timeouts - ntp_retries = int(t.get("retries", _i("NTP_RETRIES", 8))) - ntp_delay_s = float(t.get("retry_delay", _f("NTP_DELAY_S", 1.0))) - - last_exc = None - for attempt in range(ntp_retries): - try: - now = ntp.datetime # struct_time - break # success - except OSError as e: - last_exc = e - # Only retry on timeout-like errors - if getattr(e, "errno", None) == 116 or "ETIMEDOUT" in str(e): - # Reassert Wi-Fi via existing policy, then wait a bit - self.connect() - if self._debug: - print("NTP timeout, retry", attempt + 1, "of", ntp_retries) - time.sleep(ntp_delay_s) - continue - # Non-timeout: don't spin - break - - if last_exc and "now" not in locals(): - raise last_exc + # 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.""" + last_exc = None + 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") From cb5d1db6220df01271f73f6c49a7bb07ff4d884a Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Mon, 25 Aug 2025 16:06:37 -0700 Subject: [PATCH 9/9] 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 --- adafruit_fruitjam/network.py | 2 +- examples/fruitjam_ntp_settings.toml | 8 ++++---- examples/fruitjam_time_sync.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/adafruit_fruitjam/network.py b/adafruit_fruitjam/network.py index f54c189..fc349ac 100644 --- a/adafruit_fruitjam/network.py +++ b/adafruit_fruitjam/network.py @@ -1,5 +1,6 @@ # 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 """ @@ -324,7 +325,6 @@ def _combined_tz_offset(base_default): def _ntp_get_datetime(ntp, connect_cb, retries, delay_s, debug=False): """Fetch ntp.datetime with limited retries on timeout; re-connect between tries.""" - last_exc = None for i in range(retries): last_exc = None try: diff --git a/examples/fruitjam_ntp_settings.toml b/examples/fruitjam_ntp_settings.toml index fe9c23a..4cd2313 100644 --- a/examples/fruitjam_ntp_settings.toml +++ b/examples/fruitjam_ntp_settings.toml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2025 Mikey Sklar for Adafruit Industries # # SPDX-License-Identifier: MIT # Wi-Fi credentials @@ -24,10 +24,10 @@ NTP_DST = 1 # daylight saving (0=no, 1=yes) NTP_INTERVAL = 3600 # re-sync interval (seconds) # Optional tuning -NTP_TIMEOUT = 5 # socket timeout in seconds +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.0 # delay between attempts (seconds) +NTP_RETRIES = 8 # number of NTP fetch attempts +NTP_DELAY_S = "1.5" # delay between attempts (seconds) diff --git a/examples/fruitjam_time_sync.py b/examples/fruitjam_time_sync.py index 1ddb57e..08a6af5 100644 --- a/examples/fruitjam_time_sync.py +++ b/examples/fruitjam_time_sync.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2025 Mikey Sklar for Adafruit Industries # # SPDX-License-Identifier: MIT import time