Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions adafruit_fruitjam/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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!

Expand Down
123 changes: 123 additions & 0 deletions adafruit_fruitjam/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,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,
Expand Down Expand Up @@ -209,3 +214,121 @@ 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,
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 tuning knobs, e.g.:
{
"timeout": 5.0,
"cache_seconds": 0,
"require_year": 2022,
"retries": 8,
"retry_delay": 1.0,
}

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,
)

# 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
Copy link
Contributor

@FoamyGuy FoamyGuy Aug 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that "now" not in locals() is getting confused here by scope or something. Or else perhaps I don't understand the intended behavior of the scope.

In any case it seems like this condition causes the current version not to work for me with default values for tuning and other arguments. In my case it seems to always time out on the first attempt, and then succeed on the second attempt. But when it gets to this check "now" not in locals() resolves to True which causes this to raise the OSError from the 1st attempt that timed out. Even though the second attempt succeeded.

I added this to confirm the value it resolves to and to verify that now is definitely definied and has a date object in it.

print(f"now locals: {'now' not in locals()}")
print(now)

Here is the full output which shows these values just before the exception trace

code.py output:
Connecting to AP AloeVera
attempt: 0
caught OSError [Errno 116] ETIMEDOUT
retrying
attempt: 1
success
now locals: True
struct_time(tm_year=2025, tm_mon=8, tm_mday=25, tm_hour=21, tm_min=55, tm_sec=35, tm_wday=0, tm_yday=237, tm_isdst=-1)
Traceback (most recent call last):
  File "code.py", line 114, in <module>
  File "/lib/adafruit_fruitjam/__init__.py", line 266, in sync_time
  File "/lib/adafruit_fruitjam/network.py", line 334, in sync_time
  File "/lib/adafruit_fruitjam/network.py", line 313, in sync_time
  File "adafruit_ntp.py", line 123, in datetime
  File "adafruit_ntp.py", line 91, in _update_time_sync
  File "/lib/adafruit_esp32spi/adafruit_esp32spi_socketpool.py", line 180, in recv_into
OSError: [Errno 116] ETIMEDOUT

I was able to resolve this locally by adding

success = False

before the attempt for loop. And setting success = True before the break of a successful fetch. Then changing this condition to:

        if last_exc and not success:
            raise last_exc

I think that makes the intent of the condition more clear, and it seems to be working now for me with this change.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the latest version is changed and likely doesn't have this issue based on only a quick glance at the code. I will re-test with latest version a bit later.


if now.tm_year < require_year:
raise RuntimeError("NTP returned an unexpected year; not setting RTC")

rtc.RTC().datetime = now
return now
3 changes: 3 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
"audiocore",
"storage",
"terminalio",
"adafruit_connection_manager",
"adafruit_ntp",
"rtc",
]

autodoc_preserve_defaults = True
Expand Down
33 changes: 33 additions & 0 deletions examples/fruitjam_ntp_settings.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also just noticed all the copyright notices are set to my name. You can set these to your name or handle if you want.

#
# 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

# Retries
NTP_RETRIES = 8 # number of NTP fetch attempts
NTP_DELAY_S = 1.0 # delay between attempts (seconds)
11 changes: 11 additions & 0 deletions examples/fruitjam_time_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks 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())
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ adafruit-circuitpython-requests
adafruit-circuitpython-bitmap-font
adafruit-circuitpython-display-text
adafruit-circuitpython-sd
adafruit-circuitpython-ntp
adafruit-circuitpython-connectionmanager