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.
- adafruit_circuitpython_ntp-3.4.0/.github/workflows/tests.yml +32 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/PKG-INFO +1 -1
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/adafruit_circuitpython_ntp.egg-info/PKG-INFO +1 -1
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/adafruit_circuitpython_ntp.egg-info/SOURCES.txt +10 -1
- adafruit_circuitpython_ntp-3.4.0/adafruit_ntp.py +225 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/pyproject.toml +1 -1
- adafruit_circuitpython_ntp-3.4.0/tests/README.md +63 -0
- adafruit_circuitpython_ntp-3.4.0/tests/README.md.license +3 -0
- adafruit_circuitpython_ntp-3.4.0/tests/conftest.py +22 -0
- adafruit_circuitpython_ntp-3.4.0/tests/harness.py +128 -0
- adafruit_circuitpython_ntp-3.4.0/tests/test_ntp_failover.py +218 -0
- adafruit_circuitpython_ntp-3.4.0/tests/test_poll_interval.py +98 -0
- adafruit_circuitpython_ntp-3.4.0/tests/test_real_sockets.py +158 -0
- adafruit_circuitpython_ntp-3.4.0/tests/test_response_validation.py +156 -0
- adafruit_circuitpython_ntp-3.3.7/adafruit_ntp.py +0 -139
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/.gitattributes +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/.github/PULL_REQUEST_TEMPLATE/adafruit_circuitpython_pr.md +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/.github/workflows/build.yml +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/.github/workflows/failure-help-text.yml +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/.github/workflows/release_gh.yml +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/.github/workflows/release_pypi.yml +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/.gitignore +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/.pre-commit-config.yaml +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/.readthedocs.yaml +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/CODE_OF_CONDUCT.md +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/LICENSE +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/LICENSES/CC-BY-4.0.txt +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/LICENSES/MIT.txt +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/LICENSES/Unlicense.txt +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/README.rst +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/README.rst.license +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/adafruit_circuitpython_ntp.egg-info/dependency_links.txt +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/adafruit_circuitpython_ntp.egg-info/requires.txt +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/adafruit_circuitpython_ntp.egg-info/top_level.txt +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/_static/custom.css +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/_static/favicon.ico +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/_static/favicon.ico.license +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/api.rst +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/api.rst.license +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/conf.py +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/examples.rst +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/examples.rst.license +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/index.rst +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/index.rst.license +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/docs/requirements.txt +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/examples/ntp_connection_manager.py +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/examples/ntp_cpython.py +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/examples/ntp_set_rtc.py +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/examples/ntp_simpletest.py +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/optional_requirements.txt +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/requirements.txt +0 -0
- {adafruit_circuitpython_ntp-3.3.7 → adafruit_circuitpython_ntp-3.4.0}/ruff.toml +0 -0
- {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
|
|
@@ -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.
|
|
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,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
|