Fake GPS Dongle with NTP

technic
gnss
Author

Ansgar Schmidt

Published

May 27, 2026

A Fake GPS That Tells Perfect Time: NTP to gpsd on Linux

There is a certain kind of problem that only exists at the intersection of “I want this to work properly” and “I cannot have the thing that would normally make it work.” You need precise time on a Linux host. gpsd exists precisely to give you that, but gpsd wants a GPS receiver — an antenna, a clear view of the sky, ideally some roof penetration you do not have in a server closet. You are indoors. The satellites do not care.

The usual workaround is to configure chrony or ntpd directly against NTP pool servers and accept whatever jitter the network hands you. For many purposes that is perfectly fine. But if you want gpsd in the loop — because you have software that expects it, or because you want chrony to discipline the system clock against a hardware PPS signal — you need something that speaks gpsd’s language: NMEA sentences and a pulse per second.

This project builds that something out of an ESP8266 NodeMCU and about twenty euros worth of parts you probably already have in a drawer.

The Concept

gpsd does not actually care where its time comes from. It cares that data arriving on a serial port looks like NMEA sentences and that, optionally, a GPIO pin pulses once per second. If you can provide both, gpsd will accept you as a legitimate time source and hand the data to chrony, which uses the PPS signal to discipline the system clock with hardware precision.

The ESP8266 is a small WiFi-capable microcontroller that runs an Arduino sketch. In this sketch it:

  1. Connects to WiFi and synchronises its internal clock via NTP (pool.ntp.org, with ptbtime1.ptb.de from Germany’s national metrology institute as a fallback).
  2. Once synchronised, emits a $GPZDA and a $GPRMC NMEA sentence every second over its USB serial port at 9600 baud.
  3. Simultaneously raises GPIO14 (labelled D5 on the NodeMCU) for 100 milliseconds at the start of each UTC second — a hardware PPS signal.

The Linux host sees a USB serial device spitting out valid NMEA at one sentence per second, and a GPIO pin pulsing once per second. gpsd sees a GPS receiver. Chrony sees a disciplined time source. Everyone is happy.

The position data in the NMEA output is zeroed. The device reports itself as sitting at 0°N 0°E — Null Island, the point in the Gulf of Guinea where the prime meridian meets the equator. gpsd will flag this, and cgps will show you a location that is emphatically not where you are. That is expected. This device exists to tell time, not to navigate.

Hardware

The bill of materials is short:

  • An ESP8266 NodeMCU 1.0 (ESP-12E module)
  • A Linux host system — any SBC or PC with USB ports and a GPIO header (Raspberry Pi, BeagleBone, Orange Pi, or similar)
  • A USB-A to Micro-USB cable
  • One jumper wire

The USB cable does double duty: it powers the NodeMCU from the host’s USB port and carries the serial NMEA stream. The jumper wire runs from D5 (GPIO14) on the NodeMCU to any available GPIO pin on the host system. The example configuration below uses GPIO18.

One thing worth noting: the NodeMCU operates at 3.3 V logic. Most SBC GPIO headers also operate at 3.3 V. Verify the voltage level for your specific board before wiring — no level shifting is needed if the host GPIO is 3.3 V compatible.

ESP8266 NodeMCU          Linux host
──────────────────       ────────────────────
USB (Micro-USB)    ───── USB port           (serial + power)
D5 (GPIO14)        ───── GPIO18             (PPS signal)
GND                      shared via USB

The Sketch

The Arduino sketch lives in ntp_nmea_bridge.ino and has no external library dependencies beyond what ships with the ESP8266 Arduino core. WiFi credentials are kept in a separate credentials.h file that is excluded from version control — copy credentials.h.example, fill in your SSID and password, and never commit the result.

setup() handles WiFi association and NTP synchronisation, with hard timeouts that call ESP.restart() if either step fails. The device is self-healing after a power cut or a brief network outage. loop() manages the PPS pulse state and emits NMEA sentences at one-second intervals.

The PPS logic:

// Rising edge when the UTC second changes
if (now > 1609459200UL && now != last_pps_second) {
    last_pps_second = now;
    digitalWrite(PPS_PIN, HIGH);
    pps_start_ms = millis();
    pps_high = true;
}

// Falling edge after 100 ms
if (pps_high && (now_ms - pps_start_ms >= PPS_PULSE_MS)) {
    digitalWrite(PPS_PIN, LOW);
    pps_high = false;
}

The plausibility check (now > 1609459200UL, i.e., after 2021-01-01) prevents the pin from pulsing before NTP synchronisation is complete. The pin is also driven LOW immediately in setup() so it never floats during boot.

Debug output during startup uses a // prefix so gpsd silently discards it — only $ lines are valid NMEA:

// ESP8266 NTP -> NMEA bridge starting
// WiFi connected, IP: 192.168.1.42
// NTP sync OK: 2026-05-27 14:23:01 UTC
// --- NMEA output starting ---
$GPZDA,142301.00,27,05,2026,00,00*4F
$GPRMC,142301.00,A,0000.0000,N,00000.0000,E,0.000,0.000,270526,0.000,E*7C

Host System Setup

gpsd

After connecting the NodeMCU via USB, the device will typically appear as /dev/ttyUSB0. Edit /etc/default/gpsd:

DEVICES="/dev/ttyUSB0"
GPSD_OPTIONS="-n"
USBAUTO="true"

The -n flag tells gpsd not to wait for a client connection before activating the device — without it the device goes dormant when nothing is connected and chrony never gets data. Verify with gpsmon: gpsd will report a valid time fix, position at 0°N 0°E, zero satellites. All correct.

PPS

How you expose the PPS GPIO to the kernel depends on your target system and its device tree or kernel configuration tooling. The kernel needs to know which GPIO pin carries the PPS signal and register it as a /dev/pps0 device.

On a Raspberry Pi, for example, add the following to /boot/firmware/config.txt and reboot:

dtoverlay=pps-gpio,gpiopin=18

On other systems — BeagleBone, Orange Pi, or any board with a mainline kernel — the equivalent is typically a device tree overlay or a sysfs/configfs entry specific to that platform. Consult your board’s documentation for the correct mechanism.

Once the overlay is active, verify with sudo ppstest /dev/pps0. You should see one event per second.

chrony

refclock PPS /dev/pps0 lock NMEA refid PPS
refclock SHM 0 refid NMEA noselect

The lock NMEA directive is necessary because a bare PPS signal carries no date or time-of-day information — it only marks second boundaries. Chrony needs the NMEA stream to resolve which second each pulse belongs to. The noselect on the SHM reference tells chrony to use NMEA only for that disambiguation, not as a primary time source.

Accuracy and Honest Limitations

On a stable local network, NTP jitter is typically in the single-digit milliseconds, and the PPS signal from the ESP8266 reflects that same jitter. You will not achieve the tens-of-microseconds accuracy of a GNSS-backed timing system. You will achieve millisecond-class timekeeping without an antenna, without clear sky, and without dedicated GPS hardware.

That is a meaningful difference from undisciplined NTP alone, which lacks the hardware edge of a PPS signal for the kernel’s clock discipline loop. What this setup is not suitable for: financial trading, precision measurement, or anything stricter than roughly one millisecond.

When to Reach for This

Indoor server rooms. GPS reception is not an option, and you want better than NTP-only with the resilience of hardware PPS discipline.

Network-isolated test environments. The host system can reach a local NTP server but not the public internet. Swap pool.ntp.org for your internal time source.

gpsd-dependent software stacks. Some software assumes gpsd is present. Rather than patch it or simulate gpsd at the application layer, give it a real gpsd instance with a real-looking input.

Learning and experimentation. The full NMEA-over-serial and PPS path through gpsd and chrony is the same path a real GPS timing receiver uses. Building this from scratch gives you a working mental model of every layer in that stack — which is worth considerably more than the twenty euros the hardware costs.

Back to top