├── 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 | --------------------------------------------------------------------------------