Repository: adafruit/Adafruit_CircuitPython_NTP (language: python)

Issue:
tz_offset is incorrectly documented in the code and documentation
The code describes the tz_offset code as:
        :param int tz_offset: The offset of the local timezone,
            in seconds west of UTC (negative in most of Western Europe,
            positive in the US, zero in the UK).
        """

        try:
            now = self._esp.get_time()
            now = time.localtime(now[0] + tz_offset)

Timezone offset in the US is negative. For example MST7 is -25200.
Dependence on adafruit_esp32spi
It seems this library only works with adafruit_esp32spi.
What can be done to make it compatible with other backends such as adafruit_wiznet5k?

The current implementation calls `get_time` on on the interface object.  So is the right way to implement this to add this method to the adafruit_wiznet5k interface object?  Or should this module be rewritten to use generic socket calls?


Discussion / hints:

Fix patch:
diff --git a/README.rst b/README.rst
index 078319e..f1ce534 100644
--- a/README.rst
+++ b/README.rst
@@ -55,43 +55,26 @@ Usage Example
 
 .. code-block:: python
 
+    import adafruit_ntp
+    import socketpool
     import time
-    import board
-    import busio
-    from digitalio import DigitalInOut
-    from adafruit_esp32spi import adafruit_esp32spi
-    from adafruit_ntp import NTP
-
-    # If you are using a board with pre-defined ESP32 Pins:
-    esp32_cs = DigitalInOut(board.ESP_CS)
-    esp32_ready = DigitalInOut(board.ESP_BUSY)
-    esp32_reset = DigitalInOut(board.ESP_RESET)
-
-    # If you have an externally connected ESP32:
-    # esp32_cs = DigitalInOut(board.D9)
-    # esp32_ready = DigitalInOut(board.D10)
-    # esp32_reset = DigitalInOut(board.D5)
-
-    spi = busio.SPI(board.SCK, board.MOSI, board.MISO)
-    esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset)
-
-    print("Connecting to AP...")
-    while not esp.is_connected:
-        try:
-            esp.connect_AP(b"WIFI_SSID", b"WIFI_PASS")
-        except RuntimeError as e:
-            print("could not connect to AP, retrying: ", e)
-            continue
-
-    # Initialize the NTP object
-    ntp = NTP(esp)
-
-    # Fetch and set the microcontroller's current UTC time
-    ntp.set_time()
-
-    # Get the current time in seconds since Jan 1, 1970
-    current_time = time.time()
-    print("Seconds since Jan 1, 1970: {} seconds".format(current_time))
+    import wifi
+
+    # Get wifi details and more from a secrets.py file
+    try:
+        from secrets import secrets
+    except ImportError:
+        print("WiFi secrets are kept in secrets.py, please add them there!")
+        raise
+
+    wifi.radio.connect(secrets["ssid"], secrets["password"])
+
+    pool = socketpool.SocketPool(wifi.radio)
+    ntp = adafruit_ntp.NTP(pool, tz_offset=0)
+
+    while True:
+        print(ntp.datetime)
+        time.sleep(1)
 
 
 Documentation
diff --git a/adafruit_ntp.py b/adafruit_ntp.py
index 1f3ac83..59d5aec 100644
--- a/adafruit_ntp.py
+++ b/adafruit_ntp.py
@@ -1,4 +1,4 @@
-# SPDX-FileCopyrightText: 2019 Brent Rubell for Adafruit Industries
+# SPDX-FileCopyrightText: 2022 Scott Shawcroft for Adafruit Industries
 #
 # SPDX-License-Identifier: MIT
 
@@ -8,7 +8,7 @@
 
 Network Time Protocol (NTP) helper for CircuitPython
 
- * Author(s): Brent Rubell
+ * Author(s): Scott Shawcroft
 
 Implementation Notes
 --------------------
@@ -19,52 +19,74 @@
    https://github.com/adafruit/circuitpython/releases
 
 """
+import struct
 import time
-import rtc
-
-try:
-    # Used only for typing
-    import typing  # pylint: disable=unused-import
-    from adafruit_esp32spi.adafruit_esp32spi import ESP_SPIcontrol
-except ImportError:
-    pass
 
 __version__ = "0.0.0-auto.0"
 __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_NTP.git"
 
+NTP_TO_UNIX_EPOCH = 2208988800  # 1970-01-01 00:00:00
+
 
 class NTP:
     """Network Time Protocol (NTP) helper module for CircuitPython.
-    This module does not handle daylight savings or local time.
-
-    :param adafruit_esp32spi esp: ESP32SPI object.
-    :param bool debug: Set to True to output set_time() failures to console
+    This module does not handle daylight savings or local time. It simply requests
+    UTC from a NTP server.
     """
 
-    def __init__(self, esp: ESP_SPIcontrol, debug: bool = False) -> None:
-        # Verify ESP32SPI module
-        if "ESP_SPIcontrol" in str(type(esp)):
-            self._esp = esp
-        else:
-            raise TypeError("Provided object is not an ESP_SPIcontrol object.")
-        self.valid_time = False
-        self.debug = debug
+    def __init__(
+        self,
+        socketpool,
+        *,
+        server: str = "0.adafruit.pool.ntp.org",
+        port: int = 123,
+        tz_offset: int = 0,
+    ) -> None:
+        """
+        :param object socketpool: A socket provider such as CPython's `socket` module.
+        :param str server: The domain of the ntp server to query.
+        :param int port: The port of the ntp server to query.
+        :param float tz_offset: Timezone offset in hours from UTC. Only useful for timezone ignorant
+            CircuitPython. CPython will determine timezone automatically and adjust (so don't use
+            this.) For example, Pacific daylight savings time is -7.
+        """
+        self._pool = socketpool
+        self._server = server
+        self._port = port
+        self._packet = bytearray(48)
+        self._tz_offset = tz_offset * 60 * 60
 
-    def set_time(self, tz_offset: int = 0) -> None:
-        """Fetches and sets the microcontroller's current time
-        in seconds since since Jan 1, 1970.
+        # This is our estimated start time for the monotonic clock. We adjust it based on the ntp
+        # responses.
+        self._monotonic_start = 0
 
-        :param int tz_offset: The offset of the local timezone,
-            in seconds west of UTC (negative in most of Western Europe,
-            positive in the US, zero in the UK).
-        """
+        self.next_sync = 0
+
+    @property
+    def datetime(self) -> time.struct_time:
+        """Current time from NTP server."""
+        if time.monotonic_ns() > self.next_sync:
+            self._packet[0] = 0b00100011  # Not leap second, NTP version 4, Client mode
+            for i in range(1, len(self._packet)):
+                self._packet[i] = 0
+            with self._pool.socket(self._pool.AF_INET, self._pool.SOCK_DGRAM) as sock:
+                sock.sendto(self._packet, (self._server, self._port))
+                sock.recvfrom_into(self._packet)
+                # Get the time in the context to minimize the difference between it and receiving
+                # the packet.
+                destination = time.monotonic_ns()
+            poll = struct.unpack_from("!B", self._packet, offset=2)[0]
+            self.next_sync = destination + (2**poll) * 1_000_000_000
+            seconds = struct.unpack_from(
+                "!I", self._packet, offset=len(self._packet) - 8
+            )[0]
+            self._monotonic_start = (
+                seconds
+                + self._tz_offset
+                - NTP_TO_UNIX_EPOCH
+                - (destination // 1_000_000_000)
+            )
 
-        try:
-            now = self._esp.get_time()
-            now = time.localtime(now[0] + tz_offset)
-            rtc.RTC().datetime = now
-            self.valid_time = True
-        except ValueError as error:
-            if self.debug:
-                print(str(error))
-            return
+        return time.localtime(
+            time.monotonic_ns() // 1_000_000_000 + self._monotonic_start
+        )
diff --git a/docs/examples.rst b/docs/examples.rst
index 7493fa0..053a48a 100644
--- a/docs/examples.rst
+++ b/docs/examples.rst
@@ -1,8 +1,26 @@
 Simple test
 ------------
 
-Ensure your device works with this simple test.
+Ensure your device works with this simple test that prints out the time from NTP.
 
 .. literalinclude:: ../examples/ntp_simpletest.py
     :caption: examples/ntp_simpletest.py
     :linenos:
+
+Set RTC
+------------
+
+Sync your CircuitPython board's realtime clock (RTC) with time from NTP.
+
+.. literalinclude:: ../examples/ntp_set_rtc.py
+    :caption: examples/ntp_set_rtc.py
+    :linenos:
+
+Simple test with CPython
+------------------------
+
+Test the library in CPython using ``socket``.
+
+.. literalinclude:: ../examples/ntp_cpython.py
+    :caption: examples/ntp_cpython.py
+    :linenos:
diff --git a/examples/ntp_cpython.py b/examples/ntp_cpython.py
new file mode 100644
index 0000000..76d5735
--- /dev/null
+++ b/examples/ntp_cpython.py
@@ -0,0 +1,16 @@
+# SPDX-FileCopyrightText: 2022 Scott Shawcroft for Adafruit Industries
+# SPDX-License-Identifier: MIT
+
+"""Tests NTP with CPython socket"""
+
+import socket
+import time
+
+import adafruit_ntp
+
+# Don't use tz_offset kwarg with CPython because it will adjust automatically.
+ntp = adafruit_ntp.NTP(socket)
+
+while True:
+    print(ntp.datetime)
+    time.sleep(1)
diff --git a/examples/ntp_set_rtc.py b/examples/ntp_set_rtc.py
new file mode 100644
index 0000000..363c8b4
--- /dev/null
+++ b/examples/ntp_set_rtc.py
@@ -0,0 +1,32 @@
+# SPDX-FileCopyrightText: 2022 Scott Shawcroft for Adafruit Industries
+# SPDX-License-Identifier: MIT
+
+"""Example demonstrating how to set the realtime clock (RTC) based on NTP time."""
+
+import time
+
+import rtc
+import socketpool
+import wifi
+
+import adafruit_ntp
+
+# Get wifi details and more from a secrets.py file
+try:
+    from secrets import secrets
+except ImportError:
+    print("WiFi secrets are kept in secrets.py, please add them there!")
+    raise
+
+wifi.radio.connect(secrets["ssid"], secrets["password"])
+
+pool = socketpool.SocketPool(wifi.radio)
+ntp = adafruit_ntp.NTP(pool, tz_offset=0)
+
+# NOTE: This changes the system time so make sure you aren't assuming that time
+# doesn't jump.
+rtc.RTC().datetime = ntp.datetime
+
+while True:
+    print(time.localtime())
+    time.sleep(1)
diff --git a/requirements.txt b/requirements.txt
index c5f36e9..c0ada24 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,3 @@
 # SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries
 #
 # SPDX-License-Identifier: Unlicense
-
-Adafruit-Blinka
-adafruit-circuitpython-esp32spi
diff --git a/setup.py b/setup.py
index 56f78bc..5ce60c7 100644
--- a/setup.py
+++ b/setup.py
@@ -25,7 +25,7 @@
     name="adafruit-circuitpython-ntp",
     use_scm_version=True,
     setup_requires=["setuptools_scm"],
-    description="Network Time Protocol (NTP) helper for CircuitPython",
+    description="Network Time Protocol (NTP) helper for Python",
     long_description=long_description,
     long_description_content_type="text/x-rst",
     # The project's main homepage.
@@ -33,7 +33,7 @@
     # Author details
     author="Adafruit Industries",
     author_email="circuitpython@adafruit.com",
-    install_requires=["Adafruit-Blinka", "adafruit-circuitpython-esp32spi"],
+    install_requires=[],
     # Choose your license
     license="MIT",
     # See https://pypi.python.org/pypi?%3Aaction=list_classifiers


Test patch:
diff --git a/examples/ntp_simpletest.py b/examples/ntp_simpletest.py
index 9db4ab0..0258e16 100644
--- a/examples/ntp_simpletest.py
+++ b/examples/ntp_simpletest.py
@@ -1,55 +1,27 @@
-# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries
+# SPDX-FileCopyrightText: 2022 Scott Shawcroft for Adafruit Industries
 # SPDX-License-Identifier: MIT
 
+"""Print out time based on NTP."""
+
 import time
-import board
-import busio
-from digitalio import DigitalInOut
-from adafruit_esp32spi import adafruit_esp32spi
-from adafruit_ntp import NTP
-
-# If you are using a board with pre-defined ESP32 Pins:
-esp32_cs = DigitalInOut(board.ESP_CS)
-esp32_ready = DigitalInOut(board.ESP_BUSY)
-esp32_reset = DigitalInOut(board.ESP_RESET)
-
-# If you have an externally connected ESP32:
-# esp32_cs = DigitalInOut(board.D9)
-# esp32_ready = DigitalInOut(board.D10)
-# esp32_reset = DigitalInOut(board.D5)
-
-spi = busio.SPI(board.SCK, board.MOSI, board.MISO)
-esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset)
-
-print("Connecting to AP...")
-while not esp.is_connected:
-    try:
-        esp.connect_AP(b"WIFI_SSID", b"WIFI_PASS")
-    except RuntimeError as e:
-        print("could not connect to AP, retrying: ", e)
-        continue
-
-# Initialize the NTP object
-ntp = NTP(esp)
-
-# Fetch and set the microcontroller's current UTC time
-# keep retrying until a valid time is returned
-while not ntp.valid_time:
-    ntp.set_time()
-    print("Failed to obtain time, retrying in 5 seconds...")
-    time.sleep(5)
-
-# Get the current time in seconds since Jan 1, 1970
-current_time = time.time()
-print("Seconds since Jan 1, 1970: {} seconds".format(current_time))
-
-# Convert the current time in seconds since Jan 1, 1970 to a struct_time
-now = time.localtime(current_time)
-print(now)
-
-# Pretty-parse the struct_time
-print(
-    "It is currently {}/{}/{} at {}:{}:{} UTC".format(
-        now.tm_mon, now.tm_mday, now.tm_year, now.tm_hour, now.tm_min, now.tm_sec
-    )
-)
+
+import socketpool
+import wifi
+
+import adafruit_ntp
+
+# Get wifi details and more from a secrets.py file
+try:
+    from secrets import secrets
+except ImportError:
+    print("WiFi secrets are kept in secrets.py, please add them there!")
+    raise
+
+wifi.radio.connect(secrets["ssid"], secrets["password"])
+
+pool = socketpool.SocketPool(wifi.radio)
+ntp = adafruit_ntp.NTP(pool, tz_offset=0)
+
+while True:
+    print(ntp.datetime)
+    time.sleep(1)