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