diff --git a/adafruit_fruitjam/__init__.py b/adafruit_fruitjam/__init__.py index 57240b9..79d902e 100644 --- a/adafruit_fruitjam/__init__.py +++ b/adafruit_fruitjam/__init__.py @@ -245,6 +245,26 @@ 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(...)``. + + :param str server: Override NTP host (defaults to ``NTP_SERVER`` or + ``"pool.ntp.org"`` if unset). (Pass via ``server=...`` in kwargs.) + :param float tz_offset: Override hours from UTC (defaults to ``NTP_TZ``; + ``NTP_DST`` is still added). (Pass via ``tz_offset=...``.) + :param dict tuning: Advanced options dict (optional). Supported keys: + ``timeout`` (float, socket timeout seconds; defaults to ``NTP_TIMEOUT`` or 5.0), + ``cache_seconds`` (int; defaults to ``NTP_CACHE_SECONDS`` or 0), + ``require_year`` (int; defaults to ``NTP_REQUIRE_YEAR`` or 2022). + (Pass via ``tuning={...}``.) + + :returns: Synced time + :rtype: time.struct_time + """ + return self.network.sync_time(**kwargs) + def set_caption(self, caption_text, caption_position, caption_color): """A caption. Requires setting ``caption_font`` in init! diff --git a/adafruit_fruitjam/network.py b/adafruit_fruitjam/network.py index 8c73b98..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 """ @@ -25,9 +26,14 @@ """ import gc +import os +import time +import adafruit_connection_manager as acm +import adafruit_ntp import microcontroller import neopixel +import rtc from adafruit_portalbase.network import ( CONTENT_IMAGE, CONTENT_JSON, @@ -209,3 +215,133 @@ 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 – 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") diff --git a/docs/conf.py b/docs/conf.py index 5afc3cc..f51f557 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,6 +33,9 @@ "audiocore", "storage", "terminalio", + "adafruit_connection_manager", + "adafruit_ntp", + "rtc", ] autodoc_preserve_defaults = True diff --git a/examples/fruitjam_ntp_settings.toml b/examples/fruitjam_ntp_settings.toml new file mode 100644 index 0000000..4cd2313 --- /dev/null +++ b/examples/fruitjam_ntp_settings.toml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 Mikey Sklar for Adafruit Industries +# +# SPDX-License-Identifier: MIT +# Wi-Fi credentials +CIRCUITPY_WIFI_SSID = "YourSSID" +CIRCUITPY_WIFI_PASSWORD = "YourPassword" + +# NTP settings +# Common UTC offsets (hours): +# 0 UTC / Zulu +# 1 CET (Central Europe) +# 2 EET (Eastern Europe) +# 3 FET (Further Eastern Europe) +# -5 EST (Eastern US) +# -6 CST (Central US) +# -7 MST (Mountain US) +# -8 PST (Pacific US) +# -9 AKST (Alaska) +# -10 HST (Hawaii, no DST) + +NTP_SERVER = "pool.ntp.org" # NTP host (default pool.ntp.org) +NTP_TZ = -5 # timezone offset in hours +NTP_DST = 1 # daylight saving (0=no, 1=yes) +NTP_INTERVAL = 3600 # re-sync interval (seconds) + +# Optional tuning +NTP_TIMEOUT = "1.0" # socket timeout in seconds +NTP_CACHE_SECONDS = 0 # cache results (0 = always fetch) +NTP_REQUIRE_YEAR = 2022 # sanity check minimum year + +# Retries +NTP_RETRIES = 8 # number of NTP fetch attempts +NTP_DELAY_S = "1.5" # delay between attempts (seconds) diff --git a/examples/fruitjam_time_sync.py b/examples/fruitjam_time_sync.py new file mode 100644 index 0000000..08a6af5 --- /dev/null +++ b/examples/fruitjam_time_sync.py @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 Mikey Sklar for Adafruit Industries +# +# SPDX-License-Identifier: MIT +import time + +from adafruit_fruitjam import FruitJam + +fj = FruitJam() +now = fj.sync_time() +print("RTC set:", now) +print("Localtime:", time.localtime()) 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