├── LICENSE ├── README.md ├── images ├── antenna.jpg ├── gps-wiring.png ├── gps-with-pps-and-i2c-lcd.jpg ├── gps-with-pps.jpg ├── ntp-lcd-notes.jpg ├── ntp-lcd-slow.jpg └── ntp-lcd.jpg ├── resources ├── gps.fzz └── gps_i2c.fzz └── src ├── README.md ├── button.py ├── chronotron.py ├── chronotron.service ├── chronotron_venv.service └── i2c_lcd.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Dominik Schlösser 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GPS disciplined Stratum-1 NTP Server with Raspberry PI - a HowTo 2 | 3 | Stratum-1 NTP time server with Raspberry Pi, GPS and Chrony: 4 | 5 | - how to build a precision time server. 6 | - (optional) [how to add a 4x20 LCD to display](src) server status and GPS and PPS information. 7 | 8 | 9 |
10 |
11 | Optional display shows PPS lock 12 |
13 | 14 | ## Requirements - Hardware 15 | 16 | - Raspberry Pi 17 | 18 | While you can use any model of Raspberry Pi to implement a Stratum-1 NTP server with GPS, the best choice 19 | is Raspberry Pi 4, due to 1Gbit network interface and fast hardware for lowest possible latencies. 20 | 21 | - Using a Raspberry Pi 5 gives two additional advantages, but has changed the configuration of the serial port. 22 | 23 | - Support for the inbuilt hardware real time clock (RTC) that comes with the Raspberry Pi 5 24 | - Support for the PTP protocol, which allows to transmit precision time information via ethernet hardware 25 | - When connecting the GPS receiver via serial port, the new default is not to use the RX, TX pins on the 40pin connector, but the new three-pin UART connector. To use the 40-pin connector a special configuration is needed. 26 | 27 | Both options are of limited advantage for most settings: the hardware clock is only useful for providing time during the first few seconds after boot (and if both GPS _and_ network are inaccessible), and PTP is a time standard requiring IEEE1588-enabled hardware everywhere and provides no advantages over NTP/chrony in most settings. Both however are documented below (See `chrony` configuration, PTP-chapter). 28 | 29 | - GPS module 30 | 31 | * Adafruit GPS hat: [Adafruit ultimate GPS hat](https://www.adafruit.com/product/2324) 32 | * GPS module with serial output and PPS signal: [Adafruit GPS module](https://www.adafruit.com/product/746) 33 | * GPS module with USB, SMA connector and PPS: [Keystudio GPS module](https://wiki.keyestudio.com/KS0319_keyestudio_GPS_Module) 34 | * Cheap NEO6 modules: [Aliexpress NEO6](https://www.aliexpress.com/w/wholesale-neo6.html)) 35 | 36 | 37 | 38 | * Active GPS antenna 39 | 40 | When selecting a GPS antenna, make sure to get an active antenna with 3-5V power input. Passive antennas often look similar, but reception quality is far worse at similar cost. 41 | 42 | ### Antenna adapters 43 | 44 | Some boards have uFL antenna connectors, whereas almost all external active antennas use SMA, so you might need an uFL to SMA adapter: 45 | 46 | * [Adafruit uFL to SMA adapter](https://www.adafruit.com/product/851) 47 | * [Various uFL to SMA adapters](https://www.aliexpress.com/item/4000166668788.html?spm=a2g0o.productlist.0.0.2c386be8fN3oCH&algo_pvid=6da3d77b-f49d-4e6b-8dba-c9c7446cd7b4&algo_expid=6da3d77b-f49d-4e6b-8dba-c9c7446cd7b4-1&btsid=2100bddf16068135762546912e571d&ws_ab_test=searchweb0_0,searchweb201602_,searchweb201603_) 48 | 49 | ### To check 50 | 51 | #### Is external antenna selected? 52 | 53 | Some GPS modules come with a passive antenna and and external antenna plug. Check the description of your board to find out what's needed to use the external antenna. The Keystudio GPS module listed above for example, requires the removal of capacitor C2 in order to activate the external antenna. 54 | 55 | 56 | 57 | #### PPS signal easily accessible? 58 | 59 | Make sure your GPS module has an accessible output for the PPS signal. (Red mark on image) 60 | 61 | If you are using a GPS Hat that plugs into the Raspberry IO connector, check the documentation to which GPIO pin the PPS signal is connected. Adafruit's Hat uses GPIO 4. 62 | 63 | #### USB vs serial 64 | 65 | When not using a Pi Hat, decide between USB- and serial connection. 66 | 67 | USB-Connection: 68 | * No extra cables for Vcc, GND, Tx, Rx are needed: both powersupply and communication goes over USB. 69 | * No modification of Raspberry's serial console is needed, easier software installation 70 | * Example: [Keystudio GPS module](https://wiki.keyestudio.com/KS0319_keyestudio_GPS_Module) 71 | 72 | Serial connection: 73 | * Usually cheaper, if a module is purchased. 74 | * All Pi Hats use serial connections. 75 | * Raspberry serial console needs to be disabled 76 | * Sometimes, bluetooth needs to be disabled. 77 | * Raspberry Pi 5 changed the default serial connection to the 3-pin UART connector by default. 78 | 79 | ### Recommended setup 80 | 81 | * Raspberry PI 4 (or 5) 82 | * Either GPS hat (e.g. adafruit ultimate GPS hat + uFL to SMA-Adapter) or USB-GPS-Module with PPS output (e.g. keystudio gps module) 83 | * Active GPS antenna with SMA connector and 3-5V 84 | 85 | ### Most cost-efficient setup 86 | 87 | NEO6 GPS modules are available at very low cost (1-2Eur) at chinese dealers (Aliexpress) and are perfectly usable as long as they allow connecting an active external GPS antenna and provide a PPS signal. 88 | 89 | 90 | 91 | _Typical wiring between GPS module and Raspberry Pi connector._ 92 | 93 | > **Note:** If your GPS module is connected via USB, you only need to connect the PPS connector on the GPS module to GPIO 4 on the Raspberry. Power (Vin), Gnd and Tx/Rx are handled via USB. 94 | 95 | > **Note:** The Raspberry Pi 5 has a new connector for the serial port that is used by default instead of the Pins RX, TX on the diagram: 96 | 97 | #### Raspberry PI 5 serial connection changes 98 | 99 | - Either use _new the three-pin UART connector_, which is used by default for serial communication, 100 | - or reconfigure the serial ports by editing `/boot/firmware/config.txt` and adding the lines: 101 | 102 | ``` 103 | dtparam=uart0 104 | dtparam=uart0_console 105 | ``` 106 | 107 | Documentation for those options is currently in a disarray for Raspberry Pi 5, so some research might be required. See discussion in [#6](https://github.com/domschl/RaspberryNtpServer/issues/6). Also this [video on Raspberry Pi 5 Serial Port](https://www.youtube.com/watch?v=27p4XHE3iyw) usage might be helpful. 108 | 109 | ### Test 110 | 111 | **Before you continue with software:** At this point, the GPS module should be connected to the Raspberry Pi and the active antenna. Power up the Raspberry Pi, and check that the GPS module receives the GPS signal (the antenna must have unhindered access to the open sky, it does *not* work indoors!). 112 | 113 | If reception is ok, a led ("FIX" or "PPS") should start blinking on your GPS module. 114 | 115 | Check the documentation for your specific hardware how "FIX" is signaled: 116 | 117 | - The Adafruit module blinks once per second if there is no fix and once every 10 seconds when there is a fix. 118 | 119 | ### More information 120 | 121 | * Adafruit has an excellent [GPS guide](https://learn.adafruit.com/adafruit-ultimate-gps) for their ultimate GPS module. 122 | 123 | ### Professional / Lab considerations: PTP precision time protocol (OPTIONAL) 124 | 125 | If you are using laboratory equipment that uses PTP (precision time protocol) information (IEEE 1588), then you will need to use the Raspberry Pi 5 and make sure that your network switches are IEEE 1588 capable. Note that most consumer switches are not IEEE 1588 capable and cannot be used to set up a PTP network. See below (PTP chapter) for setup details and hardware tests. 126 | 127 | PTP is completely optional and not required in a network in order to have excellent results with your Raspberry Pi NTP server! 128 | 129 | ## Software 130 | 131 | > **Note:** _(Optional info)_ The original repository of `chrony` at seems to be of low availability. In case of problems, use the mirror at if you want to reference the original `chrony` sources at some point. 132 | 133 | ### Raspberry Linux preparations 134 | 135 | #### Serial port console (skip, if connected via USB) 136 | 137 | If your GPS board is connected via serial connection (Rx/Tx), you need to "free up" Raspberry's serial port, which by default is used to connect a serial (debug) console. We need to disable that console to prevent it from interferring with the GPS module. 138 | 139 | This is *not* needed, if your module is connected via USB. 140 | 141 | How to disable the serial console on Raspberry variies greatly depending on hardware revision and Linux flavour used. A few examples: 142 | 143 | ##### For Raspberry Pi 4 with Raspberry Pi OS: 144 | 145 | 1. Start raspi-config: `sudo raspi-config`. 146 | 2. Select option 3 - Interface Options. 147 | 3. Select option P6 - Serial Port. 148 | 4. At the prompt Would you like a login shell to be accessible over serial? answer 'No' 149 | 5. At the prompt Would you like the serial port hardware to be enabled? answer 'Yes' 150 | 6. Exit raspi-config and reboot the Pi for changes to take effect. 151 | 152 | See ["Disable Linux serial console"](https://www.raspberrypi.org/documentation/configuration/uart.md#:~:text=Disable%20Linux%20serial%20console&text=This%20can%20be%20done%20by,Select%20option%20P6%20%2D%20Serial%20Port) at raspberrypi.org. 153 | 154 | Older versions of `raspi-config` hide the same serial options under "Advanced options" 155 | 156 | ##### Other linux distributions and manual configuration 157 | 158 | > **Note:** Recent Raspberry Pi OS has __moved__ the location of kernel config files from `/boot/` to `/boot/firmware/`. Adapt the following paths according to your OS version (If moved, a note is left at the old `/boot/` location. 159 | 160 | 1. Edit `/boot/firmware/cmdline.txt` and remove references to the serial port `ttyAMA0`. E.g. remove: `console=ttyAMA0,115200` and (if present) `kgdboc=ttyAMA0,115200`. 161 | 2. Disable getty on serial port. `sudo systemctl disable getty@ttyAMA0` or, on some Linux distris: `sudo systemctl disable serial-getty@ttyAMA0` 162 | 3. Enable uart in `/boot/firmware/config.txt`, add a line `enable_uart=1` 163 | 4. For RPI 3 or later disable bluetooth via overlay, add another line to `/boot/firmware/config.txt`: `dtoverlay=pi3-disable-bt-overlay`. In some versions, this is done by *enabling* the overlay `dtoverlay=pi3-miniuart-bt` which disables bluetooth and reestablishes standard serial. 164 | 165 | See: ["Raspberry Pi 3, 4 and Zero W Serial Port Usage"](https://www.abelectronics.co.uk/kb/article/1035/raspberry-pi-3-serial-port-usage). 166 | 167 | #### Test serial / USB output of GPS 168 | 169 | Do a `cat` on your serial port (e.g. `cat /dev/ttyS0`), or, for USB `cat /dev/ttyACM0` or `cat /dev/ttyUSB0`, and you should receive an output like: 170 | 171 | ``` 172 | $GPGGA,1413247.000,48432.1655,N,01342323.7322,E,1,06,1.340,2505.5,M,347.6,M,,*69 173 | $GPGSA,A,3,123,303,248,205,037,415,,,,,,,1.59,1.340,0.90*049 174 | ``` 175 | 176 | ## Setting up GPSD 177 | 178 | Once you successfully receive output from your GPS module, next step is to install `gpsd` via your package manager. 179 | 180 | For Raspberry Pi OS (and Ubuntu and Debian variants), that would be: 181 | 182 | ```bash 183 | sudo apt install gpsd 184 | ``` 185 | 186 | Edit `/etc/default/gpsd`: 187 | 188 | For serial connections (use your device name as tested above): 189 | 190 | ``` 191 | GPSD_OPTIONS="-n -G" 192 | DEVICES="/dev/ttyS0" 193 | USBAUTO="false" 194 | ``` 195 | 196 | For USB connections (again use your actual device name): 197 | 198 | ``` 199 | GPSD_OPTIONS="-n" 200 | DEVICES="/dev/ttyACM0" 201 | USBAUTO="true" 202 | ``` 203 | 204 | Enable and start `gpsd` with: 205 | 206 | ``` 207 | sudo systemctl enable gpsd 208 | sudo systemctl start gpsd 209 | ``` 210 | 211 | Now use `cgps` or `gpsmon` to make sure that you are receiving GPS information and time. 212 | 213 | `cgps` should display `Status: 3D fix (xx secs)`. Do not continue until you have a stable GPS fix. 214 | 215 | ## Setting up PPS 216 | 217 | The serial or USB connection to the GPS module alone does not allow precise time synchronisation. The slow communication has latencies somewhere between 50ms to 200ms, much too high latency for precision time servers. 218 | 219 | This is compensated by the PPS signal that is directly connected to Raspberry PI's GPIO 4 (you can use other GPIO pins, simply adapt this correpondingly below). 220 | 221 | We need to enable a special kernel driver and overlay in order to receive this once-per-second GPS synchronised pulse as precisely as possible. The location of config files for kernel modules and options has changed recently (2024-03)! 222 | 223 | ### Current Raspberry Pi OS 'Bookworm' (since 2024-03) 224 | 225 | You'll find that `/boot/config.txt` just contains a note that file content has moved to `/boot/firmware/config.txt`. 226 | 227 | 1. Edit `/boot/firmware/config.txt` and add a line `dtoverlay=pps-gpio,gpiopin=4`. (Editing `cmdline.txt` is no longer necessary) 228 | 2. Edit `/etc/modules-load.d/raspberrypi.conf` and add two lines with `pps-gpio` and `pps-ldisc`, to load the required kernel modules 229 | 230 | ### Older versions of Raspberry Pi OS and other distris 231 | 232 | if your `/boot/config.txt` does __not__ contain a note that content has moved to `/boot/firmware/config.txt`: 233 | 234 | 1. Edit `/boot/cmdline.txt` and add ` bcm2708.pps_gpio_pin=4` at the end of the line. (Might not be necessary) 235 | 2. Edit `/boot/config.txt` and add a line `dtoverlay=pps-gpio,gpiopin=4`. (Depending on your distri, either 1. or 2. is necessary, but it doesn't seem to hurt to do both). 236 | 3. Edit `/etc/modules-load.d/raspberrypi.conf` and add two lines with `pps-gpio` and `pps-ldisc`, to load the required kernel modules 237 | 238 | After a reboot, a new device `/dev/pps0` should exist, and `dmesg` should show something like: 239 | 240 | ``` 241 | [ 7.528565] pps_core: LinuxPPS API ver. 1 registered 242 | [ 7.530144] pps_core: Software ver. 5.3.6 - Copyright 2005-2007 Rodolfo Giometti 243 | [ 7.540372] pps pps0: new PPS source pps@4.-1 244 | [ 7.542012] pps pps0: Registered IRQ 166 as PPS source 245 | [ 7.550775] pps_ldisc: PPS line discipl43ine registered 246 | ``` 247 | 248 | Now use `ppstest` (you might need to install `pps-utils` or `pps-tools` depending on your distri): 249 | 250 | `sudo ppstest /dev/pps0` 251 | 252 | You should see an output like: 253 | 254 | ``` 255 | trying PPS source "/dev/pps0" 256 | found PPS source "/dev/pps0" 257 | ok, found 1 source(s), now start fetching data... 258 | source 0 - assert 1606833766.999998552, sequence: 341090 - clear 0.000000000, sequence: 0 259 | source 0 - assert 1606833767.999998573, sequence: 341091 - clear 0.000000000, sequence: 0 260 | source 0 - assert 1606833768.999998751, sequence: 341092 - clear 0.000000000, sequence: 0 261 | ``` 262 | 263 | `assert` and `sequence` should both increment by 1 for each line. 264 | 265 | Once you receive a PPS signal and GPSD is configured, continue. 266 | 267 | ### Access permissions for `/dev/pps0` 268 | 269 | Some linux distribution only allow `root` to read or access `/dev/pps0`. This is no issue, if only root services want to access `/dev/pps0`. Note that recent versions of `chronyd` drop privileges after start, and then might not be able to access `/dev/pps0` anymore. See below for solutions (`udev` or running `chronyd` as root.) 270 | 271 | ### Timeouts with Raspberry Pi OS 64-bit 2023-03 onwards 272 | 273 | **Note:** This problem seems to be fixed with current Raspberry Pi OS 'bookworm' releases (2023-12 status). 274 | 275 | `sudo ppstest /dev/pps0` yields: 276 | 277 | ``` 278 | found PPS source "/dev/pps0" 279 | ok, found 1 source(s), now start fetching data... 280 | source 0 - assert 1679340614.003464678, sequence: 84623 - clear 0.000000000, sequence: 0 281 | time_pps_fetch() error -1 (Connection timed out) 282 | ``` 283 | 284 | See issue [6](https://github.com/domschl/RaspberryNtpServer/issues/6) for the ongoing discussion. 285 | 286 | ## Setting up Chrony as time-server 287 | 288 | > **Note:** Before you setup `chrony`, make sure you completed `gpsd` configuration and that `gpsd` is running ok, and that you have a valid PPS signal at `/dev/pps0`. 289 | 290 | > **Note:** Especially when using Raspberry Pi 5 (with new features PTP and RTC), it is recommended to use the standard Raspberry Pi OS, which comes with a chrony version, that is aware of the Raspberry Pi hardware. 291 | 292 | Recent chronyd versions drop root privilege after start (check for the `-u` option in `chronyd.service` to see the user that will be used to run `chronyd`). If `/dev/pps0` is not accessible for that user, pps won't work. There are two solutions: either use a version of chronyd that doesn't drop privileges and runs as root, or setup a `udev` rule that allows access to `/dev/pps0` for the process-user of `chronyd`. 293 | 294 | If you [compile](https://chrony.tuxfamily.org/doc/3.5/installation.html), [mirror](https://github.com/mlichvar/chrony/blob/master/doc/installation.adoc) `chrony` yourself, there is an option `--disable-privdrop` for `configure`. See `configure -h` for all options for building `chrony`. 295 | 296 | ### `udev` rules example 297 | 298 | Udev rules are tricky and depend on the user of the `chronyd` process and the type of connection your are using. 299 | 300 | Create a file `/etc/udev/rules.d/pps-sources.rules` starting with this example (which works with Raspberry Pi OS), which you will need to modify to your configuration: 301 | 302 | ``` 303 | KERNEL=="pps0", OWNER="root", GROUP="_chrony", MODE="0660" 304 | KERNEL=="ttyS0", RUN+="/bin/setserial -v /dev/%k low_latency irq 4" 305 | ``` 306 | 307 | To activate those new `udev` rules, either reboot, or use: `sudo udevadm control --reload-rules && sudo udevadm trigger`. 308 | 309 | This assumes `_chrony` being the user/group of the `chronyd` process (check with `ps aux | grep chronyd`) and a serial connection (here `/dev/ttyS0`) being used.[^1] 310 | 311 | ### Installation of `chrony` 312 | 313 | Install `chrony`, an alternative NTP server that in my experience results in higher precision time servers than good old ntpd. 314 | 315 | Make sure that no other time server (`ntdp`, `systemd-timedated`) is active, e.g. 316 | 317 | ``` 318 | sudo systemctl disable systemd-timedated 319 | sudo systemctl stop systemd-timedated 320 | ``` 321 | 322 | Depending on you distri and chrony versione, the config is either `/etc/chrony/chrony.conf` or `/etc/chrony.conf`, edit the file and add two lines: 323 | 324 | ``` 325 | refclock PPS /dev/pps0 lock GPS 326 | refclock SHM 0 refid GPS precision 1e-1 offset 0.01 delay 0.2 noselect 327 | ``` 328 | 329 | > **Note:** For recent `chrony` versions, an offset of `0.0` seems to prevent GPS sync, hence set it to `offset 0.01` (or any small, non-zero value) for start. See below, how to get the actual correct `offset` value. 330 | 331 | This uses a shared memory device `SHM` to get unprecise time information from GPSD (low precision, marked as `noselect`, so that chrony doesn't try to sync to serial time data). This unprecise time information is then synchronised with the much more precise PPS signal. 332 | 333 | #### Additional Raspberry Pi 5 features 334 | 335 | Verify that `chrony.conf` contains the following line (standard in Raspberry Pi OS version): 336 | 337 | ``` 338 | # This directive enables kernel synchronisation (every 11 minutes) of the 339 | # real-time clock. Note that it can't be used along with the 'rtcfile' directive. 340 | rtcsync 341 | ``` 342 | 343 | If you want to transmit PTP hardware timestamps via ethernet, add: 344 | 345 | ``` 346 | hwtimestamp * 347 | ``` 348 | 349 | See below for some more details on PTP. 350 | 351 | #### All version, continued 352 | 353 | Now enable and start `chrony`: 354 | 355 | ``` 356 | # Note: Some distributions use `chronyd` instead of `chrony`, so replace, if necessary: 357 | sudo systemctl enable chrony 358 | sudo systemctl start chrony 359 | ``` 360 | 361 | Verify that `chrony` started ok with `sudo systemctl status chronyd`. One possible error is a fatal message that `chronyd has been compiled without PPS support`. If that's the case (e.g. Manjaro ARM 64bit), you either need to [compile chrony yourself](https://chrony.tuxfamily.org/doc/2.4/installation.html), [mirror](https://github.com/mlichvar/chrony/blob/master/doc/installation.adoc), or switch to another distri. 362 | 363 | Then start the chrony console with `chronyc`. At the `chronyc>` prompt, enter: 364 | 365 | `sources` 366 | 367 | ``` 368 | chronyc> sources 369 | 210 Number of sources = 6 370 | MS Name/IP address Stratum Poll Reach LastRx Last sample 371 | =============================================================================== 372 | #* PPS0 0 4 377 13 -138ns[ -108ns] +/- 120ns 373 | #? GPS 0 4 377 13 -4624us[-4624us] +/- 2148us 374 | ^- sismox.com 3 10 377 15 -179us[ -179us] +/- 81ms 375 | ^- ntp.fra.de.as206479.net 2 10 377 749 +4235us[+4238us] +/- 16ms 376 | ^- mail.trexler.at 2 10 377 1114 +1190us[+1193us] +/- 13ms 377 | ^- ntp2.hetzner.de 2 10 377 59 -411us[ -411us] +/- 32ms 378 | chronyc> 379 | ``` 380 | 381 |
382 |
383 | Before PPS lock, stratum 3 384 |
385 | 386 | If all went well, you should see a `PPS` device marked with `#*`, indicating the active time source. Error should be in nanosecond range (`-138ns[ -108ns] +/- 120ns`). `#?` indicates an unusable time source, `^-` a usable but unused source. 387 | 388 | ### Synchronizing the offset between serial time information and PPS 389 | 390 | The PPS signal is only used, if the divergence between the slow serial/USB time information and the precise PPS signal is less than 200ms. If your PPS device stays on `#?` (unusable), and you are sure that gpsd is receiving valid GPS signals and `ppstest` shows a correct PPS signal, then we need to tune the offset between serial time information and PPS. 391 | 392 | In `chronyc`, enter `sourcestats` 393 | 394 | ``` 395 | chronyc> sourcestats 396 | 210 Number of sources = 9 397 | Name/IP Address NP NR Span Frequency Freq Skew Offset Std Dev 398 | ============================================================================== 399 | ... 400 | GPS 40 22 626 -4.310 9.467 512ms 2474us 401 | ... 402 | ``` 403 | 404 | the important field is `offset` of `GPS`: if that is larger 200ms, PPS cannot sync to GPS. 405 | 406 | Wait a few minutes for the offset to stabilize, note it's value, and edit `/etc/chrony.conf`, and change the offset in the second line with the value above, converted to seconds (512ms are 0.512 sec in this case): 407 | 408 | ``` 409 | refclock PPS /dev/pps0 lock GPS 410 | refclock SHM 0 refid GPS precision 1e-1 offset 0.512 delay 0.2 noselect 411 | ``` 412 | 413 |
414 |
415 | After PPS lock, stratum 1 416 |
417 | 418 | > **Note:** A value of `offset 0.0` seems to prevent synchronisation for some versions of chrony, so use some small non-zero value instead, if a value close to 0 is required for your installation. 419 | 420 | Restart `chrony` with `sudo systemctl restart chronyd`, and check `chronyc` again: 421 | 422 | After some time the output of `sourcestats` should show a much better offset for GPS: 423 | 424 | ``` 425 | chronyc> sourcestats 426 | 210 Number of sources = 9 427 | Name/IP Address NP NR Span Frequency Freq Skew Offset Std Dev 428 | ============================================================================== 429 | PPS0 12 5 179 +0.000 0.003 +1ns 102ns 430 | GPS 40 22 626 -4.310 9.467 -8600us 2474us 431 | ``` 432 | 433 | and `sources` should now show PPS as active, marked with `#*`. 434 | 435 | Use `chronyc` and command `tracking` to get information about the precision of your new time server: 436 | 437 | ``` 438 | chronyc> tracking 439 | Reference ID : 50505330 (PPS0) 440 | Stratum : 1 441 | Ref time (UTC) : Tue Dec 01 15:17:22 2020 442 | System time : 0.000000032 seconds slow of NTP time 443 | Last offset : -0.000000059 seconds 444 | RMS offset : 0.000000103 seconds 445 | Frequency : 2.167 ppm fast 446 | Residual freq : -0.000 ppm 447 | Skew : 0.005 ppm 448 | Root delay : 0.000000001 seconds 449 | Root dispersion : 0.000025110 seconds 450 | Update interval : 16.0 seconds 451 | Leap status : Normal 452 | ``` 453 | 454 | * For more information, check the [chrony PPS documentation](https://chrony.tuxfamily.org/faq.html#_using_a_pps_reference_clock), [mirror](https://github.com/mlichvar/chrony/blob/master/doc/faq.adoc#using-pps-refclock) 455 | 456 | ### Making you new precision time server available on the network 457 | 458 | By default, chrony allows only local access, use `allow` in `/etc/chrony.conf` to allow access in your local network, e.g.: 459 | 460 | ``` 461 | allow 192.168/16 462 | ``` 463 | 464 | To be able to administer the chrony time server over the net, add: 465 | 466 | ``` 467 | cmdallow 192.168/16 468 | ``` 469 | 470 | ### Remote testing 471 | 472 | The tool `sntp` (available by default on macOS, part of the `ntp` package for most linux distributions) 473 | allows for simple remote testing: 474 | 475 | > **Note:** when installing `ntp` for getting access to the `sntp` tool, make sure that you do not accidentally activate the `ntp` server which will conflict with your chrony installation. 476 | 477 | ```bash 478 | sntp 479 | ``` 480 | 481 | yields (mac connected via WLAN): 482 | ```bash 483 | sntp chronotron 484 | # Output: 485 | +0.011341 +/- 0.002986 chronotron 192.168.178.3 486 | ``` 487 | 488 | More information is available with `sntp -d` 489 | 490 | ```bash 491 | sntp -d chronotron 492 | # Output: 493 | sntp_exchange { 494 | result: 0 (Success) 495 | header: 24 (li:0 vn:4 mode:4) 496 | stratum: 01 (1) 497 | poll: 00 (1) 498 | precision: FFFFFFE8 (5.960464e-08) 499 | delay: 0000.0001 (0.000015259) 500 | dispersion: 0000.000B (0.000167847) 501 | ref: 50505300 ("PPS ") 502 | ... 503 | } 504 | ``` 505 | 506 | Interesting information is for example: 507 | 508 | - `ref: xx.. ("PPS")` 509 | - `precision: (5.9e-08)` 510 | 511 | ### Raspberry Pi 5 and the PTP precision time protocol via ethernet [OPTIONAL for SPECIAL SETTINGS] 512 | 513 | > **Note:** The PTP precision time protocol (IEEE 1588) is of limited use for home networks, it requires that every component (server, network switch, client) have hardware support for IEEE 1588 which is not the case for most consumer hardware. You cannot use PTP if a single component does not support it. 514 | 515 | An excellent overview over PTP, it's relation to NTP can be found at Redhat's [_combining ptp and ntp_](https://www.redhat.com/en/blog/combining-ptp-ntp-get-best-both-worlds) article. 516 | If you are not in a professional lab environment, _you might not need PTP at all!_ 517 | 518 | #### Preparations 519 | 520 | You can verify that hardware timestamping is active by executing `sudo systemctl status chrony` right after start of chrony. You should see something like: 521 | 522 | ``` 523 | Dec 20 15:17:15 chronotron chronyd[9525]: chronyd version 4.3 starting (+CMDMON +NTP +REFCLOCK +RTC +PRIVDROP +SCFILTER +SIGND +ASYNCDNS +NTS +SECHASH +IPV6 -DEBUG) 524 | Dec 20 15:17:15 chronotron chronyd[9525]: Enabled HW timestamping on eth0 525 | ``` 526 | 527 | To verify if a chrony client or server can support the PTP protocol, use: 528 | 529 | ```bash 530 | ethtool -T eth0 531 | ``` 532 | 533 | You should see something like: 534 | 535 | ``` 536 | Time stamping parameters for eth0: 537 | Capabilities: 538 | hardware-transmit 539 | software-transmit 540 | hardware-receive 541 | software-receive 542 | software-system-clock 543 | hardware-raw-clock 544 | PTP Hardware Clock: 0 545 | Hardware Transmit Timestamp Modes: 546 | off 547 | on 548 | onestep-sync 549 | Hardware Receive Filter Modes: 550 | none 551 | all 552 | ``` 553 | 554 | Required are the `hardware-transmit` and `hardware-receive` options. 555 | 556 | #### PTP software 557 | 558 | Make sure the kernel module `ptp` is loaded (`sudo modprobe ptp`). In addition, you will need `linuxptp` on both server and client. On the 559 | raspberry, it can be installed with `sudo apt install linuxptp` 560 | 561 | On the raspberry server: 562 | 563 | ``` 564 | sudo ptp4l -i eth0 ptp0 -m -2 565 | ``` 566 | 567 | The ptp server should start and output something like: 568 | 569 | ``` 570 | ptp4l[6491.320]: selected /dev/ptp0 as PTP clock 571 | ptp4l[6491.364]: port 1: INITIALIZING to LISTENING on INIT_COMPLETE 572 | ptp4l[6491.364]: port 0: INITIALIZING to LISTENING on INIT_COMPLETE 573 | ptp4l[6498.761]: port 1: LISTENING to MASTER on ANNOUNCE_RECEIPT_TIMEOUT_EXPIRES 574 | ptp4l[6498.761]: selected local clock d83add.fffe.b1d67a as best master 575 | ptp4l[6498.761]: port 1: assuming the grand master role 576 | ``` 577 | 578 | Now, on a client that has a network card with IEEE 1588 support (check with `ethtool -T eth0` that hardware-transmit is supported) and is connected either directly or via a PTP-IEEE 1588 enabled switch (hint: most consumer network switch _do not support the required protocol_!): 579 | 580 | again make sure module `ptp` is loaded, install `linuxptp` and: 581 | 582 | ``` 583 | sudo ptp4l -i enp86s0 -m -2 -s 584 | ``` 585 | 586 | the `-s` option enables slave mode for the client. 587 | 588 | If it works, you should see something like: 589 | 590 | ``` 591 | ptp4l[82232.615]: selected /dev/ptp0 as PTP clock 592 | ptp4l[82232.689]: port 1 (enp86s0): INITIALIZING to LISTENING on INIT_COMPLETE 593 | ptp4l[82232.689]: port 0 (/var/run/ptp4l): INITIALIZING to LISTENING on INIT_COMPLETE 594 | ptp4l[82232.689]: port 0 (/var/run/ptp4lro): INITIALIZING to LISTENING on INIT_COMPLETE 595 | ptp4l[82233.617]: port 1 (enp86s0): new foreign master d83add.fffe.b1d67a-1 596 | ptp4l[82237.617]: selected best master clock d83add.fffe.b1d67a 597 | ptp4l[82237.617]: port 1 (enp86s0): LISTENING to UNCALIBRATED on RS_SLAVE 598 | ptp4l[82239.619]: master offset 59823 s0 freq -51831 path delay 4413 599 | ptp4l[82240.618]: master offset 59749 s1 freq -2154985 path delay 4159 600 | ptp4l[82241.618]: master offset 59733 s2 freq +91748 path delay 4159 601 | ``` 602 | 603 | If you get any `received SYNC without timestamp` or similar with `DELAY`, then somewhere in your transmission chain there is non-IEEE 1588 hw, and the sync doesn't work. 604 | 605 | ### Further optimizations in `chrony.conf` 606 | 607 | * `lock_all` to make sure chrony is always in memory. 608 | * `local stratum 1` to signal precision time. 609 | * `allow` without any address simply allows all networks. (Including external access, if your machine is connected to the internet!) 610 | 611 | For more, check the [official chrony documenation](https://chrony.tuxfamily.org/doc/4.0/chrony.conf.html), [mirror: chrony.conf](https://github.com/mlichvar/chrony/blob/master/doc/chrony.conf.adoc). 612 | 613 | ## Add a 4x20 hardware LCD display 614 | 615 | 616 | 617 | Optionally, you can use [this sub-project to a status display to the Raspberry Pi](src). 618 | 619 | ## History 620 | 621 | - 2024-04-30: Changes to Raspberry Pi 5 serial Port configuration, see [6](https://github.com/domschl/RaspberryNtpServer/issues/6), thanks @aGGreSSiv for figuring out the issue! 622 | - 2024-03-06: Update: location of `/boot/config.txt` has changed to `/boot/firmware/config.txt`. Thanks to @Nebulosa-Cat for the information! 623 | - 2023-12-21: Cleanup for the display software, display current stratum level (thanks @Lefuneste83, see [#7](https://github.com/domschl/RaspberryNtpServer/issues/7)), implemented logging. 624 | - 2023-12-20: Raspberry 5 and suport for RTC and PTP precision time protocol how-tos. 625 | - 2023-07-11: Documentation fixes (thx. @glenne), see [#4](https://github.com/domschl/RaspberryNtpServer/issues/4) for details. Mirror links for `chrony` added. 626 | - 2023-02-24: Added information by @cvonderstein on initial `offset 0.01` and `udev` rules, see [#1](https://github.com/domschl/RaspberryNtpServer/issues/1) for details. 627 | - 2023-01-09: Code for 4x20 LCD display for NTP server PPS state added. 628 | 629 | ### References 630 | 631 | - [`chrony` documentation (mirror)](https://github.com/mlichvar/chrony/tree/master/doc) 632 | 633 | [^1]: Thanks @cvonderstein for providing this information, see [#1](https://github.com/domschl/RaspberryNtpServer/issues/1) for further discussion. 634 | -------------------------------------------------------------------------------- /images/antenna.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domschl/RaspberryNtpServer/9a47ee287c06b0c2502e7edfb399a81ae6903a5e/images/antenna.jpg -------------------------------------------------------------------------------- /images/gps-wiring.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domschl/RaspberryNtpServer/9a47ee287c06b0c2502e7edfb399a81ae6903a5e/images/gps-wiring.png -------------------------------------------------------------------------------- /images/gps-with-pps-and-i2c-lcd.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domschl/RaspberryNtpServer/9a47ee287c06b0c2502e7edfb399a81ae6903a5e/images/gps-with-pps-and-i2c-lcd.jpg -------------------------------------------------------------------------------- /images/gps-with-pps.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domschl/RaspberryNtpServer/9a47ee287c06b0c2502e7edfb399a81ae6903a5e/images/gps-with-pps.jpg -------------------------------------------------------------------------------- /images/ntp-lcd-notes.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domschl/RaspberryNtpServer/9a47ee287c06b0c2502e7edfb399a81ae6903a5e/images/ntp-lcd-notes.jpg -------------------------------------------------------------------------------- /images/ntp-lcd-slow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domschl/RaspberryNtpServer/9a47ee287c06b0c2502e7edfb399a81ae6903a5e/images/ntp-lcd-slow.jpg -------------------------------------------------------------------------------- /images/ntp-lcd.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domschl/RaspberryNtpServer/9a47ee287c06b0c2502e7edfb399a81ae6903a5e/images/ntp-lcd.jpg -------------------------------------------------------------------------------- /resources/gps.fzz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domschl/RaspberryNtpServer/9a47ee287c06b0c2502e7edfb399a81ae6903a5e/resources/gps.fzz -------------------------------------------------------------------------------- /resources/gps_i2c.fzz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domschl/RaspberryNtpServer/9a47ee287c06b0c2502e7edfb399a81ae6903a5e/resources/gps_i2c.fzz -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # Simple 4x20 LCD display for Raspberry Pi NTP Server 2 | 3 | The display shows current NTP time and date, the PPS signal locking state, Stratum level, the offset to the NTP time, the number of satellites that are currently used, and, if no PPS lock is available, alternatively the address of the time server used. 4 | 5 | If you see an address of some time server, PPS lock is not (yet) working. 6 | 7 | Note: the main script `chronotron.py` defines a `start_time` and an `end_time`, used to switch on the LCD backlight (at `start_time`) and switch it off at (`end_time`). If you want permanent backlight, simply set `start_time` and `end_time` to `None`. 8 | 9 | ## Hardware requirements 10 | 11 | - Display: HD44780 2004 LCD Display 4x20 characters with I2C interface (address 0x27 is assumed by the code), use for example: 12 | - Adafruit [LCD 4x20](https://www.adafruit.com/product/198) and [I2C Adapter](https://www.adafruit.com/product/292), description: (https://learn.adafruit.com/i2c-spi-lcd-backpack). Advantage: Stemma QT adapter (QWIIC) that can be also added to [Raspberry PI](https://www.adafruit.com/product/4463#:~:text=The%20SparkFun%20Qwiic%20or%20Stemma%20QT%20SHIM%20for,QT%20or%20Qwiic%29%20connector%20to%20your%20Raspberry%20Pi.) for clean cabling. 13 | - Amazon kit [sunfounder kit](https://www.amazon.com/SunFounder-Serial-Module-Arduino-Mega2560/dp/B01GPUMP9C) 14 | - AliExpress kit [TZT Five Star](https://de.aliexpress.com/item/1005001679675215.html 15 | 16 | Make sure that I2C is enable on your Raspberry PI. 17 | 18 | 19 | 20 | Connect the LCD Display the GND, 5V and SDA and SCL pins or the Raspberry PI. 21 | 22 | > **Note:** Raspberry PI uses 3.3V logic, and the LCD Display is connected to 5V. This _should_ not be a problem, since the lcd just receives from Raspberry PI. If you want to be on the safe side, either try to power the LCD with **3.3V**, or use a **logic-level-converter**. 23 | 24 | > **Note:** if your display uses a different address than `0x27`, 25 | or if you are using a very old Raspberry that still uses `sm_bus=0`, you need to adapt: 26 | 27 | ```python 28 | lcd = LcdDisplay(sm_bus=1, i2c_addr=0x27, cols=20, rows=4) 29 | ``` 30 | 31 | Old Raspis use `sm_bus=0`. 32 | 33 | > **Note:** if the LCD screen looks inverted or too faint, or no output is visible at all, use the potentiometer on the adapter-board to adjust the contrast. 34 | 35 | ## Software requirements 36 | 37 | > **Note:** Standard Raspberry Pi OS (tested with 'Bookworm') is recommended and used below 38 | 39 | This project uses Martijn Braam's python gpsd driver (minimum version `0.3.0`, and `smbus` (already available on some distributions). 40 | 41 | With recent python and Raspberry Pi OS updates, installation became a bit more complicated, since it is no longer easily allowed to install PIP modules into the root context. 42 | There are two solutions 43 | 44 | ### Prep variant 1 - Slightly improper shortcut `--break-system-packages` 45 | 46 | > **Note:** there is a proper way to do things, see next chapter using `venv` 47 | 48 | If you want to avoid working with Python virtual environments (e.g because the Raspberry is simply a single-use time server), you can simply force installation of gpsd-py3 into root context, which is generally considered bad practice, but in our case of limited risk, since gpsd-py3 has no further dependencies and is not part of any package manager. 49 | 50 | Install with: 51 | 52 | ```bash 53 | # should be already installed: 54 | sudo apt install python3-smbus 55 | # You need at least version 0.3.0 of gpsd-py3, otherwise number of satellites is not supported 56 | sudo pip install --break-system-packages gpsd-py3 57 | ``` 58 | 59 | ### Prep variant 2 - Use proper Python virtual environments for installations, avoiding `--break-system-packages` 60 | 61 | The proper way to install is to create a virtual environment for chronotron: 62 | 63 | If `chronotron` does not start, check with `sudo systemctl status chronotron`: It will show if it can't 64 | import a required packages. 65 | 66 | ```bash 67 | cd /opt 68 | # create a virtual env at /opt/chronotron 69 | sudo python -m venv chronotron 70 | # make the directory tree /opt/chronotron accessible for your current user 71 | chown -R $USER:$USER chronotron 72 | cd chronotron 73 | # activate the virtual environment, which allows to install pip modules into it. 74 | source bin/activate 75 | # No root! 76 | pip install smbus gpsd-py3 77 | ``` 78 | 79 | Now, while the venv is active, you have access to the packages installed within it. You can deactivate a venv with `deactivate` and re-activate it again with `source bin/activate` while being in the `/opt/chronotron` directory. 80 | 81 | When using systemd use the `chronotron_venv.service`, (rename to `chronotron.service`). This uses the python version of the chronotron venv we just created. 82 | 83 | ## Installation of chronotron software 84 | 85 | 0. Clone the repository 86 | 87 | ```bash 88 | git clone https://github.com/domschl/RaspberryNtpServer 89 | cd RaspberryNtpServer/src 90 | ``` 91 | 92 | 2. Copy the python files to `/opt/chronotron`: 93 | 94 | ```bash 95 | # Skip directory creation, if you have already created a chronotron venv 96 | mkdir /opt/chronotron 97 | chown -R $USER:$USER /opt/chronotron 98 | # Now copy: 99 | cp button.py chronotron.py i2c_lcd.py /opt/chronotron 100 | ``` 101 | 102 | 2. Install the systemd service 103 | 104 | - If you did not use a python virtual environment, use `chronotron.service`: 105 | 106 | ```bash 107 | # no venv 108 | sudo cp chronotron.service /etc/systemd/system 109 | ``` 110 | 111 | - If you used a virtual environment, use `chronotron_venv.service` and rename it: 112 | 113 | ```bash 114 | # with chronotron venv 115 | sudo cp chronotron_venv.service /etc/systemd/system/chronotron.service 116 | ``` 117 | 118 | While the venv is active, check with `which python` that the path to the venv's python matches the configuration of your systemd file, the line `ExecStart=/opt/chronotron/bin/python /opt/chronotron/chronotron.py` should use the python from within the venv, which automatically activates the venv when the service is started. 119 | 120 | 3. Both variants 121 | 122 | Enable the systemd server `chronotron` with: 123 | 124 | ```bash 125 | sudo systemctl enable chronotron 126 | sudo systemctl start chronotron 127 | ``` 128 | 129 | Check, if everthing works: 130 | 131 | ```bash 132 | sudo systemctl status chronotron 133 | ``` 134 | 135 | If everything worked, the output should be something like: 136 | 137 | ``` 138 | chronotron.service - Display chrony statistics on 4x20 LCD 139 | Loaded: loaded (/etc/systemd/system/chronotron.service; enabled; preset: disabled) 140 | Active: active (running) since Tue 2022-10-25 14:44:17 CEST; 2 months 15 days ago 141 | Main PID: 370 (chronotron.py) 142 | Tasks: 2 (limit: 3921) 143 | CPU: 3d 5h 34min 24.354s 144 | CGroup: /system.slice/chronotron.service 145 | └─370 /usr/bin/python /opt/chronotron/chronotron.py 146 | 147 | Dec 21 09:38:37 chronotron systemd[1]: Started chronotron.service - Display chrony statistics on 4x20 LCD. 148 | Dec 21 09:38:37 chronotron chronotron.py[2628]: INFO:Chronotron:Chronotron version 2.0.0 starting 149 | Dec 21 09:38:37 chronotron chronotron.py[2628]: INFO:Chronotron:Chrony aquired lock to time source 150 | Dec 21 09:38:37 chronotron chronotron.py[2628]: INFO:Chronotron:Chrony receiving time source from PPS 151 | Dec 21 09:38:37 chronotron chronotron.py[2628]: INFO:Chronotron:Chrony stratum level changed to 1 152 | Dec 21 09:38:37 chronotron chronotron.py[2628]: INFO:Chronotron:Chrony locked to high precision GPS PPS signal 153 | ``` 154 | 155 | Note: if you are using a chronotron virtual python environment (venv) it should look like this: 156 | 157 | ``` 158 | CGroup: /system.slice/chronotron.service 159 | └─8197 /opt/chronotron/bin/python /opt/chronotron/chronotron.py 160 | ``` 161 | 162 | Notice the path for python: in case of venv, we use the venv's python version. 163 | 164 | ## Notes on the display-information 165 | 166 | 167 | 168 | 1. Time and date according to NTP 169 | 2. **New:** The current stratum level is displayed as `S[1]` for stratum level 1. 170 | 3. Shows the output of `chronyc tracking`, entry `system time`, the time difference to the NTP reference (see below for further information). 171 | 4. `L[ ]` no lock, `L[*]` lock. A lock (`*`) indicates that time synchronisation is established, either via remote NTP servers or GPS + PPS 172 | 5. `PPS` signales that the lock is active using GPS and PPS, the server is in high-precision stratum 1 mode. If instead a hostname is displayed, then PPS is NOT active, and the network is used for time synchronisation, resulting in lower precision. 173 | 6. `SAT[nn]`, `nn` is the number of satellites that are actively used for time synchronisation. 174 | 7. Shows output of `chronyc sources`, the last column, "adjusted offset", of the currently active source, which is the estimated error (see below for further information) 175 | 176 | ### Further information and references 177 | 178 | #### `chronyc tracking` 179 | 180 | Marked with >>>nnnn.nnnn<<< is the information displayed at (2). 181 | Here, the system is 0.000000100 seconds faster than NTP ref. 182 | 183 | ``` 184 | chronyc tracking 185 | # sample output: 186 | Reference ID : 50505300 (PPS) 187 | Stratum : 1 188 | Ref time (UTC) : Tue Jul 11 07:22:07 2023 189 | System time : >>>0.000000100<<< seconds fast of NTP time 190 | Last offset : +0.000000030 seconds 191 | RMS offset : 0.000010080 seconds 192 | Frequency : 10.392 ppm fast 193 | Residual freq : -0.006 ppm 194 | Skew : 0.005 ppm 195 | Root delay : 0.000000001 seconds 196 | Root dispersion : 0.000384213 seconds 197 | Update interval : 16.0 seconds 198 | Leap status : Normal 199 | ``` 200 | 201 | #### `chronyc sources` 202 | 203 | Marked with `>>>nnn<<<` is the information displayed at (6). 204 | Here the estimated error of the PPS signal is 591ns 205 | 206 | ``` 207 | chronyc sources -v 208 | # sample output with explanation (-v paramenter): 209 | .-- Source mode '^' = server, '=' = peer, '#' = local clock. 210 | / .- Source state '*' = current best, '+' = combined, '-' = not combined, 211 | | / 'x' = may be in error, '~' = too variable, '?' = unusable. 212 | || .- xxxx [ yyyy ] +/- zzzz 213 | || Reachability register (octal) -. | xxxx = adjusted offset, 214 | || Log2(Polling interval) --. | | yyyy = measured offset, 215 | || \ | | zzzz = estimated error. 216 | || | | \ 217 | MS Name/IP address Stratum Poll Reach LastRx Last sample 218 | =============================================================================== 219 | #? GPS 0 4 0 780 +43ms[ +43ms] +/- 200ms 220 | #* PPS 0 4 0 782 +186ns[ +217ns] +/- >>>591ns<<< 221 | ^? 2003:2:2:140:194:25:134:> 2 10 377 340 +141us[ +141us] +/- 17ms 222 | ^? time.ontobi.com 2 10 377 264 +139us[ +139us] +/- 13ms 223 | ^? bblock.dev 2 10 377 631 +209us[ +209us] +/- 12ms 224 | ^? time01.nevondo.com 2 10 377 721 +181us[ +181us] +/- 28ms 225 | ^? static.33.250.47.78.clie> 3 10 377 1168 -191us[ -213us] +/- 15ms``` 226 | ``` 227 | 228 | #### References 229 | 230 | - 231 | - [chronyc documentation](https://github.com/mlichvar/chrony/blob/master/doc/chronyc.adoc) 232 | 233 | ## Notes: 234 | 235 | The `chronotron.py` systemd service checks periodically `chronyc` for NTP statistics (`chronyc` must be in the path!), and uses `gpsd` for the GPS statistics (number of satellites). 236 | 237 | - `button.py` is currently not used. 238 | - `i2c_lcd.py` is a buffered driver for the LCD display. 239 | - `chronotron.service` is the systemd service file. 240 | 241 | 242 | -------------------------------------------------------------------------------- /src/button.py: -------------------------------------------------------------------------------- 1 | import RPi.GPIO as gp 2 | import time 3 | 4 | 5 | class Button: 6 | def __init__(self, button_list, verbose=False): 7 | """array of [(pin, name),..]""" 8 | self.verbose = verbose 9 | self.button_list = button_list 10 | self.button_pins = [button[0] for button in button_list] 11 | gp.setmode(gp.BCM) 12 | gp.setup(self.button_pins, gp.IN, pull_up_down=gp.PUD_UP) 13 | for button in self.button_list: 14 | gp.add_event_detect( 15 | button[0], gp.FALLING, callback=self.button_pressed, bouncetime=250 16 | ) 17 | 18 | def button_pressed(self, pin): 19 | for button in self.button_list: 20 | if pin == button[0]: 21 | if self.verbose is True: 22 | print(f"{button[1]} at pin {button[0]} pressed!") 23 | button[2]() 24 | 25 | def cleanup(self): 26 | for button in self.button_list: 27 | gp.remove_event_detect(button[0]) 28 | 29 | 30 | if __name__ == "__main__": 31 | 32 | def button_blue(): 33 | print("The blue func") 34 | 35 | def button_black(): 36 | print("The black func") 37 | 38 | bt = Button([(27, "blue", button_blue), (22, "black", button_black)], True) 39 | 40 | while True: 41 | time.sleep(0.1) 42 | -------------------------------------------------------------------------------- /src/chronotron.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import time 4 | from datetime import datetime 5 | import subprocess 6 | import gpsd # from gpsd-py3 7 | import logging 8 | 9 | from i2c_lcd import LcdDisplay 10 | 11 | # from button import Button 12 | 13 | 14 | def is_current_time_in_interval(start_time_str, end_time_str): 15 | # Get the current local time 16 | current_time = datetime.now().time() 17 | # Parse start and end times from strings 18 | start_time = datetime.strptime(start_time_str, "%H:%M").time() 19 | end_time = datetime.strptime(end_time_str, "%H:%M").time() 20 | # Check if the interval spans across midnight 21 | if start_time > end_time: 22 | return current_time >= start_time or current_time < end_time 23 | else: 24 | return start_time <= current_time <= end_time 25 | 26 | 27 | def exec_cmd(cmd): 28 | ret = [] 29 | p = subprocess.Popen( 30 | cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=-1 31 | ) 32 | for line in p.stdout: 33 | ret.append(line.decode("utf-8").strip()) 34 | p.wait() 35 | if p.returncode != 0: 36 | cm = "" 37 | for c in cmd: 38 | cm = cm + c + " " 39 | print("Warning: " + cm + "failed: " + str(p.returncode)) 40 | return ret 41 | 42 | 43 | def get_statistics(log, host="localhost"): 44 | # Get number of active satellites from gpsd 45 | n = 0 46 | stats = {} 47 | try: 48 | gpsd.connect(host) 49 | n = gpsd.get_current().sats_valid 50 | stats["sats"] = n 51 | except: 52 | stats["sats"] = None 53 | pass 54 | 55 | # Get chrony tracking information 56 | cmd = ["chronyc", "tracking"] 57 | ret = exec_cmd(cmd) 58 | stats["stratum"] = None 59 | stats["system_time_offset"] = None 60 | for line in ret: 61 | pars = line.split(":", 1) 62 | if len(pars) == 2: 63 | parm = pars[0].strip() 64 | if parm == "System time": 65 | sub_pars = pars[1].strip().split(" ") 66 | if len(sub_pars) > 2: 67 | val = float(sub_pars[0]) 68 | if "slow" == sub_pars[2]: 69 | val = -1.0 * val 70 | stats["system_time_offset"] = val 71 | elif parm == "Stratum": 72 | try: 73 | n = int(pars[1]) 74 | stats["stratum"] = n 75 | except: 76 | stats["stratum"] = None 77 | 78 | # Get current time source 79 | cmd = ["chronyc", "sources"] 80 | ret = exec_cmd(cmd) 81 | stats["is_locked"] = False 82 | stats["is_pps"] = False 83 | stats["source"] = None 84 | stats["adjusted_offset"] = None 85 | for line in ret: 86 | if "#* PPS" in line: 87 | stats["is_locked"] = True 88 | stats["is_pps"] = True 89 | stats["source"] = "PPS" 90 | # #* PPS0 0 4 377 22 +271ns[ +385ns] + 91 | try: 92 | stats["adjusted_offset"] = line[50:59].strip() 93 | except: 94 | pass 95 | else: 96 | if "^*" in line: 97 | stats["is_locked"] = True 98 | stats["is_pps"] = False 99 | try: 100 | stats["source"] = line[3:33].strip() 101 | except: 102 | pass 103 | try: 104 | stats["adjusted_offset"] = line[50:59].strip() 105 | except: 106 | pass 107 | 108 | return stats 109 | 110 | 111 | def main_loop(): 112 | last_time = "" 113 | last_offset = "" 114 | select_state = 0 115 | select_states = 2 116 | trigger_time = 0 117 | main_state = 0 118 | main_states = 2 119 | old_lock = False 120 | old_pps = False 121 | old_stratum = None 122 | old_src = None 123 | 124 | version = "2.0.0" 125 | 126 | # Time interval for backlight, set to None for permanent backlight: 127 | start_time = "07:00" 128 | end_time = "21:00" 129 | 130 | logging.basicConfig(level=logging.INFO) 131 | log = logging.getLogger("Chronotron") 132 | log.setLevel("INFO") 133 | log.info(f"Chronotron version {version} starting") 134 | 135 | def select_button(): 136 | nonlocal select_state 137 | nonlocal select_states 138 | nonlocal trigger_time 139 | trigger_time = time.time() 140 | select_state = (select_state + 1) % select_states 141 | 142 | def main_button(): 143 | nonlocal main_state 144 | nonlocal main_states 145 | nonlocal trigger_time 146 | trigger_time = time.time() 147 | main_state = (main_state + 1) % main_states 148 | 149 | # bt = Button([(27, "blue", select_button), (22, "black", main_button)]) 150 | 151 | lcd = LcdDisplay(sm_bus=1, i2c_addr=0x27, cols=20, rows=4) 152 | if lcd.active is False: 153 | log.error("Failed to open display, exiting...") 154 | exit(-1) 155 | 156 | while True: 157 | time_str = time.strftime("%Y-%m-%d %H:%M:%S") 158 | if time_str != last_time: 159 | if ( 160 | start_time is None 161 | or end_time is None 162 | or is_current_time_in_interval(start_time, end_time) 163 | or start_time == end_time 164 | ): 165 | lcd.set_backlight(True) 166 | else: 167 | lcd.set_backlight(False) 168 | last_time = time_str 169 | stats = get_statistics(log) 170 | 171 | if stats["is_locked"] != old_lock: 172 | old_lock = stats["is_locked"] 173 | if old_lock is True: 174 | log.info("Chrony aquired lock to time source") 175 | 176 | if old_src != stats["source"]: 177 | old_src = stats["source"] 178 | if old_src is None: 179 | src = "None" 180 | else: 181 | src = old_src 182 | log.info(f"Chrony receiving time source from {src}") 183 | 184 | if old_stratum != stats["stratum"]: 185 | old_stratum = stats["stratum"] 186 | if old_stratum is None: 187 | strat = "?" 188 | else: 189 | strat = old_stratum 190 | log.info(f"Chrony stratum level changed to {old_stratum}") 191 | 192 | if old_pps != stats["is_pps"]: 193 | old_pps = stats["is_pps"] 194 | if old_pps: 195 | log.info("Chrony locked to high precision GPS PPS signal") 196 | else: 197 | log.info("Chrony lost PPS signal") 198 | 199 | # if select_state == 0: 200 | # lcd.print_row(0, time_str) 201 | # else: 202 | # if time.time() - trigger_time > 10: 203 | # select_state = 0 204 | # lcd.print_row(0, "Select 1") 205 | 206 | offset = stats["system_time_offset"] 207 | offs = " " 208 | if offset is not None: 209 | if stats["stratum"] is None: 210 | offs = "S[?]" 211 | else: 212 | offs = f"S[{stats['stratum']}]" 213 | offs += " {:+12.9f}sec".format(offset) 214 | if offs != last_offset: 215 | last_offset = offs 216 | 217 | if stats["sats"] is None: 218 | sats = "--" 219 | else: 220 | sats = f"{stats['sats']:02}" 221 | if stats["is_locked"]: 222 | source_str = "L[*] " 223 | else: 224 | source_str = "L[ ] " 225 | if stats["source"] is None: 226 | source_str += " " 227 | else: 228 | source_str += stats["source"] 229 | if stats["adjusted_offset"] is None: 230 | dev_str = " " 231 | else: 232 | dev_str = f"{stats['adjusted_offset']:>7}" 233 | last_str = f"SATS[{sats}] {dev_str}" 234 | lcd.print_row(0, time_str) 235 | lcd.print_row(1, offs) 236 | lcd.print_row(2, source_str) 237 | lcd.print_row(3, last_str) 238 | time.sleep(0.05) 239 | 240 | 241 | main_loop() 242 | -------------------------------------------------------------------------------- /src/chronotron.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Display chrony statistics on 4x20 LCD 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | User=root 8 | Group=root 9 | WorkingDirectory=/opt/chronotron 10 | ExecStart=/opt/chronotron/chronotron.py 11 | StandardOutput=syslog 12 | StandardError=syslog 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /src/chronotron_venv.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Display chrony statistics on 4x20 LCD 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | User=root 8 | Group=root 9 | WorkingDirectory=/opt/chronotron 10 | ExecStart=/opt/chronotron/bin/python /opt/chronotron/chronotron.py 11 | StandardOutput=syslog 12 | StandardError=syslog 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /src/i2c_lcd.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | import smbus 4 | import copy 5 | 6 | 7 | class LcdDisplay: 8 | def __init__(self, sm_bus=1, i2c_addr=0x27, cols=20, rows=4): 9 | self.log = logging.getLogger("LcdDisplay") 10 | try: 11 | self.bus = smbus.SMBus(sm_bus) # Rev 1 Pi: 0, Rev 2 Pi: 1 12 | self.active = True 13 | except Exception as e: 14 | self.log.error(f"Cannot open LCD on I2C bus: {e}") 15 | self.active = False 16 | return 17 | 18 | self.i2c_addr = i2c_addr 19 | self.cols = cols 20 | self.rows = rows 21 | self.line_cmds = [0x80, 0xC0, 0x94, 0xD4] # lines 1-4 22 | 23 | self.delay = 0.00001 24 | self.cls_delay = 0.0008 25 | self.type_data = 1 26 | self.type_command = 0 27 | self.enable = 0x04 28 | self.set_backlight(True) 29 | 30 | self.write(0x33, self.type_command) # init sequence: 0x03, 0x03 31 | # time.sleep(self.delay) 32 | self.write(0x32, self.type_command) # init seq cont: 0x03, 0x02 33 | # time.sleep(self.delay) 34 | self.write(0x06, self.type_command) # cursor dir 35 | self.write(0x0C, self.type_command) # display on, cursor off, blink of 36 | self.write(0x28, self.type_command) # data len, num lines, font size 37 | self.write(0x01, self.type_command) # clear (slow!) 38 | time.sleep(self.cls_delay) 39 | 40 | self.screen_buf = [[" " for _ in range(cols)] for _ in range(rows)] 41 | self.cur_row = 0 42 | self.cur_col = 0 43 | self.log.debug("LCD display initialized.") 44 | 45 | def set_backlight(self, state): 46 | if self.active is False: 47 | return 48 | if state: 49 | self.backlight = 0x08 50 | else: 51 | self.backlight = 0x00 52 | 53 | def write(self, byte, data_type): 54 | if self.active is False: 55 | return 56 | 57 | hi_byte = data_type | (byte & 0xF0) | self.backlight 58 | lo_byte = data_type | ((byte << 4) & 0xF0) | self.backlight 59 | 60 | self.bus.write_byte(self.i2c_addr, hi_byte) 61 | time.sleep(self.delay) 62 | self.bus.write_byte(self.i2c_addr, (hi_byte | self.enable)) 63 | time.sleep(self.delay) 64 | self.bus.write_byte(self.i2c_addr, (hi_byte & ~self.enable)) 65 | time.sleep(self.delay) 66 | 67 | self.bus.write_byte(self.i2c_addr, lo_byte) 68 | time.sleep(self.delay) 69 | self.bus.write_byte(self.i2c_addr, (lo_byte | self.enable)) 70 | time.sleep(self.delay) 71 | self.bus.write_byte(self.i2c_addr, (lo_byte & ~self.enable)) 72 | time.sleep(self.delay) 73 | 74 | def write_row(self, row): 75 | if self.active is False: 76 | return 77 | ra = self.line_cmds[row] 78 | self.write(ra, self.type_command) 79 | for i in range(self.cols): 80 | self.write(ord(self.screen_buf[row][i]), self.type_data) 81 | 82 | def print_row(self, row, text): 83 | if self.active is False: 84 | return 85 | if row < 0: 86 | row = 0 87 | if row >= self.rows: 88 | row = self.rows - 1 89 | text = text[: self.cols].ljust(self.cols, " ") 90 | self.screen_buf[row] = [text[i] for i in range(len(text))] 91 | self.write_row(row) 92 | 93 | def print_at(self, row, col, text): 94 | if self.active is False: 95 | return 96 | if row < 0 or row > self.rows - 1: 97 | row = 0 98 | if col < 0 or col > self.cols - 1: 99 | col = 0 100 | text = text[: self.cols - col] 101 | for i in range(len(text)): 102 | self.screen_buf[row][i + col] = text[i] 103 | self.write_row(row) 104 | 105 | def scroll(self, n=1): 106 | if self.active is False: 107 | return 108 | for _ in range(n): 109 | for r in range(self.rows - 1): 110 | self.screen_buf[r] = copy.copy(self.screen_buf[r + 1]) 111 | for i in range(self.cols): 112 | self.screen_buf[self.rows - 1][i] = " " 113 | for i in range(self.rows): 114 | self.write_row(i) 115 | if self.cur_row > 0: 116 | self.cur_row = self.cur_row - 1 117 | 118 | def print(self, text): 119 | if self.active is False: 120 | return 121 | if len(text) + self.cur_col > self.cols: 122 | ctext = text[: self.cols - self.cur_col] 123 | rtext = text[self.cols - self.cur_col :] 124 | else: 125 | ctext = text 126 | rtext = None 127 | if self.cur_row >= self.rows: 128 | self.scroll() 129 | for i in range(len(ctext)): 130 | self.screen_buf[self.cur_row][self.cur_col + i] = ctext[i] 131 | self.cur_col = self.cur_col + len(ctext) 132 | self.write_row(self.cur_row) 133 | if self.cur_col >= self.cols: 134 | self.cur_col = 0 135 | self.cur_row = self.cur_row + 1 136 | if rtext is not None: 137 | self.print(rtext) 138 | 139 | 140 | if __name__ == "__main__": 141 | lcd = LcdDisplay(1, 0x27, 20, 4) 142 | if lcd.active is True: 143 | lcd.print( 144 | "Hello! That is a hell of a lot of text that we are going to display on this tiny screen. However there is always a way to present information in a way that is helpful, even under contrained conditions." 145 | ) 146 | exit(0) 147 | else: 148 | print("Failed to initialize display") 149 | exit(-1) 150 | --------------------------------------------------------------------------------