adafruit-circuitpython-ntp 3.3.7__tar.gz → 3.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. adafruit_circuitpython_ntp-3.4.0/.github/workflows/tests.yml +32 -0
  2. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/PKG-INFO +1 -1
  3. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/adafruit_circuitpython_ntp.egg-info/PKG-INFO +1 -1
  4. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/adafruit_circuitpython_ntp.egg-info/SOURCES.txt +10 -1
  5. adafruit_circuitpython_ntp-3.4.0/adafruit_ntp.py +225 -0
  6. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/pyproject.toml +1 -1
  7. adafruit_circuitpython_ntp-3.4.0/tests/README.md +63 -0
  8. adafruit_circuitpython_ntp-3.4.0/tests/README.md.license +3 -0
  9. adafruit_circuitpython_ntp-3.4.0/tests/conftest.py +22 -0
  10. adafruit_circuitpython_ntp-3.4.0/tests/harness.py +128 -0
  11. adafruit_circuitpython_ntp-3.4.0/tests/test_ntp_failover.py +218 -0
  12. adafruit_circuitpython_ntp-3.4.0/tests/test_poll_interval.py +98 -0
  13. adafruit_circuitpython_ntp-3.4.0/tests/test_real_sockets.py +158 -0
  14. adafruit_circuitpython_ntp-3.4.0/tests/test_response_validation.py +156 -0
  15. adafruit_circuitpython_ntp-3.3.7/adafruit_ntp.py +0 -139
  16. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/.gitattributes +0 -0
  17. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/.github/PULL_REQUEST_TEMPLATE/adafruit_circuitpython_pr.md +0 -0
  18. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/.github/workflows/build.yml +0 -0
  19. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/.github/workflows/failure-help-text.yml +0 -0
  20. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/.github/workflows/release_gh.yml +0 -0
  21. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/.github/workflows/release_pypi.yml +0 -0
  22. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/.gitignore +0 -0
  23. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/.pre-commit-config.yaml +0 -0
  24. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/.readthedocs.yaml +0 -0
  25. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/CODE_OF_CONDUCT.md +0 -0
  26. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/LICENSE +0 -0
  27. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/LICENSES/CC-BY-4.0.txt +0 -0
  28. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/LICENSES/MIT.txt +0 -0
  29. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/LICENSES/Unlicense.txt +0 -0
  30. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/README.rst +0 -0
  31. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/README.rst.license +0 -0
  32. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/adafruit_circuitpython_ntp.egg-info/dependency_links.txt +0 -0
  33. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/adafruit_circuitpython_ntp.egg-info/requires.txt +0 -0
  34. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/adafruit_circuitpython_ntp.egg-info/top_level.txt +0 -0
  35. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/_static/custom.css +0 -0
  36. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/_static/favicon.ico +0 -0
  37. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/_static/favicon.ico.license +0 -0
  38. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/api.rst +0 -0
  39. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/api.rst.license +0 -0
  40. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/conf.py +0 -0
  41. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/examples.rst +0 -0
  42. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/examples.rst.license +0 -0
  43. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/index.rst +0 -0
  44. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/index.rst.license +0 -0
  45. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/requirements.txt +0 -0
  46. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/examples/ntp_connection_manager.py +0 -0
  47. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/examples/ntp_cpython.py +0 -0
  48. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/examples/ntp_set_rtc.py +0 -0
  49. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/examples/ntp_simpletest.py +0 -0
  50. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/optional_requirements.txt +0 -0
  51. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/requirements.txt +0 -0
  52. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/ruff.toml +0 -0
  53. {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/setup.cfg +0 -0
@@ -0,0 +1,32 @@
1
+ # SPDX-FileCopyrightText: 2026 Ted Timmons
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ name: Run Tests
6
+
7
+ on: [pull_request, push]
8
+
9
+ jobs:
10
+ pytest:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ python-version: ["3.9", "3.13"]
16
+ steps:
17
+ - name: Check out repository
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Set up Python ${{ matrix.python-version }}
21
+ uses: actions/setup-python@v5
22
+ with:
23
+ python-version: ${{ matrix.python-version }}
24
+
25
+ - name: Install pytest
26
+ run: pip install pytest
27
+
28
+ # The suite needs no hardware and no network: it substitutes a `time`
29
+ # shim that enforces the 32-bit machine-word limit on localtime(), and
30
+ # the real-socket tests use loopback UDP against a local fake server.
31
+ - name: Run tests
32
+ run: python -m pytest tests/ -v
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adafruit-circuitpython-ntp
3
- Version: 3.3.7
3
+ Version: 3.4.0
4
4
  Summary: Network Time Protocol (NTP) helper for Python
5
5
  Author-email: Adafruit Industries <circuitpython@adafruit.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adafruit-circuitpython-ntp
3
- Version: 3.3.7
3
+ Version: 3.4.0
4
4
  Summary: Network Time Protocol (NTP) helper for Python
5
5
  Author-email: Adafruit Industries <circuitpython@adafruit.com>
6
6
  License-Expression: MIT
@@ -16,6 +16,7 @@ ruff.toml
16
16
  .github/workflows/failure-help-text.yml
17
17
  .github/workflows/release_gh.yml
18
18
  .github/workflows/release_pypi.yml
19
+ .github/workflows/tests.yml
19
20
  LICENSES/CC-BY-4.0.txt
20
21
  LICENSES/MIT.txt
21
22
  LICENSES/Unlicense.txt
@@ -38,4 +39,12 @@ docs/_static/favicon.ico.license
38
39
  examples/ntp_connection_manager.py
39
40
  examples/ntp_cpython.py
40
41
  examples/ntp_set_rtc.py
41
- examples/ntp_simpletest.py
42
+ examples/ntp_simpletest.py
43
+ tests/README.md
44
+ tests/README.md.license
45
+ tests/conftest.py
46
+ tests/harness.py
47
+ tests/test_ntp_failover.py
48
+ tests/test_poll_interval.py
49
+ tests/test_real_sockets.py
50
+ tests/test_response_validation.py
@@ -0,0 +1,225 @@
1
+ # SPDX-FileCopyrightText: 2022 Scott Shawcroft for Adafruit Industries
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """
6
+ `adafruit_ntp`
7
+ ================================================================================
8
+
9
+ Network Time Protocol (NTP) helper for CircuitPython
10
+
11
+ * Author(s): Scott Shawcroft
12
+
13
+ Implementation Notes
14
+ --------------------
15
+ **Hardware:**
16
+ **Software and Dependencies:**
17
+
18
+ * Adafruit CircuitPython firmware for the supported boards:
19
+ https://github.com/adafruit/circuitpython/releases
20
+
21
+ """
22
+
23
+ import struct
24
+ import time
25
+
26
+ from micropython import const
27
+
28
+ try:
29
+ from typing import Sequence, Union
30
+ except ImportError:
31
+ pass
32
+
33
+ __version__ = "3.4.0"
34
+ __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_NTP.git"
35
+
36
+ NTP_TO_UNIX_EPOCH = 2208988800 # 1970-01-01 00:00:00
37
+ PACKET_SIZE = const(48)
38
+ # RFC 5905 constrains the poll interval to 2**4 through 2**17 seconds.
39
+ NTP_MINPOLL = const(4)
40
+ NTP_MAXPOLL = const(17)
41
+
42
+ _DEFAULT_SERVERS = (
43
+ "0.adafruit.pool.ntp.org",
44
+ "1.adafruit.pool.ntp.org",
45
+ "2.adafruit.pool.ntp.org",
46
+ "3.adafruit.pool.ntp.org",
47
+ )
48
+
49
+
50
+ class NTP:
51
+ """Network Time Protocol (NTP) helper module for CircuitPython.
52
+ This module does not handle daylight savings or local time. It simply requests
53
+ UTC from a NTP server.
54
+
55
+ :param object socketpool: A socket provider such as CPython's `socket` module.
56
+ :param server: One NTP server hostname, or a sequence of them. Each name is resolved to a
57
+ single IP and the client fails over between them: on a failed query it moves to the next
58
+ server and rotates the failed one to the back of the list. Defaults to Adafruit's four
59
+ pool names. For most reliable performance it is recommended to have 3+ pool names.
60
+ :type server: str or Sequence[str]
61
+ :param int port: The port of the ntp server to query.
62
+ :param float tz_offset: Timezone offset in hours from UTC. Only useful for timezone ignorant
63
+ CircuitPython. CPython will determine timezone automatically and adjust (so don't use
64
+ this.) For example, Pacific daylight savings time is -7.
65
+ :param float socket_timeout: UDP socket timeout, in seconds (default 1.0).
66
+ :param int cache_seconds: how many seconds to use a cached result from NTP server
67
+ (default 0, which respects NTP server's minimum).
68
+ :param int reresolve_interval: how often, in seconds, to rebuild the server IP list from DNS,
69
+ for long-running programs whose resolved members may go stale. The NTP Pool asks vendors
70
+ not to re-resolve more than once per hour, so keep this >= 3600 (default 3600).
71
+
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ socketpool,
77
+ *,
78
+ server: Union[str, Sequence[str]] = _DEFAULT_SERVERS,
79
+ port: int = 123,
80
+ tz_offset: float = 0,
81
+ socket_timeout: float = 1.0,
82
+ cache_seconds: int = 0,
83
+ reresolve_interval: int = 3600,
84
+ ) -> None:
85
+ self._pool = socketpool
86
+ self._servers = (server,) if isinstance(server, str) else tuple(server)
87
+ self._port = port
88
+ self._packet = bytearray(PACKET_SIZE)
89
+ self._tz_offset = int(tz_offset * 60 * 60)
90
+ self._socket_timeout = socket_timeout
91
+ self._cache_seconds = cache_seconds
92
+ self._reresolve_interval = reresolve_interval
93
+
94
+ self._addresses = None
95
+ self._last_resolve_ns = 0
96
+
97
+ # This is our estimated start time for the monotonic clock. We adjust it based on the ntp
98
+ # responses.
99
+ self._monotonic_start_ns = 0
100
+
101
+ self.next_sync = 0
102
+
103
+ def _resolve_servers(self) -> None:
104
+ """Rebuild the cached IP list from DNS on first use and every reresolve_interval."""
105
+ now = time.monotonic_ns()
106
+ fresh = self._addresses is not None
107
+ if fresh and (now - self._last_resolve_ns) < self._reresolve_interval * 1_000_000_000:
108
+ return
109
+
110
+ addresses = []
111
+ for name in self._servers:
112
+ try:
113
+ address = self._pool.getaddrinfo(name, self._port)[0][4]
114
+ except OSError:
115
+ continue
116
+ if address not in addresses:
117
+ addresses.append(address)
118
+
119
+ if addresses:
120
+ self._addresses = addresses
121
+ self._last_resolve_ns = now
122
+ elif self._addresses is not None:
123
+ self._last_resolve_ns = now # keep the stale list; rate-limit re-resolve during outages
124
+ else:
125
+ raise OSError("NTP: could not resolve any server")
126
+
127
+ def _query_server(self, address) -> None:
128
+ """Send one request to a single server and update the clock, or raise on failure."""
129
+ self._packet[0] = 0b00100011 # Not leap second, NTP version 4, Client mode
130
+ for i in range(1, PACKET_SIZE):
131
+ self._packet[i] = 0
132
+ with self._pool.socket(self._pool.AF_INET, self._pool.SOCK_DGRAM) as sock:
133
+ sock.settimeout(self._socket_timeout)
134
+ local_send_ns = time.monotonic_ns()
135
+ sock.sendto(self._packet, address)
136
+ received = sock.recv_into(self._packet)
137
+ local_recv_ns = time.monotonic_ns()
138
+
139
+ # A short read leaves timestamp fields reading as our own pre-send zeros.
140
+ if received is None or received < PACKET_SIZE:
141
+ raise ArithmeticError(f"NTP response was {received} bytes, expected {PACKET_SIZE}")
142
+
143
+ # Clamp poll to the RFC 5905 range so a corrupt byte can't derail the next sync.
144
+ poll = min(
145
+ max(struct.unpack_from("!B", self._packet, offset=2)[0], NTP_MINPOLL), NTP_MAXPOLL
146
+ )
147
+
148
+ srv_recv_s, srv_recv_f = struct.unpack_from("!II", self._packet, offset=32)
149
+ srv_send_s, srv_send_f = struct.unpack_from("!II", self._packet, offset=40)
150
+
151
+ # A pre-epoch timestamp is a zeroed or truncated field, not a real time.
152
+ if srv_recv_s < NTP_TO_UNIX_EPOCH or srv_send_s < NTP_TO_UNIX_EPOCH:
153
+ raise ArithmeticError("NTP response has an invalid timestamp")
154
+
155
+ # Convert the server times from NTP to UTC for local use
156
+ srv_recv_ns = (srv_recv_s - NTP_TO_UNIX_EPOCH) * 1_000_000_000 + (
157
+ srv_recv_f * 1_000_000_000 // 2**32
158
+ )
159
+ srv_send_ns = (srv_send_s - NTP_TO_UNIX_EPOCH) * 1_000_000_000 + (
160
+ srv_send_f * 1_000_000_000 // 2**32
161
+ )
162
+
163
+ # Best estimate of the offset between server UTC and board monotonic_ns time.
164
+ clock_offset = ((srv_recv_ns - local_send_ns) + (srv_send_ns - local_recv_ns)) // 2
165
+ cache_offset_s = max(2**poll, self._cache_seconds)
166
+
167
+ # Assign session state only after the response has fully validated.
168
+ self.next_sync = local_recv_ns + cache_offset_s * 1_000_000_000
169
+ self._monotonic_start_ns = clock_offset + self._tz_offset * 1_000_000_000
170
+
171
+ def _update_time_sync(self) -> None:
172
+ """Query servers with failover. Raises OSError if none respond within socket_timeout
173
+ seconds, ArithmeticError for substantially incorrect NTP results."""
174
+ self._resolve_servers()
175
+
176
+ # Try each server once, rotating a failure to the back; retry once if there is only one.
177
+ attempts = 2 if len(self._addresses) == 1 else 1
178
+ timeout_exc = None
179
+ response_exc = None
180
+ for _server in range(len(self._addresses)):
181
+ address = self._addresses[0]
182
+ for _attempt in range(attempts):
183
+ try:
184
+ self._query_server(address)
185
+ return
186
+ except OSError as exc:
187
+ timeout_exc = exc
188
+ except ArithmeticError as exc:
189
+ response_exc = exc
190
+ break
191
+ self._addresses.append(self._addresses.pop(0))
192
+
193
+ # Prefer the timeout (ETIMEDOUT) so an all-servers-down result stays backward compatible.
194
+ raise timeout_exc or response_exc or OSError("NTP: no server responded")
195
+
196
+ @property
197
+ def datetime(self) -> time.struct_time:
198
+ """Current time from NTP server. Accessing this property causes the NTP time request,
199
+ unless there has already been a recent request.
200
+
201
+ :return: The current UTC time.
202
+ :rtype: time.struct_time
203
+ """
204
+ if time.monotonic_ns() > self.next_sync:
205
+ self._update_time_sync()
206
+
207
+ # Calculate the current time based on the current and start monotonic times
208
+ current_time_s = (time.monotonic_ns() + self._monotonic_start_ns) // 1_000_000_000
209
+
210
+ return time.localtime(current_time_s)
211
+
212
+ @property
213
+ def utc_ns(self) -> int:
214
+ """UTC (unix epoch) time in nanoseconds. Accessing this property causes the NTP time
215
+ request, unless there has already been a recent request. Raises OSError exception if
216
+ no response is received within socket_timeout seconds, ArithmeticError for substantially
217
+ incorrect NTP results.
218
+
219
+ :return: UTC time in nanoseconds since the unix epoch.
220
+ :rtype: int
221
+ """
222
+ if time.monotonic_ns() > self.next_sync:
223
+ self._update_time_sync()
224
+
225
+ return time.monotonic_ns() + self._monotonic_start_ns
@@ -12,7 +12,7 @@ requires = [
12
12
  [project]
13
13
  name = "adafruit-circuitpython-ntp"
14
14
  description = "Network Time Protocol (NTP) helper for Python"
15
- version = "3.3.7"
15
+ version = "3.4.0"
16
16
  readme = "README.rst"
17
17
  authors = [
18
18
  {name = "Adafruit Industries", email = "circuitpython@adafruit.com"}
@@ -0,0 +1,63 @@
1
+ # tests
2
+
3
+ Reproduction for [issue #35](https://github.com/adafruit/Adafruit_CircuitPython_NTP/issues/35)
4
+ (`OverflowError: overflow converting long int to machine word`). Upstream ships
5
+ no test suite, so this directory is self-contained.
6
+
7
+ ```bash
8
+ python3 -m venv .venv && .venv/bin/pip install pytest
9
+ .venv/bin/python -m pytest tests/ -v
10
+ ```
11
+
12
+ ## What's here
13
+
14
+ | File | What |
15
+ |---|---|
16
+ | `test_response_validation.py` | The bug: short, zeroed, and truncated responses, driven through a fake socketpool |
17
+ | `test_poll_interval.py` | The server's poll byte clamped to the RFC 5905 range before it sets the resync interval |
18
+ | `test_real_sockets.py` | The same mechanisms shown with CPython's real UDP sockets |
19
+ | `harness.py` | Fake socketpool + a `time` shim enforcing the 32-bit machine-word limit on `localtime()`, as a real board does |
20
+ | `conftest.py` | `micropython.const` shim, and puts the repo root on `sys.path` so `import adafruit_ntp` works |
21
+
22
+ Everything runs against `../adafruit_ntp.py` in the working tree. No hardware
23
+ and no network are needed.
24
+
25
+ ## Why the `time` shim matters
26
+
27
+ The failure only exists on a 32-bit board. `time.localtime()` there converts its
28
+ argument to a **machine word**, and CPython's does not — so the bug that crashes
29
+ a QT Py ESP32-S2 passes silently under plain CPython. `harness.board_time()`
30
+ substitutes a `time` whose `localtime()` enforces the int32 limit and whose
31
+ monotonic clock is controllable. That makes a board-only crash deterministic on
32
+ a laptop.
33
+
34
+ ## Expected result
35
+
36
+ ```
37
+ 30 passed
38
+ ```
39
+
40
+ ## What the tests establish
41
+
42
+ The library reuses one `bytearray` as both request and response buffer, zeroes
43
+ it before sending, and discards `recv_into`'s return value — so it reads back
44
+ its own zeros whenever a datagram fails to overwrite offsets 40-43. What that
45
+ produces depends on where the datagram truncates:
46
+
47
+ | bytes received | before the fix | now |
48
+ |---|---|---|
49
+ | 0-32 | `OverflowError` | `ArithmeticError` |
50
+ | 33-40 | silently returned a time decades off (1962) | `ArithmeticError` |
51
+ | 41-43 | silently wrong by weeks | `ArithmeticError` |
52
+ | 44-48 | correct | correct |
53
+
54
+ The 1962 row was the quieter half: `datetime` averages the server's receive and
55
+ transmit timestamps, so a valid receive plus a zeroed transmit landed halfway
56
+ between 1900 and now — inside int32, so nothing raised at all. A guard that only
57
+ checked for a zero timestamp would not have caught the 41-43 row either; that
58
+ one needs the length check.
59
+
60
+ `test_real_sockets.py` confirms the mechanism outside the fake: buffer reuse and
61
+ truncation over loopback UDP, an over-48-byte response being harmless, and —
62
+ because the socket is never `connect()`ed — acceptance of a datagram from a
63
+ source that is not the server.
@@ -0,0 +1,3 @@
1
+ SPDX-FileCopyrightText: 2026 Ted Timmons
2
+
3
+ SPDX-License-Identifier: MIT
@@ -0,0 +1,22 @@
1
+ # SPDX-FileCopyrightText: 2026 Ted Timmons
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Make the CircuitPython library importable under CPython.
6
+
7
+ Two things are needed: a stand-in for the `micropython` module, which CPython
8
+ does not have, and the repo root on sys.path so `import adafruit_ntp` finds the
9
+ library next to this directory.
10
+ """
11
+
12
+ import os
13
+ import sys
14
+ import types
15
+
16
+ # adafruit_ntp does `from micropython import const`.
17
+ if "micropython" not in sys.modules:
18
+ _mp = types.ModuleType("micropython")
19
+ _mp.const = lambda x: x
20
+ sys.modules["micropython"] = _mp
21
+
22
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -0,0 +1,128 @@
1
+ # SPDX-FileCopyrightText: 2026 Ted Timmons
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Test harness reproducing adafruit/Adafruit_CircuitPython_NTP issue #35.
6
+
7
+ Issue #35: `OverflowError: overflow converting long int to machine word` raised
8
+ from `NTP.datetime`. Root cause reported by the filer: the NTP response packet
9
+ came back with a transmit-timestamp seconds field of 0, so the computed unix
10
+ timestamp became roughly -2208988800 (i.e. NTP epoch minus unix epoch). On a
11
+ 32-bit CircuitPython board `time.localtime()` takes a *machine word*, and
12
+ -2208988800 does not fit in int32, hence the OverflowError.
13
+
14
+ To reproduce deterministically under CPython we substitute the module's `time`
15
+ with a fake that (a) has a controllable monotonic clock and (b) enforces the
16
+ int32 machine-word limit on `localtime()` exactly like a 32-bit MCU does.
17
+ """
18
+
19
+ import contextlib
20
+ import struct
21
+ import time as _real_time
22
+
23
+ INT32_MIN = -(2**31)
24
+ INT32_MAX = 2**31 - 1
25
+
26
+ NTP_TO_UNIX_EPOCH = 2208988800
27
+ PACKET_SIZE = 48
28
+
29
+
30
+ class FakeTime:
31
+ """Stand-in for CircuitPython's `time` on a 32-bit board.
32
+
33
+ `localtime()` on such a board converts its argument to a machine word, so
34
+ anything outside int32 raises OverflowError with this exact message.
35
+ """
36
+
37
+ def __init__(self, monotonic_s=90_000):
38
+ self.monotonic_s = monotonic_s
39
+ self.localtime_calls = []
40
+
41
+ def monotonic_ns(self):
42
+ return int(self.monotonic_s * 1_000_000_000)
43
+
44
+ def localtime(self, secs):
45
+ self.localtime_calls.append(secs)
46
+ if not INT32_MIN <= secs <= INT32_MAX:
47
+ raise OverflowError("overflow converting long int to machine word")
48
+ return _real_time.gmtime(secs)
49
+
50
+ # struct_time type is referenced only in annotations
51
+ struct_time = _real_time.struct_time
52
+
53
+
54
+ def make_packet(*, transmit_s, recv_s=None, poll=6, frac=0):
55
+ """Build a 48-byte NTP server response."""
56
+ if recv_s is None:
57
+ recv_s = transmit_s
58
+ pkt = bytearray(PACKET_SIZE)
59
+ pkt[0] = 0b00100100 # LI=0, VN=4, mode=4 (server)
60
+ pkt[1] = 2 # stratum
61
+ pkt[2] = poll
62
+ struct.pack_into("!II", pkt, 32, recv_s, frac) # receive timestamp
63
+ struct.pack_into("!II", pkt, 40, transmit_s, frac) # transmit timestamp
64
+ return pkt
65
+
66
+
67
+ class FakeSocket:
68
+ def __init__(self, response):
69
+ self.response = response
70
+ self.sent = []
71
+
72
+ def settimeout(self, t):
73
+ pass
74
+
75
+ def sendto(self, packet, addr):
76
+ self.sent.append((bytes(packet), addr))
77
+
78
+ def recv_into(self, buf):
79
+ if self.response is None:
80
+ # Simulates a recv that writes nothing: the caller's buffer keeps
81
+ # whatever it held (adafruit_ntp zeroes it before sending).
82
+ return 0
83
+ n = min(len(buf), len(self.response))
84
+ buf[:n] = self.response[:n]
85
+ return n
86
+
87
+ def __enter__(self):
88
+ return self
89
+
90
+ def __exit__(self, *exc):
91
+ return False
92
+
93
+
94
+ class FakePool:
95
+ """Minimal socketpool stand-in."""
96
+
97
+ AF_INET = 2
98
+ SOCK_DGRAM = 2
99
+
100
+ def __init__(self, responses):
101
+ # responses: list of packets (or None) returned by successive requests
102
+ self.responses = list(responses)
103
+ self.request_count = 0
104
+
105
+ def getaddrinfo(self, host, port):
106
+ return [(2, 2, 0, "", ("10.0.0.1", port))]
107
+
108
+ def socket(self, family, type_):
109
+ idx = min(self.request_count, len(self.responses) - 1)
110
+ self.request_count += 1
111
+ return FakeSocket(self.responses[idx])
112
+
113
+
114
+ @contextlib.contextmanager
115
+ def board_time(module, monotonic_s=90_000):
116
+ """Swap the module's `time` for the 32-bit-board fake."""
117
+ fake = FakeTime(monotonic_s)
118
+ original = module.time
119
+ module.time = fake
120
+ try:
121
+ yield fake
122
+ finally:
123
+ module.time = original
124
+
125
+
126
+ def valid_unix_seconds(when=1_720_915_505):
127
+ """A real-world unix timestamp (2024-07-14, when issue #35 was filed)."""
128
+ return when + NTP_TO_UNIX_EPOCH