├── LICENSE.md
├── Makefile
├── README.md
├── docs
├── README.pdf
├── ntp.html
└── ntp.pdf
├── src
└── ntp.py
└── test
└── tests.py
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2020, Emil Kondayan
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions are met:
6 |
7 | * Redistributions of source code must retain the above copyright notice, this
8 | list of conditions and the following disclaimer.
9 |
10 | * Redistributions in binary form must reproduce the above copyright notice,
11 | this list of conditions and the following disclaimer in the documentation
12 | and/or other materials provided with the distribution.
13 |
14 | * Neither the name of [project] nor the names of its
15 | contributors may be used to endorse or promote products derived from
16 | this software without specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | dir_src = src
2 | dir_docs = docs
3 | src_files = $(shell find $(dir_src) -type f)
4 | app_pdoc = pdoc3
5 | chromium_browser = brave-browser
6 |
7 | all: html pdf
8 |
9 | html: $(src_files)
10 | PYTHONDONTWRITEBYTECODE=1 $(app_pdoc) --html -o $(dir_docs) $(src_files) --force
11 |
12 | pdf:
13 | $(chromium_browser) --headless --disable-gpu --print-to-pdf=$(dir_docs)/ntp.pdf $(dir_docs)/ntp.html
14 |
15 | clean:
16 | rm -rf $(dir_docs)
17 |
18 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # micropython-ntp
2 |
3 | ---
4 |
5 | # Description
6 |
7 | A robust MicroPython **Time library** for manipulating the **RTC** and and syncing it from a list of **NTP** servers.
8 |
9 | Features:
10 |
11 | 1. Sync the RTC from a NTP host
12 |
13 | 2. Multiple NTP hosts
14 |
15 | 3. Microsecond precision
16 |
17 | 4. RTC chip-agnostic
18 |
19 | 5. Calculate and compensate RTC drift
20 |
21 | 6. Timezones
22 |
23 | 7. Epochs
24 |
25 | 8. Day Light Saving Time
26 |
27 | 9. Get time in sec, ms and us
28 |
29 | 10. Custom Logger with callback function
30 |
31 | ***NOTE: This library has comprehensive unit tests and is actively maintained. However, as with any software, please test thoroughly in your specific environment before production use.***
32 |
33 | **Quick Guide**
34 |
35 | Before using the library there are a few thing that need to be done.
36 |
37 | The first and the most important one is setting a callback for manipulating the RTC. The second and of the same importance is setting a list of NTP hosts/IPs.
38 |
39 | Next things to configure are the Timezone and Daylight Saving Time but they are not mandatory if do not need them.
40 |
41 | Other things that can be configured are:
42 |
43 | - Network timeout
44 |
45 | - Default epoch - if you need to get the time in other epoch other than the default for the device. Setting a default epoch allows you the convenience of not passing the epoch parameter each time you want to read the time. Each micropython port is compiled with a default epoch. For most of the ports but not all, it is 2000. For example the Unix port uses an epoch of 1970
46 |
47 | - The drift value of the RTC if it is know in advance.
48 |
49 | - The library logging output can be directed trough a callback. If not needed it can be disabled entirely
50 |
51 | **RTC access callback**
52 |
53 | The first thing to do when using the library is to set a callback function for accessing the RTC chip. The idea behind this strategy is that the library can manipulate multiple RTC chips(internal, external or combination of both) and is chip agnostic. Providing this function is your responsibility. It's declaration is:
54 |
55 | ```python
56 | def func(datetime: tuple = None) -> tuple
57 | ```
58 |
59 | With no arguments, this method acts as a getter and returns an 8-tuple with the current date and time. With 1 argument (being an 8-tuple) it sets the date and time. The 8-tuple has the following format:
60 |
61 | ```python
62 | (year, month, day, weekday, hour, minute, second, subsecond)
63 |
64 | # year is the year including the century part
65 | # month is in (Ntp.MONTH_JAN ... Ntp.MONTH_DEC)
66 | # day is in (1 ... 31)
67 | # weekday is in (Ntp.WEEKDAY_MON ... Ntp.WEEKDAY_SUN)
68 | # hour is in (0 ... 23)
69 | # minute is in (0 ... 59)
70 | # second is in (0 ... 59)
71 | # subsecond is in (0 ... 999999)
72 | ```
73 |
74 | The `RTC` class in the `machine` module, provides a drop-in alternative for a callback:
75 |
76 | ```python
77 | from machine import RTC
78 | from ntp import Ntp
79 |
80 | _rtc = RTC()
81 | Ntp.set_datetime_callback(_rtc.datetime)
82 | ```
83 |
84 | The `set_datetime_callback` method also accepts an optional `precision` parameter to handle RTCs with different subsecond precisions:
85 |
86 | ```python
87 | # For ESP32 (default microsecond precision)
88 | Ntp.set_datetime_callback(_rtc.datetime)
89 |
90 | # For ESP8266 (millisecond precision)
91 | Ntp.set_datetime_callback(_rtc.datetime, precision=Ntp.SUBSECOND_PRECISION_MS)
92 |
93 | # For DS3231 (second precision only)
94 | Ntp.set_datetime_callback(_rtc.datetime, precision=Ntp.SUBSECOND_PRECISION_SEC)
95 | ```
96 |
97 | Available precision constants:
98 | - `Ntp.SUBSECOND_PRECISION_US` - Microsecond precision (default)
99 | - `Ntp.SUBSECOND_PRECISION_MS` - Millisecond precision
100 | - `Ntp.SUBSECOND_PRECISION_SEC` - Second precision only
101 |
102 | **RTC sync**
103 |
104 | To be able to synchronize the RTC with NTP servers you have to set a list of hosts:
105 |
106 | ```python
107 | Ntp.set_hosts(('0.pool.ntp.org', '1.pool.ntp.org', '2.pool.ntp.org'))
108 | ```
109 |
110 | You can pass a valid hostname or an IP. A basic validation is run when saving each host/ip. If the value is neither a valid hostname or IP address, it is skipped WITHOUT an error being thrown. It is your responsibility to pass the correct values.
111 |
112 | After setting a list of NTP hosts, you can synchronize the RTC:
113 |
114 | ```python
115 | Ntp.rtc_sync()
116 | ```
117 |
118 | This function will loop trough all the hostnames in the list and will try to read the time from each one. The first with a valid response will be used to sync the RTC. The RTC is always synchronized in UTC.
119 |
120 | A network timeout in seconds can be set to prevent hanging
121 |
122 | ```python
123 | Ntp.set_ntp_timeout(timeout_s: int = 1)
124 | ```
125 |
126 | **Reading the time**
127 |
128 | There are two types of functions that read the time:
129 |
130 | - ones that return the time as a timestamp
131 |
132 | - ones that return the time in a date time tuple
133 |
134 | The set of functions that return a timestamp is:
135 |
136 | ```python
137 | Ntp.time_s(epoch: int = None, utc: bool = False) -> int
138 | Ntp.time_ms(epoch: int = None, utc: bool = False) -> int
139 | Ntp.time_us(epoch: int = None, utc: bool = False) -> int
140 | ```
141 |
142 | The suffix of each function shows the timestamp representation:
143 |
144 | - **_s** - seconds
145 |
146 | - **_ms** - milliseconds
147 |
148 | - **_us** - microseconds
149 |
150 | If you want to get the time relative to an epoch, you can pass one of the following constants:
151 |
152 | ```python
153 | Ntp.EPOCH_1900
154 | Ntp.EPOCH_1970
155 | Ntp.EPOCH_2000
156 | ```
157 |
158 | If epoch parameter is `None`, the default epoch will be used. Otherwise the parameter will have a higher precedence.
159 |
160 | If `utc = True` the returned timestamp will be in UTC format which excludes the Daylight Saving Time and the Timezone offsets.
161 |
162 | To get the date and time in tuple format:
163 |
164 | ```python
165 | Ntp.time(utc: bool = False) -> tuple
166 |
167 | # 9-tuple(year, month, day, hour, minute, second, weekday, yearday, us)
168 | # year is the year including the century part
169 | # month is in (Ntp.MONTH_JAN ... Ntp.MONTH_DEC)
170 | # day is in (1 ... 31)
171 | # hour is in (0 ... 23)
172 | # minutes is in (0 ... 59)
173 | # seconds is in (0 ... 59)
174 | # weekday is in (Ntp.WEEKDAY_MON ... Ntp.WEEKDAY_SUN)
175 | # yearday is in (1 ... 366)
176 | # us is in (0 ... 999999)
177 | ```
178 |
179 | !!! Both types of function read the time from the RTC!!!
180 |
181 | To read the time directly from the NTP:
182 |
183 | ```python
184 | Ntp.ntp_time(epoch: int = None) -> tuple
185 | # 2-tuple(ntp_time, timestamp)
186 | # * ntp_time contains the accurate time(UTC) from the NTP server
187 | # in nanoseconds since the selected epoch.
188 | # * timestamp contains timestamp in microseconds taken at the time the
189 | # request to the server was sent. This timestamp can be used later to
190 | # compensate for the time difference between the request was sent
191 | # and the later moment the time is used. The timestamp is the output
192 | # of time.ticks_us()
193 | ```
194 |
195 | Get the accurate time from the first valid NTP server in the list with microsecond precision. When the server does not respond within the timeout period, the next server in the list is used. The default timeout is 1 sec. The timeout can be changed with `set_ntp_timeout()`. When none of the servers respond, throw an Exception. The epoch parameter serves the same purpose as with the other time functions.
196 |
197 | **Epochs**
198 |
199 | In micropython every port has it's own epoch configured during the compilation. Most of the ports use the epoch of `2000-01-01 00:00:00 UTC`, but some like the Unix port use a different. All of the micropython's build in time functions work according to this epoch. In this library I refer to this compiled in epoch as a **device's epoch**. It is not possible to change it during run-time.
200 |
201 | Why is this important? There are multiple occasions where you need the time in different epochs. One example is that NTP uses an epoch of 1900. Another example is if you want to store a timestamp in a database. Some of the databases use an epoch of 1970, others use an epoch of 1900. The list can go on and on.
202 |
203 | All time functions that return a timestamp supports an `epoch` parameter as described in above section.
204 |
205 | Passing an epoch parameter every time is cumbersome, that is why there is a convenience functions that allows you to set a default epoch that all time functions will use.
206 |
207 | ```python
208 | # Set a default epoch
209 | Ntp.set_epoch(epoch: int = None):
210 | ```
211 |
212 | If `None` - the device's epoch will be used. Setting the epoch is not mandatory, the device's epoch will be used as default.
213 |
214 | To get epoch of the device:
215 |
216 | ```python
217 | Ntp.device_epoch()
218 | ```
219 |
220 | The `'time()` function does not have an epoch parameter, because it returns a structured tuple.
221 |
222 | A helper function that is available for calculating the delta between two epochs:
223 |
224 | ```python
225 | epoch_delta(from_epoch: int, to_epoch: int) -> int
226 | ```
227 |
228 | If you want to convert a timestamp from an earlier epoch to a latter, you will have to subtract the seconds between the two epochs. If you want to convert a timestamp from a latter epoch to an earlier, you will have to add the seconds between the two epochs. The function takes that into account and returns a positive or negative value.
229 |
230 | **RTC drift**
231 |
232 | All RTC are prone to drifting over time. This is due to manufacturing tolerances of the crystal oscillator, PCB, passive components, system aging, temperature excursions, etc. Every chip manufacturer states in the datasheet the clock accuracy of their chip. The unit of measure is **ppm**(parts per million). By knowing the frequency and ppm of the crystal, you can calculate how much the RTC will deviate from the real time. For example if you have a 40MHz clock which is stated +-10ppm.
233 |
234 | ```python
235 | # 1 part is equal 1 tick
236 |
237 | frequency = 40_000_000
238 | ppm = 10 # 10 ticks for every 1_000_000 ticks
239 | ticks_drift_per_sec = (frequency / 1_000_000) * ppm = 400
240 |
241 | # The duration of one tick in seconds
242 | tick_time = 1 / frequency = 0.000000025
243 |
244 | # Calculate how many seconds will be the drift
245 | # of the RTC every second
246 | drift_every_sec = tick_time * ticks_drift_per_sec = 0.000_01
247 | ```
248 |
249 | From the calculation above we know that the RTC can drift +-10us every second. If we know the exact drift, we can calculate the exact deviation from the real time. Unfortunately the exact ppm of every oscillator in unknown and has to be determined per chip manually.
250 |
251 | To calculate the drift, the library uses a simpler approach. Every time the RTC is synchronized from NTP, the response is stored in a class variable. When you want to calculate the drift by calling `Ntp.drift_calculate()`, the function reads the current time from NTP and compares it with the stored from the last RTC sync. By knowing the RTC microsecond ticks and the real delta between the NTP queries, calculating the ppm is a trivial task.
252 |
253 | The longer the period between `Ntp.rtc_sync()` and `Ntp.drift_calculate()` the more accurate result you will get. **The recommended minimum interval depends on your RTC precision level**:
254 |
255 | | RTC Precision Level | Constant | Minimum Interval | Recommended Interval |
256 | |---------------------|--------------------------------|------------------|----------------------|
257 | | Microsecond | `SUBSECOND_PRECISION_US` (1) | 20 minutes | 2+ hours |
258 | | Millisecond | `SUBSECOND_PRECISION_MS` (1000)| 1 hour | 12+ hours |
259 | | Second | `SUBSECOND_PRECISION_SEC` (1M) | 24 hours | Several days |
260 |
261 | Using shorter intervals than recommended may result in highly inaccurate drift calculations, particularly with lower-precision RTCs. **Important:** With second-precision RTCs, short measurement periods will produce meaningless drift values because the measurement error (up to ±0.5 seconds) may far exceed the actual drift. For such RTCs, it's often better to manually set the drift value based on datasheet specifications or long-term observations.
262 |
263 | To calculate the drift:
264 |
265 | ```python
266 | Ntp.drift_calculate(new_time = None) -> tuple
267 | ```
268 |
269 | Returns a 2-tuple `(ppm, us)` where:
270 | - `ppm` is a float representing the calculated drift in ppm units
271 | - `us` is an integer containing the absolute drift in microseconds
272 | Both values can be positive or negative. Positive values represent an RTC that is speeding, while negative values represent an RTC that is lagging.
273 |
274 | To get the current drift of the RTC in microseconds:
275 |
276 | ```python
277 | Ntp.drift_us(ppm_drift: float = None)
278 | ```
279 |
280 | This function does not read the time from the NTP server(no internet connection is required), instead it uses the previously calculated ppm.
281 |
282 | To manually set the drift:
283 |
284 | ```python
285 | Ntp.set_drift_ppm(ppm: float)
286 | ```
287 |
288 | The `ppm` parameter can be positive or negative. Positive values represent a RTC that is speeding, negative values represent RTC that is lagging. This is useful if you have in advance the ppm of the current chip, for example if you have previously calculated and stored the ppm.
289 |
290 | The function `Ntp.rtc_sync()` is a pretty costly operation since it requires a network access. For an embedded IoT device this is unfeasible. Instead, you can compensate for the drift at regular and much shorter intervals by:
291 |
292 | ```python
293 | Ntp.drift_compensate(Ntp.drift_us())
294 | ```
295 |
296 | A NTP sync can be performed at much longer intervals, like a day or week, depending on your device stability. If your device uses a TXCO(Temperature Compensated Crystal Oscillator), the period between NTP syncs can be much longer.
297 |
298 | Here is a list of all the functions that are managing the drift:
299 |
300 | ```python
301 | Ntp.drift_calculate(new_time = None) -> tuple
302 | Ntp.drift_last_compensate(epoch: int = None, utc: bool = False) -> int
303 | Ntp.drift_last_calculate(epoch: int = None, utc: bool = False) -> int
304 | Ntp.drift_ppm() -> float
305 | Ntp.set_drift_ppm(ppm: float)
306 | Ntp.drift_us(ppm_drift: float = None) -> int
307 | Ntp.drift_compensate(compensate_us: int)
308 | ```
309 |
310 | **Timezones**
311 |
312 | The library has support for timezones. When setting the timezone ensures basic validity check.
313 |
314 | ```python
315 | Ntp.set_timezone(hour: int, minute: int = 0)
316 | ```
317 |
318 | **!!! NOTE: When syncing or drift compensating the RTC, the time will be set in UTC**
319 |
320 | Functions that support the `utc` argument can be instructed to return the time with the Timezone and DST calculated or the UTC time:
321 |
322 | ```python
323 | Ntp.time(utc: bool = False) -> tuple
324 | Ntp.time_s(epoch: int = None, utc: bool = False) -> int
325 | Ntp.time_ms(epoch: int = None, utc: bool = False) -> int
326 | Ntp.time_us(epoch: int = None, utc: bool = False) -> int
327 |
328 | Ntp.rtc_last_sync(epoch: int = None, utc: bool = False) -> int
329 | Ntp.drift_last_compensate(epoch: int = None, utc: bool = False) -> int
330 | Ntp.drift_last_calculate(epoch: int = None, utc: bool = False) -> int
331 | ```
332 |
333 | **Daylight Saving Time**
334 |
335 | The library supports calculating the time according to the Daylight Saving Time. To start using the DST functionality you have to set three things first:
336 |
337 | - DST start date and time
338 |
339 | - DST end date and time
340 |
341 | - DST bias
342 |
343 | These parameters can be set with just one function `set_dst(start: tuple, end: tuple, bias: int)` for convenience or you can set each parameter separately with a dedicated function. Example:
344 |
345 | ```python
346 | # Set DST data in one pass
347 | # start (tuple): 4-tuple(month, week, weekday, hour) start of DST
348 | # end (tuple) :4-tuple(month, week, weekday, hour) end of DST
349 | # bias (int): Daylight Saving Time bias expressed in minutes
350 | Ntp.set_dst(start: tuple = None, end: tuple = None, bias: int = 0)
351 |
352 | # Set the start date and time of the DST
353 | # month (int): number in range 1(Jan) - 12(Dec)
354 | # week (int): integer in range 1 - 6. Sometimes there are months when they can spread over a 6 weeks ex. 05.2021
355 | # weekday (int): integer in range 0(Mon) - 6(Sun)
356 | # hour (int): integer in range 0 - 23
357 | Ntp.set_dst_start(month: int, week: int, weekday: int, hour: int)
358 |
359 | # Set the end date and time of the DST
360 | # month (int): number in range 1(Jan) - 12(Dec)
361 | # week (int): number in range 1 - 6. Sometimes there are months when they can spread over 6 weeks.
362 | # weekday (int): number in range 0(Mon) - 6(Sun)
363 | # hour (int): number in range 0 - 23
364 | Ntp.set_dst_end(month: int, week: int, weekday: int, hour: int)
365 |
366 | # Set Daylight Saving Time bias expressed in minutes.
367 | # bias (int): minutes of the DST bias. Correct values are 0, 30, 60, 90 and 120
368 | # Setting to 0 effectively disables DST
369 | Ntp.set_dst_bias(bias: int)
370 | ```
371 |
372 | You can disable DST functionality by setting any of the start or end date time to `None`
373 |
374 | ```python
375 | # Default values are `None` which disables the DST
376 | Ntp.set_dst()
377 | ```
378 |
379 | To calculate if DST is currently in effect:
380 |
381 | ```python
382 | Ntp.dst() -> int
383 | ```
384 |
385 | Returns the bias in seconds. A value of `0` means no DST is in effect or it is disabled.
386 |
387 | To get a boolean value:
388 |
389 | ```python
390 | bool(Ntp.dst())
391 | ```
392 |
393 | **Logger**
394 |
395 | The library support setting a custom logger. If you want to redirect the error messages to another destination, set your logger
396 |
397 | ```python
398 | Ntp.set_logger_callback(callback = print)
399 | ```
400 |
401 | The default logger is `print()` and to set it just call the method without any parameters. To disable logging, set the callback to `None`
402 |
403 | # Example
404 |
405 | ```python
406 | from machine import RTC
407 | from ntp import Ntp
408 | import time
409 |
410 | def ntp_log_callback(msg: str):
411 | print(msg)
412 |
413 | _rtc = RTC()
414 |
415 | # Initializing
416 | Ntp.set_datetime_callback(_rtc.datetime)
417 | Ntp.set_logger_callback(ntp_log_callback)
418 |
419 | # Set a list of valid hostnames/IPs
420 | Ntp.set_hosts(('0.pool.ntp.org', '1.pool.ntp.org', '2.pool.ntp.org'))
421 | # Network timeout set to 1 second
422 | Ntp.set_ntp_timeout(1)
423 | # Set timezone to 2 hours and 0 minutes
424 | Ntp.set_timezone(2, 0)
425 | # If you know the RTC drift in advance, set it manually to -4.6ppm
426 | Ntp.set_drift_ppm(-4.6)
427 | # Set epoch to 1970. All time calculations will be according to this epoch
428 | Ntp.set_epoch(Ntp.EPOCH_1970)
429 | # Set the DST start and end date time and the bias in one go
430 | Ntp.set_dst((Ntp.MONTH_MAR, Ntp.WEEK_LAST, Ntp.WEEKDAY_SUN, 3),
431 | (Ntp.MONTH_OCT, Ntp.WEEK_LAST, Ntp.WEEKDAY_SUN, 4),
432 | 60)
433 |
434 |
435 | # Syncing the RTC with the time from the NTP servers
436 | Ntp.rtc_sync()
437 |
438 | # Wait appropriate time based on RTC precision
439 | # For microsecond precision: at least 20 minutes
440 | # For millisecond precision: at least 1 hour
441 | # For second precision: at least 24 hours
442 | time.sleep(7200) # 2 hours - reasonable for microsecond precision
443 |
444 | # Calculate the RTC drift
445 | drift_ppm, drift_us = Ntp.drift_calculate()
446 | print(f"Calculated drift: {drift_ppm} PPM, {drift_us} microseconds")
447 |
448 | # Wait some time
449 | time.sleep(3600) # 1 hour
450 |
451 | # Compensate the RTC drift
452 | Ntp.drift_compensate(Ntp.drift_us())
453 |
454 | # Get the last timestamp the RTC was synchronized
455 | Ntp.rtc_last_sync()
456 |
457 | # Get the last timestamp the RTC was compensated
458 | Ntp.drift_last_compensate()
459 |
460 | # Get the last timestamp the RTC drift was calculated
461 | Ntp.drift_last_calculate()
462 |
463 | # Get the calculated drift in ppm
464 | Ntp.drift_ppm()
465 |
466 | # Get the calculated drift in us
467 | Ntp.drift_us()
468 | ```
469 |
470 | # Dependencies
471 |
472 | * Module sockets
473 |
474 | * Module struct
475 |
476 | * Module time
477 |
478 | * Module re
479 |
480 | # Contributions
481 |
482 | If you want to help me improve this library, you can open Pull Requests. Only very well-documented PRs will be accepted. Please use clear and meaningful commit messages to explain what you've done.
483 |
484 | A strong emphasis is placed on documentation. Ensure that all methods and classes have clear and concise docstrings. In addition to docstrings, it's desirable to include comments within your code to provide context and explain the logic, especially in complex sections. If you introduce a new feature, consider updating or creating a corresponding documentation section.
485 |
486 | Before submitting your PR, ensure that you've tested your changes thoroughly. If possible, add unit tests for any new functionality you've added. This will not only improve the reliability of the project but will also increase the chances of your PR being accepted.
487 |
488 | Thank you for your interest in contributing! Every effort, big or small, is highly valued.
489 |
490 | # Download
491 |
492 | You can download the project from GitHub:
493 |
494 | ```bash
495 | git clone https://github.com/ekondayan/micropython-ntp.git micropython-ntp
496 | ```
497 |
498 | # License
499 |
500 | This Source Code Form is subject to the BSD 3-Clause license. You can find it under the LICENSE.md file in the projects' directory or here: [The 3-Clause BSD License](https://opensource.org/licenses/BSD-3-Clause)
501 |
--------------------------------------------------------------------------------
/docs/README.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ekondayan/micropython-ntp/1bca385ef8c24f1c3da0fdbdca8db3474575c92d/docs/README.pdf
--------------------------------------------------------------------------------
/docs/ntp.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ekondayan/micropython-ntp/1bca385ef8c24f1c3da0fdbdca8db3474575c92d/docs/ntp.pdf
--------------------------------------------------------------------------------
/src/ntp.py:
--------------------------------------------------------------------------------
1 | import socket
2 | import struct
3 | import time
4 | import re
5 |
6 | try:
7 | from micropython import const
8 | except ImportError:
9 | def const(v):
10 | return v
11 |
12 | _EPOCH_DELTA_1900_1970 = const(2208988800) # Seconds between 1900 and 1970
13 | _EPOCH_DELTA_1900_2000 = const(3155673600) # Seconds between 1900 and 2000
14 | _EPOCH_DELTA_1970_2000 = const(946684800) # Seconds between 1970 and 2000 = _EPOCH_DELTA_1900_2000 - _EPOCH_DELTA_1900_1970
15 |
16 |
17 | class Ntp:
18 | EPOCH_1900 = const(0)
19 | EPOCH_1970 = const(1)
20 | EPOCH_2000 = const(2)
21 |
22 | MONTH_JAN = const(1)
23 | MONTH_FEB = const(2)
24 | MONTH_MAR = const(3)
25 | MONTH_APR = const(4)
26 | MONTH_MAY = const(5)
27 | MONTH_JUN = const(6)
28 | MONTH_JUL = const(7)
29 | MONTH_AUG = const(8)
30 | MONTH_SEP = const(9)
31 | MONTH_OCT = const(10)
32 | MONTH_NOV = const(11)
33 | MONTH_DEC = const(12)
34 |
35 | WEEK_FIRST = const(1)
36 | WEEK_SECOND = const(2)
37 | WEEK_THIRD = const(3)
38 | WEEK_FOURTH = const(4)
39 | WEEK_FIFTH = const(5)
40 | WEEK_LAST = const(6)
41 |
42 | WEEKDAY_MON = const(0)
43 | WEEKDAY_TUE = const(1)
44 | WEEKDAY_WED = const(2)
45 | WEEKDAY_THU = const(3)
46 | WEEKDAY_FRI = const(4)
47 | WEEKDAY_SAT = const(5)
48 | WEEKDAY_SUN = const(6)
49 |
50 | SUBSECOND_PRECISION_SEC = const(1000_000)
51 | SUBSECOND_PRECISION_MS = const(1000)
52 | SUBSECOND_PRECISION_US = const(1)
53 |
54 | _log_callback = print # Callback for message output
55 | _datetime_callback = None # Callback for reading/writing the RTC
56 | _datetime_callback_precision = SUBSECOND_PRECISION_US # Callback precision
57 | _hosts: list = [] # Array of hostnames or IPs
58 | _timezone: int = 0 # Timezone offset in seconds
59 | _rtc_last_sync: int = 0 # Last RTC synchronization timestamp. Uses device's epoch
60 | _drift_last_compensate: int = 0 # Last RTC drift compensation timestamp. Uses device's epoch
61 | _drift_last_calculate: int = 0 # Last RTC drift calculation timestamp. Uses device's epoch
62 | _ppm_drift: float = 0.0 # RTC drift
63 | _ntp_timeout_s: int = 1 # Network timeout when communicating with NTP servers
64 | _epoch = EPOCH_2000 # User selected epoch
65 | _device_epoch = None # The device's epoch
66 |
67 | _dst_start: (tuple, None) = None # (month, week, day of week, hour)
68 | _dst_end: (tuple, None) = None # (month, week, day of week, hour)
69 | _dst_bias: int = 0 # Time bias in seconds
70 |
71 | _dst_cache_switch_hours_start = None # Cache the switch hour calculation
72 | _dst_cache_switch_hours_end = None # Cache the switch hour calculation
73 | _dst_cache_switch_hours_timestamp = None # Cache the year, the last switch time calculation was made
74 |
75 | # ========================================
76 | # Preallocate ram to prevent fragmentation
77 | # ========================================
78 | __weekdays = (5, 6, 0, 1, 2, 3, 4)
79 | __days = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
80 | __ntp_msg = bytearray(48)
81 | # Lookup Table for fast access. Row = from_epoch Column = to_epoch
82 | __epoch_delta_lut = ((0, -_EPOCH_DELTA_1900_1970, -_EPOCH_DELTA_1900_2000),
83 | (_EPOCH_DELTA_1900_1970, 0, -_EPOCH_DELTA_1970_2000),
84 | (_EPOCH_DELTA_1900_2000, _EPOCH_DELTA_1970_2000, 0))
85 |
86 | @classmethod
87 | def set_datetime_callback(cls, callback, precision = SUBSECOND_PRECISION_US):
88 | """
89 | Assigns a callback function for managing a Real Time Clock (RTC) chip.
90 | This abstraction allows the library to operate independently of the specific RTC chip used,
91 | enabling compatibility with a wide range of RTC hardware, including internal, external,
92 | or multiple RTC chips.
93 |
94 | The micropython-ntp library operates with microsecond precision. This method adapts the callback
95 | to interface correctly with the library by adjusting the subsecond precision. Ports like the ESP8266,
96 | which operate with millisecond precision, can use the 'precision' parameter to set the appropriate
97 | subsecond precision level.
98 |
99 | Args:
100 | callback (function): A callable object designed for RTC communication. It must conform
101 | to the following specifications:
102 | - No arguments (getter mode): Returns the current date and time as
103 | an 8-tuple (year, month, day, weekday, hour, minute, second, subsecond).
104 | - Single argument (setter mode): Accepts an 8-tuple to set the RTC's
105 | date and time. The 8-tuple format is detailed below.
106 | Note: 'weekday' in the 8-tuple is zero-indexed (0 corresponds to Monday).
107 |
108 | precision (int): Specifies the subsecond precision in the 8-tuple. It defines how the
109 | subsecond value is adjusted. The constants are:
110 | - SUBSECOND_PRECISION_SEC Default (second-level precision),
111 | - SUBSECOND_PRECISION_MS (millisecond-level precision),
112 | - SUBSECOND_PRECISION_US (microsecond-level precision, default).
113 |
114 | Raises:
115 | ValueError: If 'callback' is not a callable object.
116 | ValueError: If 'precision' is not one of the predefined constants.
117 | ValueError: If the 'callback' does not adhere to the expected input/output format (e.g., does not
118 | return an 8-tuple in getter mode or accept an 8-tuple in setter mode).
119 |
120 | Note:
121 | Set this callback before any RTC-related functionality is used in the library.
122 | The implementation should be tailored to the specific RTC hardware and communication requirements.
123 |
124 | Callback 8-tuple format:
125 | - year (int): Full year including the century (e.g., 2024).
126 | - month (int): Month of the year, from 1 (January) to 12 (December).
127 | - day (int): Day of the month, from 1 to 31.
128 | - weekday (int): Day of the week, from 0 (Monday) to 6 (Sunday).
129 | - hour (int): Hour of the day, from 0 to 23.
130 | - minute (int): Minute of the hour, from 0 to 59.
131 | - second (int): Second of the minute, from 0 to 59.
132 | - subsecond (int): Subsecond value, adjusted for precision as per 'precision' parameter.
133 |
134 | Examples:
135 | # For ESP32, using default microsecond precision
136 | import machine
137 | set_datetime_callback(machine.RTC().datetime)
138 |
139 | # For ESP8266, using millisecond precision
140 | import machine
141 | set_datetime_callback(machine.RTC().datetime, SUBSECOND_PRECISION_MS)
142 | """
143 |
144 | if not callable(callback):
145 | raise ValueError('Invalid parameter: callback={} must be a callable object'.format(callback))
146 |
147 | if precision not in (cls.SUBSECOND_PRECISION_SEC, cls.SUBSECOND_PRECISION_MS, cls.SUBSECOND_PRECISION_US):
148 | raise ValueError('Invalid parameter: precision={} must be one of SUBSECOND_PRECISION_SEC, SUBSECOND_PRECISION_MS, SUBSECOND_PRECISION_US'.format(precision))
149 |
150 | cls._datetime_callback_precision = precision
151 |
152 | def precision_adjusted_callback(*args):
153 | if len(args) == 0:
154 | # Getter mode
155 | dt = callback()
156 | if isinstance(dt, (tuple, list)) and len(dt) == 8:
157 | return dt[:7] + (dt[7] * precision,)
158 | raise ValueError('Invalid callback: In getter mode must return 8-tuple')
159 | elif len(args) == 1 and isinstance(args[0], (tuple, list)) and len(args[0]) == 8:
160 | # Setter mode
161 | dt = args[0]
162 | return callback(dt[:7] + (dt[7] // precision,))
163 |
164 | raise ValueError(f'Invalid parameters: {args}. In setter mode expects 8-tuple')
165 |
166 | cls._datetime_callback = precision_adjusted_callback
167 |
168 | @classmethod
169 | def set_logger_callback(cls, callback = print):
170 | """
171 | Configures a custom logging callback for the class. This method allows setting a specific
172 | function to handle log messages, replacing the default `print()` function. It can also
173 | disable logging by setting the callback to `None`.
174 |
175 | The logger callback function should accept a single string argument, which is the log message.
176 | By default, the logger uses Python's built-in `print()` function, which can be overridden by
177 | any custom function that takes a string argument. To disable logging, pass `None` as the callback.
178 |
179 | Args:
180 | callback (function, optional): A callable object to handle logging. It should accept a
181 | single string parameter (the log message). The default value
182 | is `print`, which directs log messages to the standard output.
183 | Passing `None` disables logging. Any other non-callable value
184 | raises an exception.
185 |
186 | Raises:
187 | ValueError: If 'callback' is neither a callable object nor `None`.
188 |
189 | Usage:
190 | # Set a custom logger
191 | set_logger_callback(custom_log_function)
192 |
193 | # Disable logging
194 | set_logger_callback(None)
195 |
196 | # Use the default logger (print)
197 | set_logger_callback()
198 | """
199 |
200 | if callback is not None and not callable(callback):
201 | raise ValueError('Invalid parameter: callback={} must be a callable object or None to disable logging'.format(callback))
202 |
203 | cls._log_callback = callback
204 |
205 | @classmethod
206 | def set_epoch(cls, epoch: int = None):
207 | """ Set the default epoch. All functions that return a timestamp value, calculate the result relative to an epoch.
208 | If you do not pass an epoch parameter to those functions, the default epoch will be used.
209 |
210 | !!! NOTE: If you want to use an epoch other than the device's epoch,
211 | it is recommended to set the default epoch before you start using the class.
212 |
213 | Args:
214 | epoch (int, None): If None - the device's epoch will be used.
215 | If int in (Ntp.EPOCH_1900, Ntp.EPOCH_1970, Ntp.EPOCH_2000) - a default epoch according to which the time will be calculated.
216 | """
217 |
218 | if epoch is None:
219 | cls._epoch = cls.device_epoch()
220 | elif isinstance(epoch, int) and cls.EPOCH_1900 <= epoch <= cls.EPOCH_2000:
221 | cls._epoch = epoch
222 | else:
223 | raise ValueError('Invalid parameter: epoch={} must be a one of Ntp.EPOCH_1900, Ntp.EPOCH_1970, Ntp.EPOCH_2000 or None'.format(epoch))
224 |
225 | @classmethod
226 | def get_epoch(cls):
227 | """ Get the default epoch
228 |
229 | Returns:
230 | int: One of (Ntp.EPOCH_1900, Ntp.EPOCH_1970, Ntp.EPOCH_2000)
231 | """
232 | return cls._epoch
233 |
234 | @classmethod
235 | def set_dst(cls, start: tuple = None, end: tuple = None, bias: int = 0):
236 | """ A convenient function that set DST data in one pass. Parameters 'start' and 'end' are
237 | of type 4-tuple(month, week, weekday, hour) where:
238 | * month is in (Ntp.MONTH_JAN ... Ntp.MONTH_DEC)
239 | * week is in (Ntp.WEEK_FIRST ... Ntp.WEEK_LAST)
240 | * weekday is in (Ntp.WEEKDAY_MON ... Ntp.WEEKDAY_SUN)
241 | * hour is in (0 ... 23)
242 | To disable DST set 'start' or 'end' to None or 'bias' to 0. Clearing one of them will
243 | clear the others. To quickly disable DST just call the function without arguments - set_dst()
244 |
245 | Args:
246 | start (tuple): 4-tuple(month, week, weekday, hour) start of DST. Set to None to disable DST
247 | end (tuple) :4-tuple(month, week, weekday, hour) end of DST. Set to None to disable DST
248 | bias (int): Daylight Saving Time bias expressed in minutes. Set to 0 to disable DST
249 | """
250 |
251 | # Disable DST if bias is 0 or the start or end time is None
252 | if start is None or end is None or bias == 0:
253 | cls._dst_start = None
254 | cls._dst_end = None
255 | cls._dst_bias = 0
256 | elif not isinstance(start, tuple) or len(start) != 4:
257 | raise ValueError("Invalid parameter: start={} must be a 4-tuple(month, week, weekday, hour)".format(start))
258 | elif not isinstance(end, tuple) or len(end) != 4:
259 | raise ValueError("Invalid parameter: end={} must be a 4-tuple(month, week, weekday, hour)".format(end))
260 | else:
261 | cls.set_dst_start(start[0], start[1], start[2], start[3])
262 | cls.set_dst_end(end[0], end[1], end[2], end[3])
263 | cls.set_dst_bias(bias)
264 |
265 | @classmethod
266 | def set_dst_start(cls, month: int, week: int, weekday: int, hour: int):
267 | """ Set the start date and time of the DST
268 |
269 | Args:
270 | month (int): number in (Ntp.MONTH_JAN ... Ntp.MONTH_DEC)
271 | week (int): integer in (Ntp.WEEK_FIRST ... Ntp.WEEK_LAST). Sometimes there are months that stretch into 6 weeks. Ex. 05.2021
272 | weekday (int): integer in (Ntp.WEEKDAY_MON ... Ntp.WEEKDAY_SUN)
273 | hour (int): integer in range 0 - 23
274 | """
275 |
276 | if not isinstance(month, int) or not cls.MONTH_JAN <= month <= cls.MONTH_DEC:
277 | raise ValueError("Invalid parameter: month={} must be a integer in (Ntp.MONTH_JAN ... Ntp.MONTH_DEC)".format(month))
278 | elif not isinstance(week, int) or not cls.WEEK_FIRST <= week <= cls.WEEK_LAST:
279 | raise ValueError("Invalid parameter: week={} must be a integer in (Ntp.WEEK_FIRST ... Ntp.WEEK_LAST)".format(week))
280 | elif not isinstance(weekday, int) or not cls.WEEKDAY_MON <= weekday <= cls.WEEKDAY_SUN:
281 | raise ValueError("Invalid parameter: weekday={} must be a integer in (Ntp.WEEKDAY_MON ... Ntp.WEEKDAY_SUN)".format(weekday))
282 | elif not isinstance(hour, int) or not 0 <= hour <= 23:
283 | raise ValueError("Invalid parameter: hour={} must be a integer between 0 and 23".format(hour))
284 |
285 | cls._dst_start = (month, week, weekday, hour)
286 |
287 | @classmethod
288 | def get_dst_start(cls):
289 | """ Get the start point of DST.
290 |
291 | Returns:
292 | tuple: 4-tuple(month, week, weekday, hour) or None if DST is disabled
293 | """
294 |
295 | return cls._dst_start
296 |
297 | @classmethod
298 | def set_dst_end(cls, month: int, week: int, weekday: int, hour: int):
299 | """ Set the end date and time of the DST
300 |
301 | Args:
302 | month (int): number in (Ntp.MONTH_JAN ... Ntp.MONTH_DEC)
303 | week (int): integer in (Ntp.WEEK_FIRST ... Ntp.WEEK_LAST). Sometimes there are months that stretch into 6 weeks. Ex. 05.2021
304 | weekday (int): integer in (Ntp.WEEKDAY_MON ... Ntp.WEEKDAY_SUN)
305 | hour (int): integer in range 0 - 23
306 | """
307 |
308 | if not isinstance(month, int) or not cls.MONTH_JAN <= month <= cls.MONTH_DEC:
309 | raise ValueError("Invalid parameter: month={} must be a integer in (Ntp.MONTH_JAN ... Ntp.MONTH_DEC)".format(month))
310 | elif not isinstance(week, int) or not cls.WEEK_FIRST <= week <= cls.WEEK_LAST:
311 | raise ValueError("Invalid parameter: week={} must be a integer in (Ntp.WEEK_FIRST ... Ntp.WEEK_LAST)".format(week))
312 | elif not isinstance(weekday, int) or not cls.WEEKDAY_MON <= weekday <= cls.WEEKDAY_SUN:
313 | raise ValueError("Invalid parameter: weekday={} must be a integer in (Ntp.WEEKDAY_MON ... Ntp.WEEKDAY_SUN)".format(weekday))
314 | elif not isinstance(hour, int) or not 0 <= hour <= 23:
315 | raise ValueError("Invalid parameter: hour={} must be a integer between 0 and 23".format(hour))
316 |
317 | cls._dst_end = (month, week, weekday, hour)
318 |
319 | @classmethod
320 | def get_dst_end(cls):
321 | """ Get the end point of DST.
322 |
323 | Returns:
324 | tuple: 4-tuple(month, week, weekday, hour) or None if DST is disabled
325 | """
326 |
327 | return cls._dst_end
328 |
329 | @classmethod
330 | def set_dst_bias(cls, bias: int):
331 | """ Set Daylight Saving Time bias expressed in minutes. To disable DST set the bias to 0.
332 | By disabling the DST, the dst_start and dst_end will be set None
333 |
334 | Args:
335 | bias (int): minutes of the DST bias. Correct values are 0, 30, 60, 90 and 120.
336 | Setting to 0 effectively disables DST
337 | """
338 |
339 | if not isinstance(bias, int) or bias not in (0, 30, 60, 90, 120):
340 | raise ValueError("Invalid parameter: bias={} represents minutes offset and must be either 0, 30, 60, 90 or 120".format(bias))
341 |
342 | if bias == 0:
343 | cls._dst_start = None
344 | cls._dst_end = None
345 |
346 | # Convert to seconds
347 | cls._dst_bias = bias * 60
348 |
349 | @classmethod
350 | def get_dst_bias(cls):
351 | """ Get Daylight Saving Time bias expressed in minutes.
352 |
353 | Returns:
354 | int: minutes of the DST bias. Valid values are 0, 30, 60, 90 and 120
355 | """
356 |
357 | # Convert to minutes
358 | return cls._dst_bias // 60
359 |
360 | @classmethod
361 | def set_dst_time_bias(cls, bias: int):
362 | """ DEPRECATED. The function is renamed to set_dst_bias(). This name will be deprecated soon """
363 | cls.set_dst_bias(bias)
364 |
365 | @classmethod
366 | def get_dst_time_bias(cls):
367 | """ DEPRECATED. The function is renamed to get_dst_bias(). This name will be deprecated soon """
368 | return cls.get_dst_bias()
369 |
370 | @classmethod
371 | def dst(cls, dt = None):
372 | """ Calculate if DST is currently in effect and return the bias in seconds.
373 |
374 | Args:
375 | dt (tuple, None): If a None - current datetime will be read using the callback.
376 | If an 8-tuple(year, month, day, weekday, hour, minute, second, subsecond), it's value will be used to calculate the DST
377 |
378 | Returns:
379 | int: Calculated DST bias in seconds
380 | """
381 |
382 | # Return 0 if DST is disabled
383 | if cls._dst_start is None or cls._dst_end is None or cls._dst_bias == 0:
384 | return 0
385 |
386 | # If a datetime tuple is passed, the DST will be calculated according to it otherwise read the current datetime
387 | if dt is None:
388 | # dt = (year, month, day, weekday, hour, minute, second, subsecond)
389 | # index 0 1 2 3 4 5 6 7
390 | dt = cls._datetime()
391 | elif not isinstance(dt, tuple) or len(dt) != 8:
392 | raise ValueError(
393 | 'Invalid parameter: dt={} must be a 8-tuple(year, month, day, weekday, hour, minute, second, subsecond)'.format(dt))
394 |
395 | # Calculates and caches the hours since the beginning of the month when the DST starts/ends
396 | if dt[0] != cls._dst_cache_switch_hours_timestamp or \
397 | cls._dst_cache_switch_hours_start is None or \
398 | cls._dst_cache_switch_hours_end is None:
399 | cls._dst_cache_switch_hours_timestamp = dt[0]
400 | cls._dst_cache_switch_hours_start = cls.weekday_in_month(dt[0], cls._dst_start[0], cls._dst_start[1], cls._dst_start[2]) * 24 + cls._dst_start[3]
401 | cls._dst_cache_switch_hours_end = cls.weekday_in_month(dt[0], cls._dst_end[0], cls._dst_end[1], cls._dst_end[2]) * 24 + cls._dst_end[3]
402 |
403 | # Condition 1: The current month is strictly within the DST period
404 | # Condition 2: Current month is the month the DST period starts. Calculates the current hours since the beginning of the month
405 | # and compares it with the cached value of the hours when DST starts
406 | # Condition 3: Current month is the month the DST period ends. Calculates the current hours since the beginning of the month
407 | # and compares it with the cached value of the hours when DST ends
408 | # If one of the three conditions is True, the DST is in effect
409 | if cls._dst_start[0] < dt[1] < cls._dst_end[0] or \
410 | (dt[1] == cls._dst_start[0] and (dt[2] * 24 + dt[4]) >= cls._dst_cache_switch_hours_start) or \
411 | (dt[1] == cls._dst_end[0] and (dt[2] * 24 + dt[4]) < cls._dst_cache_switch_hours_end):
412 | return cls._dst_bias
413 |
414 | # The current month is outside the DST period
415 | return 0
416 |
417 | @classmethod
418 | def set_ntp_timeout(cls, timeout_s: int = 1):
419 | """ Set a timeout of the network requests to the NTP servers. Default is 1 sec.
420 |
421 | Args:
422 | timeout_s (int): Timeout of the network request in seconds
423 | """
424 |
425 | if not isinstance(timeout_s, int):
426 | raise ValueError('Invalid parameter: timeout_s={} must be int'.format(timeout_s))
427 |
428 | cls._ntp_timeout_s = timeout_s
429 |
430 | @classmethod
431 | def get_ntp_timeout(cls):
432 | """ Get the timeout of the network requests to the NTP servers.
433 |
434 | Returns:
435 | int: Timeout of the request in seconds
436 | """
437 |
438 | return cls._ntp_timeout_s
439 |
440 | @classmethod
441 | def get_hosts(cls):
442 | """ Get a tuple of NTP servers.
443 |
444 | Returns:
445 | tuple: NTP servers
446 | """
447 |
448 | return tuple(cls._hosts)
449 |
450 | @classmethod
451 | def set_hosts(cls, value: tuple):
452 | """ Set a tuple with NTP servers.
453 |
454 | Args:
455 | value (tuple): NTP servers. Can contain hostnames or IP addresses
456 | """
457 |
458 | cls._hosts.clear()
459 |
460 | for host in value:
461 | if cls._validate_host(host):
462 | cls._hosts.append(host)
463 |
464 | @classmethod
465 | def get_timezone(cls):
466 | """ Get the timezone as a tuple.
467 |
468 | Returns:
469 | tuple: The timezone as a 2-tuple(hour, minute)
470 | """
471 |
472 | return cls._timezone // 3600, (cls._timezone % 3600) // 60
473 |
474 | @classmethod
475 | def set_timezone(cls, hour: int, minute: int = 0):
476 | """ Validates if the provided hour and minute values represent a valid timezone offset.
477 | The valid hour range is -12 to +14 for a zero minute offset, and specific values
478 | for 30 and 45 minute offsets. Raises ValueError if the inputs are not integers, and
479 | raises Exception for invalid timezone combinations.
480 |
481 | Args:
482 | hour (int): Timezone hour offset.
483 | minute (int, optional): Timezone minute offset. Default is 0.
484 | """
485 |
486 | if not isinstance(hour, int) or not isinstance(minute, int):
487 | raise ValueError('Invalid parameter: hour={}, minute={} must be integers'.format(hour, minute))
488 |
489 | if (
490 | (minute not in (0, 30, 45)) or
491 | (minute == 0 and not (-12 <= hour <= 14)) or
492 | (minute == 30 and hour not in (-9, -3, 3, 4, 5, 6, 9, 10)) or
493 | (minute == 45 and hour not in (5, 8, 12))
494 | ):
495 | raise ValueError('Invalid timezone for hour={} and minute={}'.format(hour, minute))
496 |
497 | cls._timezone = hour * 3600 + minute * 60
498 |
499 | @classmethod
500 | def time(cls, utc: bool = False):
501 | """ Get a tuple with the date and time in UTC or local timezone + DST.
502 |
503 | Args:
504 | utc (bool): the returned time will be according to UTC time
505 |
506 | Returns:
507 | tuple: 9-tuple(year, month, day, hour, minute, second, weekday, yearday, us)
508 | * year is the year including the century part
509 | * month is in (Ntp.MONTH_JAN ... Ntp.MONTH_DEC)
510 | * day is in (1 ... 31)
511 | * hour is in (0 ... 23)
512 | * minutes is in (0 ... 59)
513 | * seconds is in (0 ... 59)
514 | * weekday is in (Ntp.WEEKDAY_MON ... Ntp.WEEKDAY_SUN)
515 | * yearday is in (1 ... 366)
516 | * us is in (0 ... 999999)
517 | """
518 |
519 | # gmtime() uses the device's epoch
520 | us = cls.time_us(cls.device_epoch(), utc = utc)
521 | # (year, month, day, hour, minute, second, weekday, yearday) + (us,)
522 | return time.gmtime(us // 1000_000) + (us % 1000_000,)
523 |
524 | @classmethod
525 | def time_s(cls, epoch: int = None, utc: bool = False):
526 | """ Return the current time in seconds according to the selected
527 | epoch, timezone and Daylight Saving Time. To skip the timezone and DST calculation
528 | set utc to True.
529 |
530 | Args:
531 | utc (bool): the returned time will be according to UTC time
532 | epoch (int, None): an epoch according to which the time will be calculated. If None, the user selected epoch will be used.
533 | Possible values: Ntp.EPOCH_1900, Ntp.EPOCH_1970, Ntp.EPOCH_2000, None
534 |
535 | Returns:
536 | int: the time in seconds since the selected epoch
537 | """
538 |
539 | return cls.time_us(epoch = epoch, utc = utc) // 1000_000
540 |
541 | @classmethod
542 | def time_ms(cls, epoch: int = None, utc: bool = False):
543 | """ Return the current time in milliseconds according to the selected
544 | epoch, timezone and Daylight Saving Time. To skip the timezone and DST calculation
545 | set utc to True.
546 |
547 | Args:
548 | utc (bool): the returned time will be according to UTC time
549 | epoch (int, None): an epoch according to which the time will be calculated. If None, the user selected epoch will be used.
550 | Possible values: Ntp.EPOCH_1900, Ntp.EPOCH_1970, Ntp.EPOCH_2000, None
551 |
552 | Returns:
553 | int: the time in milliseconds since the selected epoch
554 | """
555 |
556 | return cls.time_us(epoch = epoch, utc = utc) // 1000
557 |
558 | @classmethod
559 | def time_us(cls, epoch: int = None, utc: bool = False):
560 | """ Return the current time in microseconds according to the selected
561 | epoch, timezone and Daylight Saving Time. To skip the timezone and DST calculation
562 | set utc to True.
563 |
564 | Args:
565 | utc (bool): the returned time will be according to UTC time
566 | epoch (int, None): an epoch according to which the time will be calculated. If None, the user selected epoch will be used.
567 | Possible values: Ntp.EPOCH_1900, Ntp.EPOCH_1970, Ntp.EPOCH_2000, None
568 |
569 | Returns:
570 | int: the time in microseconds since the selected epoch
571 | """
572 |
573 | # dt = (year, month, day, weekday, hour, minute, second, subsecond)
574 | # index 0 1 2 3 4 5 6 7
575 | dt = cls._datetime()
576 |
577 | epoch_delta = cls.epoch_delta(cls.device_epoch(), epoch)
578 |
579 | # Daylight Saving Time (DST) is not used for UTC as it is a time standard for all time zones.
580 | timezone_and_dst = 0 if utc else (cls._timezone + cls.dst(dt))
581 | # mktime() uses the device's epoch
582 | return (time.mktime((dt[0], dt[1], dt[2], dt[4], dt[5], dt[6], 0, 0, 0)) + epoch_delta + timezone_and_dst) * 1000_000 + dt[7]
583 |
584 | @classmethod
585 | def ntp_time(cls, epoch: int = None):
586 | """
587 | Retrieves the current UTC time from the first responsive NTP server in the provided server list with microsecond precision.
588 | This method attempts to connect to NTP servers sequentially, based on the server list order, until a response is received or the list is exhausted.
589 |
590 | In case of a server timeout, defined by the class-level timeout setting (`set_ntp_timeout()`), the method proceeds to the next server in the list.
591 | The default timeout is 1 sec. If no servers respond within the timeout period, the method raises an exception.
592 |
593 | The time received from the server is adjusted for network delay and converted to the specified epoch time format.
594 |
595 | Args:
596 | epoch (int, optional): The epoch year from which the time will be calculated. If None, the epoch set by the user is used.
597 | Possible values are Ntp.EPOCH_1900, Ntp.EPOCH_1970, Ntp.EPOCH_2000, or None for the user-defined default.
598 |
599 | Returns:
600 | tuple: A 2-tuple(ntp_time, timestamp) containing:
601 | 1. The adjusted current time from the NTP server in microseconds since the specified epoch.
602 | 2. The timestamp in microseconds at the moment the request was sent to the server. This value can be used to compensate for time differences
603 | between when the response was received and when the returned time is used.
604 |
605 | Raises:
606 | RuntimeError: If unable to connect to any NTP server from the provided list.
607 |
608 | Note:
609 | The function assumes that the list of NTP servers (`cls._hosts`), NTP message template (`cls.__ntp_msg`), and timeout setting (`cls._ntp_timeout_s`)
610 | are properly initialized in the class.
611 | """
612 |
613 | if not any(cls._hosts):
614 | raise Exception('There are no valid Hostnames/IPs set for the time server')
615 |
616 | # Clear the NTP request packet
617 | cls.__ntp_msg[0] = 0x1B
618 | for i in range(1, len(cls.__ntp_msg)):
619 | cls.__ntp_msg[i] = 0
620 |
621 | for host in cls._hosts:
622 | s = None
623 | try:
624 | host_addr = socket.getaddrinfo(host, 123)[0][-1]
625 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
626 | s.settimeout(cls._ntp_timeout_s)
627 | transmin_ts_us = time.ticks_us() # Record send time (T1)
628 | s.sendto(cls.__ntp_msg, host_addr)
629 | s.readinto(cls.__ntp_msg)
630 | receive_ts_us = time.ticks_us() # Record receive time (T2)
631 | except Exception as e:
632 | cls._log('(NTP) Network error: Host({}) Error({})'.format(host, str(e)))
633 | continue
634 | finally:
635 | if s is not None:
636 | s.close()
637 |
638 | # Mode: The mode field of the NTP packet is an 8-bit field that specifies the mode of the packet.
639 | # A value of 4 indicates a server response, so if the mode value is not 4, the packet is invalid.
640 | if (cls.__ntp_msg[0] & 0b00000111) != 4:
641 | cls._log('(NTP) Invalid packet due to bad "mode" field value: Host({})'.format(host))
642 | continue
643 |
644 | # Leap Indicator: The leap indicator field of the NTP packet is a 2-bit field that indicates the status of the server's clock.
645 | # A value of 0 or 1 indicates a normal or unsynchronized clock, so if the leap indicator field is set to any other value, the packet is invalid.
646 | if ((cls.__ntp_msg[0] >> 6) & 0b00000011) > 2:
647 | cls._log('(NTP) Invalid packet due to bad "leap" field value: Host({})'.format(host))
648 | continue
649 |
650 | # Stratum: The stratum field of the NTP packet is an 8-bit field that indicates the stratum level of the server.
651 | # A value outside the range 1 to 15 indicates an invalid packet.
652 | if not (1 <= (cls.__ntp_msg[1]) <= 15):
653 | cls._log('(NTP) Invalid packet due to bad "stratum" field value: Host({})'.format(host))
654 | continue
655 |
656 | # Extract T3 and T4 from the NTP packet
657 | # Receive Timestamp (T3): The Receive Timestamp field of the NTP packet is a 64-bit field that contains the server's time when the packet was received.
658 | srv_receive_ts_sec, srv_receive_ts_frac = struct.unpack('!II', cls.__ntp_msg[32:40]) # T3
659 | # Transmit Timestamp (T4): The Transmit Timestamp field of the NTP packet is a 64-bit field that contains the server's time when the packet was sent.
660 | srv_transmit_ts_sec, srv_transmit_ts_frac = struct.unpack('!II', cls.__ntp_msg[40:48]) # T4
661 |
662 | # If any of these fields is zero, it may indicate that the packet is invalid.
663 | if srv_transmit_ts_sec == 0 or srv_receive_ts_sec == 0:
664 | cls._log('(NTP) Invalid packet: Host({})'.format(host))
665 | continue
666 |
667 | # Convert T3 to microseconds
668 | srv_receive_ts_us = srv_receive_ts_sec * 1_000_000 + (srv_receive_ts_frac * 1_000_000 >> 32)
669 | # Convert T4 to microseconds
670 | srv_transmit_ts_us = srv_transmit_ts_sec * 1_000_000 + (srv_transmit_ts_frac * 1_000_000 >> 32)
671 | # Calculate network delay in microseconds
672 | network_delay_us = (receive_ts_us - transmin_ts_us) - (srv_transmit_ts_us - srv_receive_ts_us)
673 | # Adjust server time (T4) by half of the network delay
674 | adjusted_server_time_us = srv_transmit_ts_us - (network_delay_us // 2)
675 | # Adjust server time (T4) by the epoch difference
676 | adjusted_server_time_us += cls.epoch_delta(from_epoch = cls.EPOCH_1900, to_epoch = epoch) * 1_000_000
677 |
678 | # Return the adjusted server time and the reception time in us
679 | return adjusted_server_time_us, receive_ts_us
680 |
681 | raise RuntimeError('Can not connect to any of the NTP servers')
682 |
683 | @classmethod
684 | def rtc_sync(cls, new_time = None):
685 | """ Synchronize the RTC with the time from the NTP server. To bypass the NTP server,
686 | you can pass an optional parameter with the new time. This is useful when your device has
687 | an accurate RTC on board, which can be used instead of the costly NTP queries.
688 |
689 | Args:
690 | new_time (tuple, None): If None - the RTC will be synchronized from the NTP server.
691 | If 2-tuple - the RTC will be synchronized with the given value.
692 | The 2-tuple format is (time, timestamp), where:
693 | * time = the micro second time in UTC since the device's epoch
694 | * timestamp = micro second timestamp at the moment the time was sampled
695 | """
696 |
697 | if new_time is None:
698 | new_time = cls.ntp_time(cls.device_epoch())
699 | elif not isinstance(new_time, tuple) or not len(new_time) == 2:
700 | raise ValueError('Invalid parameter: new_time={} must be a either None or 2-tuple(time, timestamp)'.format(new_time))
701 |
702 | # Take into account the time from the moment it was taken up to this point
703 | ntp_us = new_time[0] + (time.ticks_us() - new_time[1])
704 | lt = time.gmtime(ntp_us // 1000_000)
705 | # lt = (year, month, day, hour, minute, second, weekday, yearday)
706 | # index 0 1 2 3 4 5 6 7
707 |
708 | cls._datetime((lt[0], lt[1], lt[2], lt[6], lt[3], lt[4], lt[5], ntp_us % 1000_000))
709 | # Store the precision-adjusted value to match what RTC actually stores
710 | cls._rtc_last_sync = (ntp_us // cls._datetime_callback_precision) * cls._datetime_callback_precision
711 |
712 | @classmethod
713 | def rtc_last_sync(cls, epoch: int = None, utc: bool = False):
714 | """ Get the last time the RTC was synchronized.
715 |
716 | Args:
717 | epoch (int, None): an epoch according to which the time will be calculated. If None, the user selected epoch will be used.
718 | Possible values: Ntp.EPOCH_1900, Ntp.EPOCH_1970, Ntp.EPOCH_2000, None
719 | utc (bool): the returned time will be according to UTC time
720 |
721 | Returns:
722 | int: RTC last sync time in micro seconds by taking into account epoch and utc
723 | """
724 |
725 | timezone_and_dst = 0 if utc else (cls._timezone + cls.dst())
726 | epoch_delta = cls.epoch_delta(cls.device_epoch(), epoch)
727 | return 0 if cls._rtc_last_sync == 0 else cls._rtc_last_sync + (epoch_delta + timezone_and_dst) * 1000_000
728 |
729 | @classmethod
730 | def drift_calculate(cls, new_time = None):
731 | """ Calculate the drift of the RTC. Compare the time from the RTC with the time
732 | from the NTP server and calculates the drift in ppm units and the absolute drift
733 | time in micro seconds. To bypass the NTP server, you can pass an optional parameter
734 | with the new time. This is useful when your device has an accurate RTC on board,
735 | which can be used instead of the costly NTP queries.
736 | To be able to calculate the drift, the RTC has to be
737 | synchronized first. More accurate results can be achieved if the time between last
738 | RTC synchronization and calling this function is increased. Practical tests shows
739 | that the minimum time from the last RTC synchronization has to be at least 20 min.
740 | To get more stable and reliable data, periods of more than 2 hours are suggested.
741 | The longer, the better.
742 | Once the drift is calculated, the device can go offline and periodically call
743 | drift_compensate() to keep the RTC accurate. To calculate the drift in absolute
744 | micro seconds call drift_us(). Example: drift_compensate(drift_us()).
745 | The calculated drift is stored and can be retrieved later with drift_ppm().
746 |
747 | Args:
748 | new_time (tuple): None or 2-tuple(time, timestamp). If None, the RTC will be synchronized
749 | from the NTP server. If 2-tuple is passed, the RTC will be compensated with the given value.
750 | The 2-tuple format is (time, timestamp), where:
751 | * time = the micro second time in UTC relative to the device's epoch
752 | * timestamp = micro second timestamp in CPU ticks at the moment the time was sampled.
753 | Example:
754 | from time import ticks_us
755 | timestamp = ticks_us()
756 |
757 | Returns:
758 | tuple: 2-tuple(ppm, us) ppm is a float and represents the calculated drift in ppm
759 | units; us is integer and contains the absolute drift in micro seconds.
760 | Both parameters can have negative and positive values. The sign shows in which
761 | direction the RTC is drifting. Positive values represent an RTC that is speeding,
762 | while negative values represent RTC that is lagging
763 | """
764 |
765 | # The RTC has not been synchronized, and the actual drift can not be calculated
766 | if cls._rtc_last_sync == 0 and cls._drift_last_compensate == 0:
767 | return 0.0, 0
768 |
769 | if new_time is None:
770 | new_time = cls.ntp_time(cls.device_epoch())
771 | elif not isinstance(new_time, tuple) or not len(new_time) == 2:
772 | raise ValueError('Invalid parameter: new_time={} must be a either None or 2-tuple(time, timestamp)'.format(new_time))
773 |
774 | rtc_us = cls.time_us(epoch = cls.device_epoch(), utc = True)
775 | # For maximum accuracy, subtract the execution time of all the code up to this point
776 | ntp_us = new_time[0] + (time.ticks_us() - new_time[1])
777 |
778 | # Calculate the delta between the current time and the last rtc sync or last compensate(whatever occurred last)
779 | rtc_sync_delta = ntp_us - max(cls._rtc_last_sync, cls._drift_last_compensate)
780 | rtc_ntp_delta = rtc_us - ntp_us
781 | cls._ppm_drift = (rtc_ntp_delta / rtc_sync_delta) * 1000_000
782 | cls._drift_last_calculate = ntp_us
783 |
784 | return cls._ppm_drift, rtc_ntp_delta
785 |
786 | @classmethod
787 | def drift_last_compensate(cls, epoch: int = None, utc: bool = False):
788 | """ Get the last time the RTC was compensated based on the drift calculation.
789 |
790 | Args:
791 | utc (bool): the returned time will be according to UTC time
792 | epoch (int, None): an epoch according to which the time will be calculated. If None, the user selected epoch will be used.
793 | Possible values: Ntp.EPOCH_1900, Ntp.EPOCH_1970, Ntp.EPOCH_2000, None
794 |
795 | Returns:
796 | int: RTC last compensate time in micro seconds by taking into account epoch and utc
797 | """
798 |
799 | timezone_and_dst = 0 if utc else (cls._timezone + cls.dst())
800 | epoch_delta = cls.epoch_delta(cls.device_epoch(), epoch)
801 | return 0 if cls._drift_last_compensate == 0 else cls._drift_last_compensate + (epoch_delta + timezone_and_dst) * 1000_000
802 |
803 | @classmethod
804 | def drift_last_calculate(cls, epoch: int = None, utc: bool = False):
805 | """ Get the last time the drift was calculated.
806 |
807 | Args:
808 | utc (bool): the returned time will be according to UTC time
809 | epoch (int, None): an epoch according to which the time will be calculated. If None, the user selected epoch will be used.
810 | Possible values: Ntp.EPOCH_1900, Ntp.EPOCH_1970, Ntp.EPOCH_2000, None
811 |
812 | Returns:
813 | int: the last drift calculation time in micro seconds by taking into account epoch and utc
814 | """
815 |
816 | timezone_and_dst = 0 if utc else (cls._timezone + cls.dst())
817 | epoch_delta = cls.epoch_delta(cls.device_epoch(), epoch)
818 | return 0 if cls._drift_last_calculate == 0 else cls._drift_last_calculate + (epoch_delta + timezone_and_dst) * 1000_000
819 |
820 | @classmethod
821 | def drift_ppm(cls):
822 | """ Get the calculated or manually set drift in ppm units.
823 |
824 | Returns:
825 | float: positive or negative number containing the drift value in ppm units
826 | """
827 |
828 | return cls._ppm_drift
829 |
830 | @classmethod
831 | def set_drift_ppm(cls, ppm: float):
832 | """ Manually set the drift in ppm units. If you know in advance the actual drift you can
833 | set it with this function.
834 | The ppm can be calculated in advance and stored in a Non-Volatile Storage as calibration
835 | data. That way the drift_calculate() as well as the initial long wait period can be skipped.
836 |
837 | Args:
838 | ppm (float, int): positive or negative number containing the drift value in ppm units.
839 | Positive values represent a speeding, while negative values represent a lagging RTC
840 | """
841 |
842 | if not isinstance(ppm, (float, int)):
843 | raise ValueError('Invalid parameter: ppm={} must be float or int'.format(ppm))
844 |
845 | cls._ppm_drift = float(ppm)
846 |
847 | @classmethod
848 | def drift_us(cls, ppm_drift: float = None):
849 | """ Calculate the drift in absolute micro seconds.
850 |
851 | Args:
852 | ppm_drift (float, None): if None, use the previously calculated or manually set ppm.
853 | If you pass a value other than None, the drift is calculated according to this
854 | value
855 |
856 | Returns:
857 | int: number containing the calculated drift in micro seconds.
858 | Positive values represent a speeding, while negative values represent a lagging RTC
859 | """
860 |
861 | if cls._rtc_last_sync == 0 and cls._drift_last_compensate == 0:
862 | return 0
863 |
864 | if ppm_drift is None:
865 | ppm_drift = cls._ppm_drift
866 |
867 | if not isinstance(ppm_drift, (float, int)):
868 | raise ValueError('Invalid parameter: ppm_drift={} must be float or int'.format(ppm_drift))
869 |
870 | delta_time_rtc = cls.time_us(epoch = cls.device_epoch(), utc = True) - max(cls._rtc_last_sync, cls._drift_last_compensate)
871 | delta_time_real = int((1000_000 * delta_time_rtc) // (1000_000 + ppm_drift))
872 |
873 | return delta_time_rtc - delta_time_real
874 |
875 | @classmethod
876 | def drift_compensate(cls, compensate_us: int):
877 | """ Compensate the RTC by adding the compensate_us parameter to it. The value can be
878 | positive or negative, depending on how you wish to compensate the RTC.
879 |
880 | Args:
881 | compensate_us (int): the microseconds that will be added to the RTC
882 | """
883 |
884 | if not isinstance(compensate_us, int):
885 | raise ValueError('Invalid parameter: compensate_us={} must be int'.format(compensate_us))
886 |
887 | rtc_us = cls.time_us(epoch = cls.device_epoch(), utc = True) + compensate_us
888 | lt = time.gmtime(rtc_us // 1000_000)
889 | # lt = (year, month, day, hour, minute, second, weekday, yearday)
890 | # index 0 1 2 3 4 5 6 7
891 |
892 | cls._datetime((lt[0], lt[1], lt[2], lt[6], lt[3], lt[4], lt[5], rtc_us % 1000_000))
893 | cls._drift_last_compensate = rtc_us
894 |
895 | @classmethod
896 | def weekday(cls, year: int, month: int, day: int):
897 | """ Find Weekday using Zeller's Algorithm, from the year, month and day.
898 |
899 | Args:
900 | year (int): number greater than 1
901 | month (int): number in range 1(Jan) - 12(Dec)
902 | day (int): number in range 1-31
903 |
904 | Returns:
905 | int: 0(Mon) 1(Tue) 2(Wed) 3(Thu) 4(Fri) 5(Sat) to 6(Sun)
906 | """
907 |
908 | if not isinstance(year, int) or not 1 <= year:
909 | raise ValueError('Invalid parameter: year={} must be int and greater than 1'.format(year))
910 | elif not isinstance(month, int) or not cls.MONTH_JAN <= month <= cls.MONTH_DEC:
911 | raise ValueError('Invalid parameter: month={} must be int in range 1-12'.format(month))
912 |
913 | days = cls.days_in_month(year, month)
914 | if day > days:
915 | raise ValueError('Invalid parameter: day={} is greater than the days in month({})'.format(day, days))
916 |
917 | if month <= 2:
918 | month += 12
919 | year -= 1
920 |
921 | y = year % 100
922 | c = year // 100
923 | w = int(day + int((13 * (month + 1)) / 5) + y + int(y / 4) + int(c / 4) + 5 * c) % 7
924 |
925 | return cls.__weekdays[w]
926 |
927 | @classmethod
928 | def days_in_month(cls, year, month):
929 | """ Calculate how many days are in a given year and month
930 |
931 | Args:
932 | year (int): number greater than 1
933 | month (int): number in range 1(Jan) - 12(Dec)
934 |
935 | Returns:
936 | int: the number of days in the given month
937 | """
938 |
939 | if not isinstance(year, int) or not 1 <= year:
940 | raise ValueError('Invalid parameter: year={} must be int and greater than 1'.format(year))
941 | elif not isinstance(month, int) or not cls.MONTH_JAN <= month <= cls.MONTH_DEC:
942 | raise ValueError('Invalid parameter: month={} must be int in range 1-12'.format(month))
943 |
944 | if month == cls.MONTH_FEB:
945 | if (year % 400 == 0) or ((year % 4 == 0) and (year % 100 != 0)):
946 | return cls.__days[1] + 1
947 |
948 | return cls.__days[month - 1]
949 |
950 | @classmethod
951 | def weeks_in_month(cls, year, month):
952 | """ Split the month into tuples of weeks. The definition of a week is from Mon to Sun.
953 | If a month starts on a day different from Monday, the first week will be: day 1 to the day of the
954 | first Sunday. If a month ends on a day different from the Sunday, the last week will be: the last
955 | Monday till the end of the month. A month can have up to 6 weeks in it.
956 | For example if we run this function for May 2021, the result will be:
957 | [(1, 2), (3, 9), (10, 16), (17, 23), (24, 30), (31, 31)]. You can clearly see that
958 | the first week consists of just two days: Sat and Sun; the last week consists of just a single
959 | day: Mon
960 |
961 | Args:
962 | year (int): number greater than 1
963 | month (int): number in range 1(Jan) - 12(Dec)
964 |
965 | Returns:
966 | list: 2-tuples of weeks. Each tuple contains the first and the last day of the current week.
967 | Example result for May 2021: [(1, 2), (3, 9), (10, 16), (17, 23), (24, 30), (31, 31)]
968 | """
969 |
970 | if not isinstance(year, int) or not 1 <= year:
971 | raise ValueError('Invalid parameter: year={} must be int and greater than 1'.format(year))
972 | elif not isinstance(month, int) or not cls.MONTH_JAN <= month <= cls.MONTH_DEC:
973 | raise ValueError('Invalid parameter: month={} must be int in range 1-12'.format(month))
974 |
975 | first_sunday = 7 - cls.weekday(year, month, 1)
976 | weeks_list = list()
977 | weeks_list.append((1, first_sunday))
978 | days_in_month = cls.days_in_month(year, month)
979 | for i in range(0, 5):
980 | if days_in_month <= first_sunday + (i + 1) * 7:
981 | weeks_list.append((weeks_list[i][1] + 1, days_in_month))
982 | break
983 | else:
984 | weeks_list.append((weeks_list[i][1] + 1, first_sunday + (i + 1) * 7))
985 |
986 | return weeks_list
987 |
988 | @classmethod
989 | def weekday_in_month(cls, year: int, month: int, ordinal_weekday: int, weekday: int):
990 | """Calculate and return the day of the month for the Nth ordinal occurrence of the specified weekday
991 | within a given month and year. If there are fewer occurrences of the specified weekday in the month,
992 | the function returns the day of the last occurrence of the specified weekday. For instance, if you are
993 | looking for the second Tuesday of a month, "second" is the ordinal representing the occurrence of
994 | the weekday "Tuesday," and you would use 2 as the value for the ordinal_weekday parameter in the function.
995 | Example:
996 | weekday_in_month(2021, Ntp.MONTH_MAR, Ntp.WEEK_SECOND, Ntp.WEEKDAY_SUN)
997 | weekday_in_month(2021, Ntp.MONTH_OCT, Ntp.WEEK_LAST, Ntp.WEEKDAY_SUN)
998 |
999 | Args:
1000 | year (int): The year for which the calculation is to be made, must be an integer greater than 1.
1001 | month (int): The month for which the calculation is to be made, must be an integer in the range 1(Jan) - 12(Dec).
1002 | ordinal_weekday (int): Represents the ordinal occurance of the weekday in the specified month, must be an integer in the range 1-6.
1003 | weekday (int): Represents the specific weekday, must be an integer in the range 0(Mon)-6(Sun).
1004 |
1005 | Returns:
1006 | int: The day of the month of the Nth ordinal occurrence of the specified weekday. If the ordinal specified is greater
1007 | than the total occurrences of the weekday in that month, it returns the day of the last occurrence of the specified weekday.
1008 |
1009 | Raises:
1010 | ValueError: If any of the parameters are of incorrect type or out of the valid range.
1011 | """
1012 |
1013 | if not isinstance(year, int) or not 1 <= year:
1014 | raise ValueError('Invalid parameter: year={} must be int and greater than 1'.format(year))
1015 | elif not isinstance(month, int) or not cls.MONTH_JAN <= month <= cls.MONTH_DEC:
1016 | raise ValueError('Invalid parameter: month={} must be int in range 1-12'.format(month))
1017 | elif not isinstance(ordinal_weekday, int) or not cls.WEEK_FIRST <= ordinal_weekday <= cls.WEEK_LAST:
1018 | raise ValueError('Invalid parameter: ordinal_weekday={} must be int in range 1-6'.format(ordinal_weekday))
1019 | elif not isinstance(weekday, int) or not cls.WEEKDAY_MON <= weekday <= cls.WEEKDAY_SUN:
1020 | raise ValueError('Invalid parameter: weekday={} must be int in range 0-6'.format(weekday))
1021 |
1022 | first_weekday = cls.weekday(year, month, 1) # weekday of first day of month
1023 | first_day = 1 + (weekday - first_weekday) % 7 # monthday of first requested weekday
1024 | weekdays = [i for i in range(first_day, cls.days_in_month(year, month) + 1, 7)]
1025 | return weekdays[-1] if ordinal_weekday > len(weekdays) else weekdays[ordinal_weekday - 1]
1026 |
1027 | @classmethod
1028 | def day_from_week_and_weekday(cls, year, month, week, weekday):
1029 | """ Calculate the day based on year, month, week and weekday. If the selected week is
1030 | outside the boundaries of the month, the last weekday of the month will be returned.
1031 | Otherwise, if the weekday is within the boundaries of the month but is outside the
1032 | boundaries of the week, raise an exception. This behaviour is desired when you want
1033 | to select the last weekday of the month, like the last Sunday of October or the
1034 | last Sunday of March.
1035 | Example: day_from_week_and_weekday(2021, Ntp.MONTH_MAR, Ntp.WEEK_LAST, Ntp.WEEKDAY_SUN)
1036 | day_from_week_and_weekday(2021, Ntp.MONTH_OCT, Ntp.WEEK_LAST, Ntp.WEEKDAY_SUN)
1037 |
1038 | Args:
1039 | year (int): number greater than 1
1040 | month (int): number in range 1(Jan) - 12(Dec)
1041 | week (int): number in range 1-6
1042 | weekday (int): number in range 0(Mon)-6(Sun)
1043 |
1044 | Returns:
1045 | int: the calculated day. If the day is outside the boundaries of the month, returns
1046 | the last weekday in the month. If the weekday is outside the boundaries of the
1047 | given week, raise an exception
1048 | """
1049 |
1050 | if not isinstance(year, int) or not 1 <= year:
1051 | raise ValueError('Invalid parameter: year={} must be int and greater than 1'.format(year))
1052 | elif not isinstance(month, int) or not cls.MONTH_JAN <= month <= cls.MONTH_DEC:
1053 | raise ValueError('Invalid parameter: month={} must be int in range 1-12'.format(month))
1054 | elif not isinstance(week, int) or not cls.WEEK_FIRST <= week <= cls.WEEK_LAST:
1055 | raise ValueError('Invalid parameter: week={} must be int in range 1-6'.format(week))
1056 | elif not isinstance(weekday, int) or not cls.WEEKDAY_MON <= weekday <= cls.WEEKDAY_SUN:
1057 | raise ValueError('Invalid parameter: weekday={} must be int in range 0-6'.format(weekday))
1058 |
1059 | weeks = cls.weeks_in_month(year, month)
1060 | # Last day of the last week is the total days in month. This is faster instead of calling days_in_month(year, month)
1061 | days_in_month = weeks[-1][1]
1062 |
1063 | week_tuple = weeks[-1] if week > len(weeks) else weeks[week - 1]
1064 | day = week_tuple[0] + weekday
1065 |
1066 | # If the day is outside the boundaries of the month, select the week before the last
1067 | # This behaviour guarantees to return the last weekday of the month
1068 | if day > days_in_month:
1069 | return weeks[-2][0] + weekday
1070 |
1071 | # The desired weekday overflow the last day of the week
1072 | if day > week_tuple[1]:
1073 | raise Exception('The weekday does not exists in the selected week')
1074 |
1075 | # The first week is an edge case thus it must be handled in a special way
1076 | if week == cls.WEEK_FIRST:
1077 | # If first week does not contain the week day return the weekday from the second week
1078 | if weeks[0][0] + (6 - weekday) > weeks[0][1]:
1079 | return weeks[1][0] + weekday
1080 |
1081 | return weekday - (6 - weeks[0][1])
1082 |
1083 | return day
1084 |
1085 | @classmethod
1086 | def epoch_delta(cls, from_epoch: int, to_epoch: int):
1087 | """ Calculates the delta between two epochs. If you want to convert a timestamp from an earlier epoch to a latter,
1088 | you will have to subtract the seconds between the two epochs. If you want to convert a timestamp from a latter epoch to an earlier,
1089 | you will have to add the seconds between the two epochs. The function takes that into account and returns a positive or negative value.
1090 |
1091 | Args:
1092 | from_epoch (int, None): an epoch according to which the time will be calculated. If None, the user selected epoch will be used.
1093 | Possible values: Ntp.EPOCH_1900, Ntp.EPOCH_1970, Ntp.EPOCH_2000, None
1094 | to_epoch (int, None): an epoch according to which the time will be calculated. If None, the user selected epoch will be used.
1095 | Possible values: Ntp.EPOCH_1900, Ntp.EPOCH_1970, Ntp.EPOCH_2000, None
1096 |
1097 | Returns:
1098 | int: The delta between the two epochs in seconds. Positive or negative number
1099 | """
1100 |
1101 | if from_epoch is None:
1102 | from_epoch = cls._epoch
1103 | elif not isinstance(from_epoch, int) or not (cls.EPOCH_1900 <= from_epoch <= cls.EPOCH_2000):
1104 | raise ValueError('Invalid parameter: from_epoch={} must be a one of Ntp.EPOCH_1900, Ntp.EPOCH_1970, Ntp.EPOCH_2000, None'.format(from_epoch))
1105 |
1106 | if to_epoch is None:
1107 | to_epoch = cls._epoch
1108 | elif not isinstance(to_epoch, int) or not (cls.EPOCH_1900 <= to_epoch <= cls.EPOCH_2000):
1109 | raise ValueError('Invalid parameter: to_epoch={} must be a one of Ntp.EPOCH_1900, Ntp.EPOCH_1970, Ntp.EPOCH_2000, None'.format(to_epoch))
1110 |
1111 | return cls.__epoch_delta_lut[from_epoch][to_epoch]
1112 |
1113 | @classmethod
1114 | def device_epoch(cls):
1115 | """ Get the device's epoch. Most of the micropython ports use the epoch of 2000, but some like the Unix port does use a different epoch.
1116 | Functions like time.gmtime() and RTC.datetime() will use the device's epoch.
1117 |
1118 | Returns:
1119 | int: Ntp.EPOCH_1900, Ntp.EPOCH_1970, Ntp.EPOCH_2000
1120 | """
1121 | # Return the cached value
1122 | if cls._device_epoch is not None:
1123 | return cls._device_epoch
1124 |
1125 | # Get the device epoch
1126 | year = time.gmtime(0)[0]
1127 | if year == 1900:
1128 | cls._device_epoch = cls.EPOCH_1900
1129 | return cls._device_epoch
1130 | elif year == 1970:
1131 | cls._device_epoch = cls.EPOCH_1970
1132 | return cls._device_epoch
1133 | elif year == 2000:
1134 | cls._device_epoch = cls.EPOCH_2000
1135 | return cls._device_epoch
1136 |
1137 | raise RuntimeError('Unsupported device epoch({})'.format(year))
1138 |
1139 | @classmethod
1140 | def _log(cls, message: str):
1141 | """ Use the logger callback to log a message.
1142 |
1143 | Args:
1144 | message (str): the message to be passed to the logger
1145 | """
1146 |
1147 | if callable(cls._log_callback):
1148 | cls._log_callback(message)
1149 |
1150 | @classmethod
1151 | def _datetime(cls, *dt):
1152 | """ Wrapper around calling the predefined datetime callback. This method
1153 | functions both as a getter and a setter for datetime information.
1154 |
1155 | Args:
1156 | dt (tuple, None): None or 8-tuple(year, month, day, weekday, hour, minute, second, subsecond)
1157 | If None, the function acts as a getter. If a tuple, the function acts as a setter
1158 | """
1159 |
1160 | if not callable(cls._datetime_callback):
1161 | raise Exception('No callback set to access the RTC')
1162 |
1163 | try:
1164 | return cls._datetime_callback(*dt)
1165 | except Exception as e:
1166 | cls._log('(RTC) Error. {}'.format(e))
1167 | raise e
1168 |
1169 | @staticmethod
1170 | def _validate_host(host: str):
1171 | """ Check if a host is valid. A host can be any valid hostname or IP address
1172 |
1173 | Args:
1174 | host (str): hostname or IP address in dot notation to be validated
1175 |
1176 | Returns:
1177 | bool: True on success, False on error
1178 | """
1179 |
1180 | if Ntp._validate_ip(host) or Ntp._validate_hostname(host):
1181 | return True
1182 |
1183 | return False
1184 |
1185 | @staticmethod
1186 | def _validate_hostname(hostname: str):
1187 | """ Check if a hostname is valid.
1188 |
1189 | Args:
1190 | hostname (str): the hostname to be validated
1191 |
1192 | Returns:
1193 | bool: True on success, False on error
1194 | """
1195 |
1196 | if not isinstance(hostname, str):
1197 | raise ValueError('Invalid parameter: hostname={} must be a string'.format(hostname))
1198 |
1199 | # strip exactly one dot from the right, if present
1200 | if hostname[-1] == '.':
1201 | hostname = hostname[:-1]
1202 |
1203 | if not (0 < len(hostname) <= 253):
1204 | return False
1205 |
1206 | labels = hostname.split('.')
1207 |
1208 | # the TLD must be not all-numeric
1209 | if re.match(r'[0-9]+$', labels[-1]):
1210 | return False
1211 |
1212 | allowed = re.compile(r'^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9_\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9_\-]*[A-Za-z0-9])$')
1213 | if not allowed.match(hostname):
1214 | return False
1215 |
1216 | return True
1217 |
1218 | @staticmethod
1219 | def _validate_ip(ip: str):
1220 | """ Check if the IP is a valid IP address in dot notation
1221 |
1222 | Args:
1223 | ip (str): the ip to be validated
1224 |
1225 | Returns:
1226 | bool: True on success, False on error
1227 | """
1228 |
1229 | if not isinstance(ip, str):
1230 | raise ValueError('Invalid parameter: ip={} must be a string'.format(ip))
1231 |
1232 | allowed = re.compile(
1233 | r'^(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)$')
1234 | if allowed.match(ip) is None:
1235 | return False
1236 |
1237 | return True
1238 |
--------------------------------------------------------------------------------
/test/tests.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from ..src import ntp
3 |
4 |
5 | class TestDayFromWeekAndWeekday(unittest.TestCase):
6 | """Unit tests for function day_from_week_and_weekday(cls, year, month, week, weekday)"""
7 |
8 | test_dates = [
9 | ((2013, ntp.Ntp.MONTH_MAR, ntp.Ntp.WEEK_FIRST, ntp.Ntp.WEEKDAY_MON), 4),
10 | ((2013, ntp.Ntp.MONTH_MAR, ntp.Ntp.WEEK_LAST, ntp.Ntp.WEEKDAY_SUN), 31),
11 | ((2013, ntp.Ntp.MONTH_OCT, ntp.Ntp.WEEK_FIRST, ntp.Ntp.WEEKDAY_MON), 7),
12 | ((2013, ntp.Ntp.MONTH_OCT, ntp.Ntp.WEEK_LAST, ntp.Ntp.WEEKDAY_SUN), 27),
13 |
14 | ((2015, ntp.Ntp.MONTH_APR, ntp.Ntp.WEEK_FIRST, ntp.Ntp.WEEKDAY_THU), 2),
15 | ((2015, ntp.Ntp.MONTH_APR, ntp.Ntp.WEEK_LAST, ntp.Ntp.WEEKDAY_SUN), 26),
16 | ((2015, ntp.Ntp.MONTH_NOV, ntp.Ntp.WEEK_FIRST, ntp.Ntp.WEEKDAY_SUN), 1),
17 | ((2015, ntp.Ntp.MONTH_NOV, ntp.Ntp.WEEK_LAST, ntp.Ntp.WEEKDAY_MON), 30),
18 |
19 | ((2017, ntp.Ntp.MONTH_FEB, ntp.Ntp.WEEK_FIRST, ntp.Ntp.WEEKDAY_WED), 1),
20 | ((2017, ntp.Ntp.MONTH_FEB, ntp.Ntp.WEEK_FIRST, ntp.Ntp.WEEKDAY_SUN), 5),
21 | ((2017, ntp.Ntp.MONTH_FEB, ntp.Ntp.WEEK_FIRST, ntp.Ntp.WEEKDAY_TUE), 7),
22 | ((2017, ntp.Ntp.MONTH_FEB, ntp.Ntp.WEEK_LAST, ntp.Ntp.WEEKDAY_SUN), 26),
23 | ((2017, ntp.Ntp.MONTH_SEP, ntp.Ntp.WEEK_FIRST, ntp.Ntp.WEEKDAY_SAT), 2),
24 | ((2017, ntp.Ntp.MONTH_SEP, ntp.Ntp.WEEK_LAST, ntp.Ntp.WEEKDAY_SUN), 24),
25 | ((2017, ntp.Ntp.MONTH_SEP, ntp.Ntp.WEEK_LAST, ntp.Ntp.WEEKDAY_SAT), 30),
26 |
27 | ((2020, ntp.Ntp.MONTH_FEB, ntp.Ntp.WEEK_FIRST, ntp.Ntp.WEEKDAY_SAT), 1),
28 | ((2020, ntp.Ntp.MONTH_FEB, ntp.Ntp.WEEK_FIRST, ntp.Ntp.WEEKDAY_TUE), 4),
29 | ((2020, ntp.Ntp.MONTH_FEB, ntp.Ntp.WEEK_FIRST, ntp.Ntp.WEEKDAY_FRI), 7),
30 | ((2020, ntp.Ntp.MONTH_FEB, ntp.Ntp.WEEK_LAST, ntp.Ntp.WEEKDAY_FRI), 28),
31 | ((2020, ntp.Ntp.MONTH_FEB, ntp.Ntp.WEEK_LAST, ntp.Ntp.WEEKDAY_SAT), 29),
32 | ((2020, ntp.Ntp.MONTH_FEB, ntp.Ntp.WEEK_LAST, ntp.Ntp.WEEKDAY_SUN), 23),
33 | ((2020, ntp.Ntp.MONTH_OCT, ntp.Ntp.WEEK_FIRST, ntp.Ntp.WEEKDAY_FRI), 30),
34 | ((2020, ntp.Ntp.MONTH_OCT, ntp.Ntp.WEEK_LAST, ntp.Ntp.WEEKDAY_SUN), 25),
35 | ((2020, ntp.Ntp.MONTH_OCT, ntp.Ntp.WEEK_SECOND, ntp.Ntp.WEEKDAY_TUE), 6),
36 |
37 | ((2022, ntp.Ntp.MONTH_JAN, ntp.Ntp.WEEK_FIRST, ntp.Ntp.WEEKDAY_MON), 3),
38 | ((2022, ntp.Ntp.MONTH_FEB, ntp.Ntp.WEEK_FIFTH, ntp.Ntp.WEEKDAY_TUE), 22),
39 | ((2024, ntp.Ntp.MONTH_FEB, ntp.Ntp.WEEK_FIFTH, ntp.Ntp.WEEKDAY_THU), 29),
40 | ]
41 |
42 | def test_valid_inputs_within_boundary(self):
43 | errors = []
44 | for date in self.test_dates:
45 | try:
46 | result = ntp.Ntp.day_from_week_and_weekday(*date[0])
47 | except:
48 | continue
49 |
50 | if result != date[1]:
51 | errors.append(f"Expected {date[1]} for sample ({date[0]}), but got {result}.")
52 |
53 | # Raise an assertion with all collected error messages
54 | self.assertTrue(not errors, "\n".join(errors))
55 |
56 | def test_invalid_month(self):
57 | with self.assertRaises(ValueError):
58 | ntp.Ntp.day_from_week_and_weekday(2022, 13, 1, 1)
59 |
60 | def test_invalid_week(self):
61 | with self.assertRaises(ValueError):
62 | ntp.Ntp.day_from_week_and_weekday(2022, 1, 0, 1)
63 |
64 | def test_invalid_weekday(self):
65 | with self.assertRaises(ValueError):
66 | ntp.Ntp.day_from_week_and_weekday(2022, 1, 1, 8)
67 |
68 |
69 | # Run the tests
70 | unittest.TextTestRunner().run(unittest.TestLoader().loadTestsFromTestCase(TestDayFromWeekAndWeekday))
71 |
--------------------------------------------------------------------------------