Note (🤖✨AI-Generated README): This
README.mdwas generated with AI assistance. It may contain mistakes, outdated assumptions, or wording that’s overly enthusiastic. Treat it as a starting point and verify details (pin mappings, build steps, features) against the actual code and hardware.I've gone through the README.md file a few times now and I believe I have corrected all the AI hallucinations/errors.
A GPS-backed NTP server for the Raspberry Pi Pico W (RP2040), built in C++ with the Pico SDK.
The project currently uses NMEA data from a GPS receiver on UART0 to establish UTC time, uses PPS edge timing to improve second-boundary alignment, serves NTP on UDP/123 over Pico W Wi-Fi, and presents a live ANSI status dashboard over USB CDC. The codebase also includes support for an HT16K33 4-digit I2C clock display, though that display update path is currently disabled in main.cpp.
The project is beyond the original “NMEA-only prototype” stage.
What is implemented now:
- GPS UART input on
uart0using an IRQ-driven ring buffer - Lightweight NMEA parsing for
RMC,GGA, andZDA - GPS device state machine with
Booting,Acquiring,Acquired,Locked, andError - PPS input capture by GPIO interrupt using
time_us_64()timestamps - Timebase seeding from GPS UTC and PPS-assisted second alignment
- NTP server on UDP port 123 using lwIP
- Pico W Wi-Fi station mode with configurable static IPv4 support
- ANSI single-screen dashboard over USB serial
- LED state indication driven from a repeating timer
- Internal temperature readout with EMA smoothing
- HT16K33 I2C clock display support in the tree
What is still in progress or not fully finished:
- Tight PPS discipline and long-term holdover behavior still need refinement
- The code reports synchronized state aggressively in places and should be tightened further as the timing model matures
- The HT16K33 display update function exists, but the call is commented out in the main loop, so the external clock is not presently being refreshed
- USB Ethernet / composite USB work is not part of the current active firmware path
- Raspberry Pi Pico W
- Waveshare Pico GPS L76X, or another GPS receiver that provides NMEA output
- GPS
TX-> PicoGPIO1(UART0 RX) - GPS
RX-> PicoGPIO0(UART0 TX) - GPS
GND-> PicoGND - GPS
VCC-> Pico3V3or module-appropriate supply
- GPS
PPS-> PicoGPIO16
I2C1 SDA->GPIO14I2C1 SCL->GPIO15- Default I2C address:
0x70
The project includes support for an HT16K33-based 4-digit Adafruit I2C clock display.
Adafruit 0.56" Seven-Segment Backpack Guide
https://learn.adafruit.com/adafruit-led-backpack/0-dot-56-seven-segment-backpack
The current code initializes the display like this:
ht16k33_clock_init(i2c1, 14, 15, 0x70, 100000);That means the firmware is using:
- I2C instance:
i2c1 - Pico GP14:
SDA - Pico GP15:
SCL - I2C address:
0x70 - I2C speed:
100 kHz
Connect the Adafruit display to the Pico / Pico W as follows:
| Pico Pin | Function | Adafruit Display Pin |
|---|---|---|
| GP14 | I2C1 SDA | SDA |
| GP15 | I2C1 SCL | SCL |
| 3V3(OUT) | 3.3V power | VCC |
| GND | Ground | GND |
- The code enables the Pico’s internal pull-ups on SDA and SCL.
- The display is currently configured for the default HT16K33 I2C address
0x70. If the backpack address jumpers are changed, update the address inmain.cpp. - This display path is intended for an I2C HT16K33 backpack, not a TM1637 display.
- Use a display module that is compatible with 3.3V I2C logic.
- If using a module that has 5V logic then supply the HT16K33 backpack with 5V power.
- In the current source, the clock display initialization is present, but the periodic update call may still be disabled in the main loop depending on your branch, so wiring it correctly does not necessarily mean it will update until that call is enabled.
Pico GP14 -> SDA Pico GP15 -> SCL Pico 3V3(OUT) -> VCC Pico GND -> GND
If you ever change the HT16K33 address jumpers on the Adafruit board, update this line accordingly:
ht16k33_clock_init(i2c1, 14, 15, 0x70, 100000);
^^^^ - Change this addressmain.cpp performs the following high-level flow:
- Initialize USB stdio
- Initialize temperature, uptime, timebase, and optional clock display support
- Initialize Wi-Fi and start the NTP server if Wi-Fi connects successfully
- Initialize LED state binding and repeating LED timer
- Initialize GPS UART on
uart0 - Initialize PPS capture on
GPIO16 - Enter the main loop to:
- pull NMEA lines from the UART ring buffer
- update GPS state
- feed new PPS edges into the timebase
- service the LED output
- redraw the dashboard
The dashboard is currently active. The HT16K33 display update call is present but commented out.
gps_uart.cpp implements an IRQ-driven receive path with a ring buffer and line extraction helper. The code is designed to avoid blocking the main loop while still assembling full NMEA lines.
gps_state.cpp currently handles:
RMCfor UTC time/date validity and timebase seedingGGAfor fix quality, satellites, and HDOPZDAfor a formatted UTC timestamp string shown on the dashboard
The parser intentionally ignores unrelated sentences instead of letting them affect fix state. Acquired presently requires both:
- valid
RMCstatus (A) - valid
GGAfix quality
Locked is entered only when PPS is present, recent, and approximately 1 Hz.
The current state model is:
BootingAcquiringAcquiredLockedError
In practical terms:
Acquiringmeans GPS time/fix conditions are not yet sufficientAcquiredmeans valid NMEA-derived time and fix are presentLockedmeans PPS is being detected and passes the present sanity checks
pps.cpp arms a rising-edge IRQ on the configured GPIO and records:
- total edge count
- last PPS interval in microseconds
- absolute time of the last edge in microseconds since boot
This is already live in the current code, not just planned.
timebase.cpp maintains a baseline of:
- UTC Unix seconds
- local microsecond counter at the baseline instant
It can be seeded from GPS UTC and then queried as either Unix time or NTP seconds/fraction. The current implementation also tracks PPS edges and uses fresh PPS edges to improve second-boundary alignment when GPS UTC updates arrive. When PPS steps are accepted, the baseline can advance on PPS edges as well.
This means the project is no longer purely “NMEA timestamp only,” but it is also not yet a fully mature disciplined clock in the chrony/ntpd sense.
ntp_server.cpp implements a minimal server using lwIP UDP.
Current behavior:
- listens on UDP port
123 - responds only to NTP client mode (
mode 3) requests - copies the request packet payload into a packed
NtpPacketstructure - populates receive and transmit timestamps from the internal timebase
- echoes the client transmit timestamp back into originate timestamp
- uses
"GPS\0"as the reference ID
Current stratum behavior:
1when time is available and the timebase reports synced2when time is available but not synced16when no usable time is available
Leap indicator behavior:
0when synced3when unsynchronized/alarm
This logic is already implemented and active.
wifi_cfg.cpp initializes CYW43, enables STA mode, and supports either static IPv4 or DHCP behavior. The current main.cpp path configures static addressing before connecting. The IP octets are sourced from W_IPAddress, while subnet mask, gateway, and optional DNS are assigned in code.
The existing README’s hard-coded static IP description was too specific for the present source tree unless W_IPAddress is set to that exact value in headers not shown here.
ui_console.cpp renders a single-screen ANSI dashboard refreshed every 500 ms. It currently shows:
- GPS state
- RMC validity
- GGA fix status
- satellite count
- HDOP
- UTC from ZDA
- UTC from RMC time field
- PPS detection, interval, age, and timebase PPS telemetry
- CPU temperature
- uptime
- Wi-Fi link state and IP address
- NTP server up/down state and port
This is more advanced than the earlier README indicated because PPS diagnostics are already present in the UI.
led.cpp uses a 50 ms repeating timer callback to compute desired LED state and a foreground led_service() function to apply it. Patterns differ by GPS state.
That structure keeps the timer callback short and avoids doing unnecessary heavy work in timing-sensitive paths.
The repository now contains ht16k33_clock.cpp, which provides support for a 4-digit I2C clock display using an HT16K33 backpack. It supports:
- initialization on a selected I2C instance
- brightness control
- clear / dashed fallback display
- displaying
HH:MMfrom Unix UTC time with optional UTC offset
In main.cpp, the clock is initialized, but update_clock_display() is currently commented out in the main loop, so this feature is not presently active at runtime.
main.cpp- system bring-up and main loopgps_uart.{h,cpp}- GPS UART IRQ receive path and line extractiongps_state.{h,cpp}- NMEA parsing and GPS state machinepps.{h,cpp}- PPS GPIO IRQ capture and telemetrytimebase.{h,cpp}- UTC baseline, PPS edge integration, NTP time conversionntp_server.{h,cpp}- lwIP UDP NTP serverwifi_cfg.{h,cpp}- CYW43 Wi-Fi initialization and IP configurationui_console.{h,cpp}- ANSI dashboardled.{h,cpp}- LED patterns and LED service logictemp.{h,cpp}- RP2040 internal temperature reading and smoothinght16k33_clock.{h,cpp}- optional 4-digit I2C clock display supportuptime.{h,cpp}- uptime formatting helper
- Pico SDK
- CMake
- Ninja
- ARM embedded toolchain
PICO_BOARD=pico_w
mkdir -p build
cd build
cmake -G Ninja .. -DPICO_BOARD=pico_w
ninjaPut the Pico W into BOOTSEL mode and copy the generated UF2 to the mounted mass-storage device.
This project can be opened directly as a normal CMake-based Pico SDK project in either VS Code or VSCodium.

Before opening the project, make sure the following are already installed and working on your system:
- VS Code or VSCodium
- CMake
- Ninja
- ARM GCC toolchain (
arm-none-eabi-gcc/arm-none-eabi-g++) - Pico SDK
- A working environment variable for the Pico SDK, typically:
export PICO_SDK_PATH=/path/to/pico-sdkStart VS Code or VSCodium, then open the folder that contains the project CMakeLists.txt.
From the menu:
- File -> Open Folder
- Select the root project folder
- Open it
Or from a terminal:
codium .or
code .When the folder opens, VS Code / VSCodium should detect that this is a CMake project.
If prompted:
- Allow CMake to configure the project
- Select the appropriate kit/toolchain if needed
- Make sure the target board is set for Pico W
This project is intended to build with:
-DPICO_BOARD=pico_wThese extensions are commonly useful:
- CMake Tools
- C/C++
- GitLens (optional)
- Pico extension if you are using the Raspberry Pi Pico VS Code tooling
Once the project is open and configured, you can build it from the terminal:
mkdir -p build
cd build
cmake -G Ninja .. -DPICO_BOARD=pico_w
ninjaOr use the VS Code / VSCodium CMake controls to configure and build from the UI.
If you add new .cpp files to the project, VS Code / VSCodium will not automatically add them to the build.
You must update CMakeLists.txt and add the new source file to the target source list manually.
After a successful build, the generated .uf2 file can be copied to the Pico while it is in BOOTSEL mode.
A normal workflow in VS Code / VSCodium looks like this:
- Open the project folder
- Let CMake configure
- Build the project
- Connect the Pico in BOOTSEL mode to flash new firmware
- Open a serial terminal to view USB CDC output
Example serial console:
picocom -b 115200 /dev/ttyACM0- If the project does not configure, verify
PICO_SDK_PATH - If headers are missing, confirm the Pico SDK and toolchain are installed correctly
- If new source files are not compiling, check that they were added to
CMakeLists.txt - If the board is wrong, make sure the build is using
PICO_BOARD=pico_w
The firmware expects a local wifi_secrets.h that defines at least:
#pragma once
inline constexpr const char WIFI_SSID[] = "YOUR_WIFI_SSID";
inline constexpr const char WIFI_PASSWORD[] = "YOUR_WIFI_PASSWORD";Do not commit that file.
The firmware uses USB CDC stdio. On Linux, for example:
picocom -b 115200 /dev/ttyACM0You should see the dashboard refresh at about 2 Hz.
Once Wi-Fi is up and the dashboard shows an IP address, the Pico W listens on UDP port 123.
Example quick test on Linux:
ntpdate -q <PICO_IP>Example continuous test loop:
while true; do ntpdate -q <PICO_IP>; sleep 5; doneThe code is now PPS-aware, but the project should still be described carefully.
It is reasonable to say the firmware is a GPS/PPS-backed NTP server prototype or early Stratum-1 implementation. It is less accurate to describe it as fully finished Stratum-1 timing infrastructure at this point, because the timing discipline and sync qualification logic still need additional tightening.
In particular:
- PPS capture is implemented
- PPS affects lock state and timebase behavior
- NTP stratum is driven by internal sync state
- further refinement is still needed before calling the timing model fully mature
main.cppcurrently usessleep_ms()during startup for USB enumeration convenience; that is fine at boot but should remain outside timing-sensitive callbacks- the current sync logic in
timebase.cppis still evolving and should be reviewed as PPS discipline is tightened - the current LED source file appears to contain a likely typo in the non-CYW43 path (
g_led_desiredinstead ofs_led_desired), which should be checked in the full project build context before release documentation claims that file is final fileciteturn0file3 - the clock display support exists but is not yet enabled in the main loop
- gateway and DNS values are presently hard-coded in
main.cpp
- tighten PPS discipline and sync qualification
- decide the precise meaning of
AcquiredversusLockedfor NTP stratum reporting - enable and validate the HT16K33 display path
- make network configuration more flexible
- continue toward USB Ethernet / composite USB options in future variants
This update was based on the currently uploaded source files, including README.md, main.cpp, gps_state.cpp, gps_uart.cpp, ht16k33_clock.cpp, led.cpp, ntp_server.cpp, pps.cpp, timebase.cpp, ui_console.cpp, and wifi_cfg.cpp.

