The Cuckoo Escapement: field report, kernel patch, dashboard

What the Raspberry Pi time-server guides get wrong on a Pi 4, with the
measurements. The headline artifact is a four-line pps-gpio patch: PREEMPT_RT
force-threads IRQ handlers, and pps-gpio takes its timestamp inside its handler,
so the realtime kernel puts a scheduler between the electrical edge and the
clock. IRQF_NO_THREAD takes RMS offset from 2468 ns to 199 ns.

- kernel/     the patch
- dashboard/  live status page (position hidden by default)
- docs-site/  the write-up (Astro/Starlight, brass, no tutorial section)
This commit is contained in:
Ryan Malloy 2026-07-14 09:21:25 -06:00
commit 6881489bf6
56 changed files with 10297 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.env
.venv/
__pycache__/
dist/
*.egg-info/
node_modules/

48
README.md Normal file
View File

@ -0,0 +1,48 @@
# The Cuckoo Escapement
**What the Raspberry Pi time-server guides get wrong, and the numbers to prove it.**
Docs: **[cuckoo.warehack.ing](https://cuckoo.warehack.ing)**
We built a GPS-disciplined Stratum 1 NTP server on a Raspberry Pi 4, followed the
published advice, and measured everything. Most of that advice is wrong on this
board. One piece of it is wrong on *every* board.
| Change | RMS offset |
|---|---|
| Baseline | 823 ns |
| chrony median `filter` + `prefer` on the PPS refclock | 440 ns |
| PREEMPT_RT, as the guides recommend | **2468 ns***worse* |
| PREEMPT_RT + our `IRQF_NO_THREAD` patch | **199 ns** |
## The one thing worth stealing
[`kernel/0001-pps-gpio-keep-timestamp-in-hard-irq-under-PREEMPT_RT.patch`](kernel/)
PREEMPT_RT force-threads interrupt handlers. `pps-gpio` takes its timestamp
*inside* its handler. So the realtime kernel — the marquee upgrade in every guide
— puts a scheduler between the electrical edge and the clock, and triples your
jitter. Four lines fix it.
As far as we can tell this isn't applied anywhere, which means anyone running
GPIO-based PPS on a realtime kernel today is silently eating microseconds of
jitter with no reason to suspect it. chrony still says Stratum 1. Everything
still *looks* fine.
## What's here
| | |
|---|---|
| `kernel/` | The patch, and how to build it |
| `dashboard/` | The live status page (FastAPI + WebSocket, no build step) |
| `docs-site/` | The write-up — Astro / Starlight |
## n = 1
Every number here comes from one Pi 4 and one GPS module. This is a field report,
not a study. We're publishing the method alongside the results precisely so you
can check it against your own board rather than take our word for it.
---
A [Supported Systems](https://supported.systems) joint.

13
dashboard/.env.example Normal file
View File

@ -0,0 +1,13 @@
# Copy to .env. Nothing site-specific belongs in the Makefile.
PI=deploy@gps-ntp.local
KEY=~/.ssh/id_ed25519
APPDIR=/opt/gpsntp-dashboard
# Only needed if you front the dashboard with TLS via `make caddy`.
DOMAIN=clock.example.internal
CERT_SRC=$(HOME)/.certs/clock.example.internal
# hidden (default) | coarse (~11 km) | exact
# The receiver knows exactly where it is. `hidden` keeps that out of the page —
# and out of any screenshot you post. Think before you change this.
GPSNTP_POSITION=hidden

52
dashboard/Makefile Normal file
View File

@ -0,0 +1,52 @@
# gpsntp-dashboard — deploy to the Pi.
#
# Everything site-specific lives in .env (copy .env.example). Nothing in this
# file names a host, a domain, or a certificate path.
-include .env
export
PI ?= deploy@gps-ntp.local
KEY ?= ~/.ssh/id_ed25519
APPDIR ?= /opt/gpsntp-dashboard
DOMAIN ?= clock.example.internal
SSH = ssh -i $(KEY) $(PI)
RSYNC = rsync -az -e "ssh -i $(KEY)"
# Where your Let's Encrypt live/ directory is on THIS machine, and where the
# cert should land on the Pi. Only needed if you front the dashboard with TLS.
CERT_SRC ?= $(HOME)/.certs/$(DOMAIN)
CERT_DST ?= /etc/caddy/certs/$(DOMAIN)
.PHONY: deploy logs restart status lint run caddy cert-sync
deploy:
$(RSYNC) --delete --exclude '.venv' --exclude '.git' --exclude '__pycache__' --exclude 'dist' ./ $(PI):/tmp/gpsntp-src/
$(SSH) 'sudo mkdir -p $(APPDIR) && sudo rsync -a --delete --exclude .venv /tmp/gpsntp-src/ $(APPDIR)/ && sudo APPDIR=$(APPDIR) bash $(APPDIR)/deploy/deploy.sh'
logs:
$(SSH) 'journalctl -u gpsntp-dashboard -n 60 -f'
restart:
$(SSH) 'sudo systemctl restart gpsntp-dashboard'
status:
$(SSH) 'systemctl status gpsntp-dashboard --no-pager; curl -s localhost:8080/healthz'
# Renders deploy/Caddyfile.tmpl with $(DOMAIN) and installs it.
caddy:
sed 's|{{DOMAIN}}|$(DOMAIN)|g' deploy/Caddyfile.tmpl > /tmp/Caddyfile.rendered
$(RSYNC) /tmp/Caddyfile.rendered $(PI):/tmp/Caddyfile
$(SSH) 'sudo cp /tmp/Caddyfile /etc/caddy/Caddyfile && sudo caddy validate --adapter caddyfile --config /etc/caddy/Caddyfile && sudo systemctl reload caddy && echo reloaded'
# Push a renewed cert to the Pi. The Pi issues nothing itself — see
# deploy/Caddyfile.tmpl for why.
cert-sync:
$(RSYNC) -L $(CERT_SRC)/fullchain.pem $(CERT_SRC)/privkey.pem $(PI):/tmp/
$(SSH) 'sudo mv /tmp/fullchain.pem /tmp/privkey.pem $(CERT_DST)/ && sudo chown -R caddy:caddy /etc/caddy/certs && sudo chmod 600 $(CERT_DST)/privkey.pem && sudo systemctl reload caddy && echo "cert synced + caddy reloaded"'
lint:
uvx ruff check src/
run:
GPSD_HOST=$${GPSD_HOST:-gps-ntp.local} uv run gpsntp-dashboard

View File

@ -0,0 +1,11 @@
# gps-ntp dashboard front door. `make caddy` renders {{DOMAIN}} from .env.
#
# The cert is issued OFF-BOX and synced to the Pi (see `make cert-sync`) rather
# than obtained by Caddy itself. A time server usually lives on a LAN with no
# inbound reachability, so HTTP-01 can't work; use DNS-01 wherever you already
# run ACME and copy the result here.
{{DOMAIN}} {
tls /etc/caddy/certs/{{DOMAIN}}/fullchain.pem /etc/caddy/certs/{{DOMAIN}}/privkey.pem
encode zstd gzip
reverse_proxy 127.0.0.1:8080
}

View File

@ -0,0 +1,10 @@
# systemd drop-in: /etc/systemd/system/caddy.service.d/affinity.conf
#
# Keep Caddy off cpu0. The PPS interrupt is handled there and cannot be moved
# (Pi 4 GPIO IRQs are demuxed via pinctrl-bcm2835 and refuse an smp_affinity),
# so any work scheduled on cpu0 adds jitter to the PPS timestamp directly.
# Caddy is mostly idle, but on this box cpu0 belongs to the clock. See
# TIMING-NOTES.md.
[Service]
CPUAffinity=1
Nice=10

View File

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Idempotent install/update of the gps-ntp dashboard. Run on the Pi as root
# from the synced source tree (APPDIR). Safe to re-run.
set -euo pipefail
APPDIR="${APPDIR:-/opt/gpsntp-dashboard}"
SVC=gpsntp-dashboard
echo "==> dedicated system user"
id -u gpsntp &>/dev/null || useradd --system --no-create-home --shell /usr/sbin/nologin gpsntp
echo "==> python venv + editable install"
if [ ! -x "$APPDIR/.venv/bin/python" ]; then
if ! python3 -m venv "$APPDIR/.venv" 2>/dev/null; then
apt-get update -qq && apt-get install -y python3-venv python3-pip
python3 -m venv "$APPDIR/.venv"
fi
fi
"$APPDIR/.venv/bin/pip" install --quiet --upgrade pip
"$APPDIR/.venv/bin/pip" install --quiet -e "$APPDIR"
echo "==> narrow sudoers rule (clients command only)"
install -m 0440 "$APPDIR/deploy/gpsntp-dashboard.sudoers" /etc/sudoers.d/gpsntp-dashboard
visudo -cf /etc/sudoers.d/gpsntp-dashboard
echo "==> systemd unit"
install -m 0644 "$APPDIR/deploy/gpsntp-dashboard.service" "/etc/systemd/system/${SVC}.service"
echo "==> ownership"
chown -R gpsntp:gpsntp "$APPDIR"
echo "==> enable + (re)start"
systemctl daemon-reload
systemctl enable "${SVC}.service" >/dev/null 2>&1 || true
systemctl restart "${SVC}.service"
sleep 2
systemctl is-active "${SVC}.service" && echo "==> dashboard active on :8080"

View File

@ -0,0 +1,35 @@
[Unit]
Description=gps-ntp status dashboard
Documentation=https://git.supported.systems/time-pi
After=network-online.target gpsd.service chrony.service
Wants=network-online.target
[Service]
Type=exec
User=gpsntp
Group=gpsntp
ExecStart=/opt/gpsntp-dashboard/.venv/bin/gpsntp-dashboard
Environment=GPSNTP_PORT=8080
Environment=GPSNTP_HOST=0.0.0.0
Restart=on-failure
RestartSec=3
# Keep the dashboard OFF cpu0. The PPS interrupt is handled on cpu0 and cannot
# be moved (Pi 4 GPIO IRQs are demuxed through pinctrl-bcm2835 and refuse an
# smp_affinity), so any load there directly adds jitter to the timestamp.
# Measured: the dashboard cost ~36% more PPS jitter before this. It is a
# monitoring tool; it must never perturb the clock it is watching.
CPUAffinity=1
Nice=10
# Hardening. NoNewPrivileges is intentionally NOT set: the served-clients
# panel shells `sudo -n chronyc -c clients`, allowed by a narrow sudoers rule.
PrivateTmp=true
ProtectHome=true
ProtectControlGroups=true
ProtectKernelTunables=true
RestrictSUIDSGID=false
LockPersonality=true
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,4 @@
# Least-privilege: the dashboard user may run ONLY this one read-only command.
# `chronyc -c clients` is privileged (returns "501 Not authorised" otherwise),
# and this is the served-client list the dashboard displays. Nothing else.
gpsntp ALL=(root) NOPASSWD: /usr/bin/chronyc -c clients

29
dashboard/pyproject.toml Normal file
View File

@ -0,0 +1,29 @@
[project]
name = "gpsntp-dashboard"
version = "2026.07.12"
description = "Live status dashboard for a GPS/PPS-disciplined chrony Stratum 1 time server"
readme = "README.md"
requires-python = ">=3.11"
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
license = {text = "MIT"}
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.34",
]
[project.scripts]
gpsntp-dashboard = "gpsntp_dashboard.main:run"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/gpsntp_dashboard"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]

View File

@ -0,0 +1,3 @@
"""Live status dashboard for a GPS/PPS-disciplined chrony Stratum 1 time server."""
__version__ = "2026.07.12"

View File

@ -0,0 +1,144 @@
"""Read chronyd state via `chronyc -c` (CSV) and shape it for the dashboard."""
from __future__ import annotations
import asyncio
# `chronyc -c sources` state/mode codes -> human meaning.
_MODE = {"^": "server", "=": "peer", "#": "refclock"}
_STATE = {
"*": "selected",
"+": "combined",
"-": "not_combined",
"?": "unreachable",
"x": "falseticker",
"~": "unstable",
}
async def _run(*args: str, stdin: bytes | None = None) -> str:
proc = await asyncio.create_subprocess_exec(
*args,
stdin=asyncio.subprocess.PIPE if stdin is not None else None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate(stdin)
if proc.returncode != 0:
raise RuntimeError(err.decode(errors="replace").strip() or "command failed")
return out.decode(errors="replace")
async def collect() -> dict:
"""Fetch tracking + sources + sourcestats in a SINGLE chronyc process.
chronyc reads commands from stdin, so one fork serves all three. This
matters: forking a process per metric per second measurably degraded PPS
jitter (~36%), because the churn lands on the same CPU that handles the
PPS interrupt -- and that IRQ can't be moved off it (GPIO mux). The three
outputs are told apart by field count: 14=tracking, 10=sources, 8=stats.
"""
text = await _run("chronyc", "-c", stdin=b"tracking\nsources\nsourcestats\n")
out: dict = {"tracking": None, "sources": [], "sourcestats": {}}
for line in text.splitlines():
c = line.split(",")
if len(c) >= 14 and out["tracking"] is None:
out["tracking"] = _parse_tracking(c)
elif len(c) == 10:
out["sources"].append(_parse_source(c))
elif len(c) == 8:
out["sourcestats"][c[0]] = _parse_sourcestat(c)
return out
def _f(value: str) -> float | None:
try:
return float(value)
except (ValueError, TypeError):
return None
def _parse_tracking(c: list[str]) -> dict:
"""Reference, stratum, offsets, root delay/dispersion."""
return {
"ref_id": c[0],
"ref_name": c[1],
"stratum": int(c[2]) if c[2].isdigit() else None,
"ref_time": _f(c[3]),
"system_time": _f(c[4]),
"last_offset": _f(c[5]),
"rms_offset": _f(c[6]),
"frequency_ppm": _f(c[7]),
"residual_freq_ppm": _f(c[8]),
"skew_ppm": _f(c[9]),
"root_delay": _f(c[10]),
"root_dispersion": _f(c[11]),
"update_interval": _f(c[12]),
"leap_status": c[13],
}
def _parse_source(c: list[str]) -> dict:
state = _STATE.get(c[1], c[1])
try:
reach = int(c[5], 8) # chrony reports the reachability register in octal
except ValueError:
reach = None
# chrony reports '?' both for a genuinely unreachable source AND for one it
# simply isn't considering -- notably our GPS refclock, which is `noselect`
# (it exists only to tell PPS which second it is, never to be chosen). If
# samples are still arriving (reach > 0) the source is plainly NOT
# unreachable, so don't cry wolf: call it what it is, a reference.
# Flagging a healthy source red trains you to ignore red.
if state == "unreachable" and reach:
state = "reference_only"
return {
"mode": _MODE.get(c[0], c[0]),
"state": state,
"name": c[2],
"stratum": int(c[3]) if c[3].isdigit() else None,
"poll": int(c[4]) if c[4].lstrip("-").isdigit() else None,
"reach": reach,
"last_rx": int(c[6]) if c[6].lstrip("-").isdigit() else None,
"offset": _f(c[7]),
"offset_measured": _f(c[8]),
"error": _f(c[9]),
}
def _parse_sourcestat(c: list[str]) -> dict:
return {
"samples": int(c[1]) if c[1].isdigit() else None,
"runs": int(c[2]) if c[2].isdigit() else None,
"span": int(c[3]) if c[3].isdigit() else None,
"frequency_ppm": _f(c[4]),
"freq_skew_ppm": _f(c[5]),
"offset": _f(c[6]),
"std_dev": _f(c[7]),
}
async def clients() -> list[dict]:
"""Served NTP clients. Privileged, so try sudo -n; degrade quietly if denied."""
for cmd in (("chronyc", "-c", "clients"), ("sudo", "-n", "chronyc", "-c", "clients")):
try:
text = await _run(*cmd)
except RuntimeError:
continue
rows = []
for line in text.splitlines():
c = line.split(",")
if len(c) < 2 or not c[0]:
continue
rows.append(
{
"address": c[0],
"ntp_requests": int(c[1]) if c[1].isdigit() else None,
"ntp_dropped": int(c[2]) if len(c) > 2 and c[2].isdigit() else None,
"last_seen": int(c[4]) if len(c) > 4 and c[4].lstrip("-").isdigit() else None,
}
)
return rows
return []

View File

@ -0,0 +1,165 @@
"""Async gpsd client: streams JSON, keeps the latest fix (TPV) and sky (SKY)."""
from __future__ import annotations
import asyncio
import json
import os
# How much of the receiver's position to expose. DEFAULT IS "hidden", on purpose.
#
# A time server is stationary and its position contributes nothing to time
# accuracy -- so the coordinates are operationally useless here. But people
# screenshot dashboards and paste them into forums and issues, and a lat/lon to
# 7 decimal places is their home address. Leaking something sensitive to display
# something nobody needs is a bad trade. Opt in if you actually want it.
#
# hidden (default) : no coordinates; just "position locked"
# coarse : rounded to ~11 km -- enough to sanity-check the region
# exact : full precision
POSITION_MODE = os.environ.get("GPSNTP_POSITION", "hidden").strip().lower()
# gpsd gnssid -> constellation name (used for colouring the sky plot).
CONSTELLATION = {
0: "GPS",
1: "SBAS",
2: "Galileo",
3: "BeiDou",
4: "IMES",
5: "QZSS",
6: "GLONASS",
7: "NavIC",
}
_FIX_MODE = {0: "unknown", 1: "none", 2: "2D", 3: "3D"}
# TPV status: 2 = DGPS, 3 = RTK fixed, 4 = RTK float (best-effort labels).
_FIX_STATUS = {0: "unknown", 1: "GPS", 2: "DGPS", 3: "RTK-fixed", 4: "RTK-float"}
class GpsdReader:
"""Connect to gpsd, WATCH, and cache the newest TPV + SKY reports."""
def __init__(self, host: str = "127.0.0.1", port: int = 2947):
self.host = host
self.port = port
self.tpv: dict = {}
self.satellites: list[dict] = []
self.dop: dict = {}
self.connected = False
self._task: asyncio.Task | None = None
def start(self) -> None:
self._task = asyncio.create_task(self._run())
async def stop(self) -> None:
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
async def _run(self) -> None:
while True:
try:
await self._read_loop()
except asyncio.CancelledError:
raise
except Exception:
self.connected = False
await asyncio.sleep(2) # gpsd down / restarting; retry
async def _read_loop(self) -> None:
reader, writer = await asyncio.open_connection(self.host, self.port)
self.connected = True
writer.write(b'?WATCH={"enable":true,"json":true}\n')
await writer.drain()
try:
while True:
line = await reader.readline()
if not line:
break
self._ingest(line)
finally:
self.connected = False
writer.close()
def _ingest(self, line: bytes) -> None:
try:
msg = json.loads(line)
except json.JSONDecodeError:
return
cls = msg.get("class")
if cls == "TPV":
# gpsd sends PARTIAL TPV reports — a frame may omit "mode", "lat",
# etc. Overwriting the cache would drop the fix state and render it
# as "unknown". Merge instead, so known fields survive until gpsd
# actually supersedes them. (Same trap as the DOP-only SKY frames.)
self.tpv.update(msg)
elif cls == "SKY":
# DOP-only SKY frames omit satellites[] — keep the last real list.
sats = msg.get("satellites")
if sats is not None:
self.satellites = sats
for k in ("hdop", "vdop", "pdop", "gdop", "tdop", "uSat", "nSat"):
if k in msg:
self.dop[k] = msg[k]
def _fix_status(self, mode: int) -> str:
"""TPV omits `status` when there's no augmentation (e.g. SBAS disabled).
An absent status on a valid fix means plain GPS, NOT 'unknown' -- the
absence of *extra* information is not the absence of information.
"""
status = self.tpv.get("status")
if not status: # None or 0
return "GPS" if mode >= 2 else "none"
return _FIX_STATUS.get(status, "GPS")
def _position(self) -> dict:
"""Expose position per POSITION_MODE. See the note at the top of this file:
hidden by default, because a screenshotted dashboard should not publish
the operator's home address to display a number nobody needs.
"""
lat, lon = self.tpv.get("lat"), self.tpv.get("lon")
has_fix = lat is not None and lon is not None
if not has_fix or POSITION_MODE == "hidden":
return {"lat": None, "lon": None, "has_position": has_fix,
"position_mode": POSITION_MODE}
if POSITION_MODE == "coarse":
lat, lon = round(lat, 1), round(lon, 1) # ~11 km — region, not street
return {"lat": lat, "lon": lon, "has_position": True,
"position_mode": POSITION_MODE}
def snapshot(self) -> dict:
t = self.tpv
mode = t.get("mode", 0)
sats = [
{
"prn": s.get("PRN"),
"az": s.get("az"),
"el": s.get("el"),
"ss": s.get("ss"),
"used": bool(s.get("used")),
"constellation": CONSTELLATION.get(s.get("gnssid"), "other"),
}
for s in self.satellites
if s.get("az") is not None and s.get("el") is not None
]
return {
"connected": self.connected,
"device": t.get("device"),
"fix_mode": _FIX_MODE.get(mode, "unknown"),
"fix_status": self._fix_status(mode),
**self._position(), # lat/lon gated by GPSNTP_POSITION (default: hidden)
"alt_m": t.get("altMSL", t.get("alt")),
"time": t.get("time"),
"leapseconds": t.get("leapseconds"),
"ept": t.get("ept"),
"epx": t.get("epx"),
"epy": t.get("epy"),
"hdop": self.dop.get("hdop"),
"pdop": self.dop.get("pdop"),
"sats_used": sum(1 for s in sats if s["used"]),
"sats_seen": len(sats),
"satellites": sats,
}

View File

@ -0,0 +1,158 @@
"""FastAPI app: one background collector feeds the dashboard, /api, /metrics, /ws."""
from __future__ import annotations
import asyncio
import contextlib
import os
import time
from collections import deque
from importlib.metadata import version
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from . import chrony, metrics
from .gpsd_reader import GpsdReader
STATIC = Path(__file__).parent / "static"
POLL_SECONDS = float(os.environ.get("GPSNTP_POLL_SECONDS", "1"))
HISTORY_LEN = int(os.environ.get("GPSNTP_HISTORY_POINTS", "180")) # ~3 min at 1s
CLIENTS_EVERY = 5 # sample the privileged clients list every Nth cycle
gpsd = GpsdReader(
host=os.environ.get("GPSD_HOST", "127.0.0.1"),
port=int(os.environ.get("GPSD_PORT", "2947")),
)
# Shared state produced by the single collector task.
_history: deque[dict] = deque(maxlen=HISTORY_LEN)
_state: dict = {"snapshot": {"version": "?", "history": []}}
async def _build(include_clients: bool, prev_clients) -> dict:
"""One pass over chrony + gpsd. Each source degrades independently."""
snap: dict = {"gps": gpsd.snapshot(), "version": _state["snapshot"]["version"],
"server_time": time.time()}
# ONE chronyc process for tracking+sources+sourcestats. Forking a process
# per metric per second measurably worsened PPS jitter (~36%) -- the churn
# lands on the CPU that handles the PPS interrupt. See chrony.collect().
try:
snap.update(await chrony.collect())
except Exception as exc:
snap["tracking"] = snap["sources"] = snap["sourcestats"] = None
snap.setdefault("errors", {})["chrony"] = str(exc)
if include_clients:
try:
snap["clients"] = await chrony.clients()
except Exception as exc:
snap["clients"] = None
snap.setdefault("errors", {})["clients"] = str(exc)
else:
snap["clients"] = prev_clients
return snap
async def _collect_loop() -> None:
_state["snapshot"]["version"] = version("gpsntp-dashboard")
i = 0
prev_clients = None
while True:
try:
snap = await _build(include_clients=(i % CLIENTS_EVERY == 0), prev_clients=prev_clients)
prev_clients = snap.get("clients")
t = snap.get("tracking") or {}
srcs = snap.get("sources") or []
pps = next((s.get("offset") for s in srcs if s["name"] == "PPS"), None)
_history.append({"t": snap["server_time"], "sys": t.get("system_time"), "pps": pps})
snap["history"] = list(_history)
_state["snapshot"] = snap
except Exception:
pass
i += 1
await asyncio.sleep(POLL_SECONDS)
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
gpsd.start()
task = asyncio.create_task(_collect_loop())
yield
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
await gpsd.stop()
app = FastAPI(title="gps-ntp dashboard", version=version("gpsntp-dashboard"), lifespan=lifespan)
@app.get("/api/status")
async def api_status() -> JSONResponse:
return JSONResponse(_state["snapshot"])
@app.get("/metrics")
async def prometheus() -> PlainTextResponse:
return PlainTextResponse(metrics.render(_state["snapshot"]), media_type=metrics.CONTENT_TYPE)
@app.get("/healthz")
async def healthz() -> dict:
return {"ok": True, "gpsd_connected": gpsd.connected}
@app.websocket("/ws")
async def ws(socket: WebSocket) -> None:
await socket.accept()
try:
while True:
await socket.send_json(_state["snapshot"])
await asyncio.sleep(POLL_SECONDS)
except WebSocketDisconnect:
pass
@app.get("/")
async def index() -> FileResponse:
return FileResponse(STATIC / "index.html")
class RevalidatingStatic(StaticFiles):
"""StaticFiles, but the browser must ask before reusing a cached copy.
Starlette sends an ETag and Last-Modified but no Cache-Control, so browsers
fall back to *heuristic* freshness (RFC 9111 4.2.2) and happily serve a
stale style.css for hours. Every deploy replaces these files at the same
URLs, so that means a dashboard someone left open shows old markup against
old CSS which is exactly how we shipped an unstyled footer once.
`no-cache` doesn't mean "don't cache", it means "revalidate before use".
The ETag still turns that into a 304 with an empty body, so the cost is one
conditional request for ~30 KB of assets on a LAN. Cheap; correct.
"""
def is_not_modified(self, response_headers, request_headers) -> bool: # noqa: ANN001
response_headers["cache-control"] = "no-cache"
return super().is_not_modified(response_headers, request_headers)
async def get_response(self, path: str, scope): # noqa: ANN001, ANN201
response = await super().get_response(path, scope)
response.headers["cache-control"] = "no-cache"
return response
app.mount("/static", RevalidatingStatic(directory=STATIC), name="static")
def run() -> None:
import uvicorn
uvicorn.run(
"gpsntp_dashboard.main:app",
host=os.environ.get("GPSNTP_HOST", "0.0.0.0"),
port=int(os.environ.get("GPSNTP_PORT", "8080")),
log_level="info",
)

View File

@ -0,0 +1,88 @@
"""Render a status snapshot as Prometheus text exposition format (0.0.4)."""
from __future__ import annotations
CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
_FIX_MODE_NUM = {"none": 0, "2D": 2, "3D": 3, "unknown": 0}
def _esc(v: str) -> str:
return str(v).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
def _labels(pairs: dict) -> str:
inner = ",".join(f'{k}="{_esc(v)}"' for k, v in pairs.items() if v is not None)
return f"{{{inner}}}" if inner else ""
def render(snap: dict) -> str:
out: list[str] = []
def metric(name: str, mtype: str, help_: str, samples: list[tuple[dict, float]]):
vals = [(lbls, v) for lbls, v in samples if v is not None]
if not vals:
return
out.append(f"# HELP {name} {help_}")
out.append(f"# TYPE {name} {mtype}")
for lbls, v in vals:
out.append(f"{name}{_labels(lbls)} {v}")
metric("gpsntp_up", "gauge", "Dashboard collector produced a snapshot", [({}, 1)])
g = snap.get("gps") or {}
metric("gpsntp_gpsd_connected", "gauge", "gpsd socket connected",
[({}, 1 if g.get("connected") else 0)])
metric("gpsntp_gps_fix_mode", "gauge", "GPS fix mode (0 none, 2 2D, 3 3D)",
[({}, _FIX_MODE_NUM.get(g.get("fix_mode"), 0))])
metric("gpsntp_gps_satellites_used", "gauge", "Satellites used in the fix",
[({}, g.get("sats_used"))])
metric("gpsntp_gps_satellites_visible", "gauge", "Satellites visible",
[({}, g.get("sats_seen"))])
metric("gpsntp_gps_hdop", "gauge", "Horizontal dilution of precision",
[({}, g.get("hdop"))])
t = snap.get("tracking") or {}
metric("gpsntp_stratum", "gauge", "Stratum of this server", [({}, t.get("stratum"))])
metric("gpsntp_system_offset_seconds", "gauge", "System clock offset from true time",
[({}, t.get("system_time"))])
metric("gpsntp_last_offset_seconds", "gauge", "Last measured offset",
[({}, t.get("last_offset"))])
metric("gpsntp_rms_offset_seconds", "gauge", "RMS offset", [({}, t.get("rms_offset"))])
metric("gpsntp_frequency_ppm", "gauge", "Clock frequency correction",
[({}, t.get("frequency_ppm"))])
metric("gpsntp_skew_ppm", "gauge", "Estimated frequency skew", [({}, t.get("skew_ppm"))])
metric("gpsntp_root_delay_seconds", "gauge", "Total root delay to the reference",
[({}, t.get("root_delay"))])
metric("gpsntp_root_dispersion_seconds", "gauge", "Total root dispersion",
[({}, t.get("root_dispersion"))])
if t.get("ref_name"):
metric("gpsntp_reference_info", "gauge", "Current reference (value always 1)",
[({"ref_id": t.get("ref_id"), "ref_name": t.get("ref_name")}, 1)])
sources = snap.get("sources") or []
stats = snap.get("sourcestats") or {}
metric("gpsntp_source_offset_seconds", "gauge", "Per-source last offset",
[({"name": s["name"], "mode": s["mode"], "state": s["state"]}, s.get("offset"))
for s in sources])
metric("gpsntp_source_stratum", "gauge", "Per-source stratum",
[({"name": s["name"]}, s.get("stratum")) for s in sources])
metric("gpsntp_source_reach", "gauge", "Per-source reachability register (0-255)",
[({"name": s["name"]}, s.get("reach")) for s in sources])
metric("gpsntp_source_last_rx_seconds", "gauge", "Seconds since last sample from source",
[({"name": s["name"]}, s.get("last_rx")) for s in sources])
metric("gpsntp_source_std_dev_seconds", "gauge", "Per-source estimated std deviation",
[({"name": n}, st.get("std_dev")) for n, st in stats.items()])
metric("gpsntp_satellite_snr_db", "gauge", "Per-satellite carrier-to-noise density",
[({"prn": s.get("prn"), "constellation": s.get("constellation"),
"used": "true" if s.get("used") else "false"}, s.get("ss"))
for s in (g.get("satellites") or []) if s.get("ss")])
clients = snap.get("clients")
if clients is not None:
metric("gpsntp_clients_total", "gauge", "Number of NTP clients seen", [({}, len(clients))])
metric("gpsntp_client_ntp_requests_total", "counter", "NTP requests per client",
[({"address": c["address"]}, c.get("ntp_requests")) for c in clients])
return "\n".join(out) + "\n"

View File

@ -0,0 +1,295 @@
"use strict";
const CONSTS = {
GPS: getComputedStyle(document.documentElement).getPropertyValue("--c-gps").trim(),
GLONASS: cssVar("--c-glonass"),
Galileo: cssVar("--c-galileo"),
BeiDou: cssVar("--c-beidou"),
SBAS: cssVar("--c-sbas"),
QZSS: cssVar("--c-qzss"),
other: cssVar("--c-other"),
};
function cssVar(n) { return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); }
function color(constellation) { return CONSTS[constellation] || CONSTS.other; }
const $ = (id) => document.getElementById(id);
// ---- clock (interpolated from the server's disciplined time) ----
let clockBase = null; // { serverMs, perfMs }
function tickClock() {
let d;
if (clockBase) {
d = new Date(clockBase.serverMs + (performance.now() - clockBase.perfMs));
} else {
d = new Date();
}
const p = (n, w = 2) => String(n).padStart(w, "0");
$("utc-clock").firstChild.nodeValue =
`${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
$("utc-frac").textContent = "." + p(d.getUTCMilliseconds(), 3);
$("utc-date").textContent = d.toISOString().slice(0, 10);
requestAnimationFrame(tickClock);
}
requestAnimationFrame(tickClock);
// ---- formatting ----
function fmtOffset(sec) {
if (sec === null || sec === undefined) return "—";
const a = Math.abs(sec);
const sign = sec < 0 ? "" : "+";
if (a < 1e-6) return `${sign}${(a * 1e9).toFixed(0)} ns`;
if (a < 1e-3) return `${sign}${(a * 1e6).toFixed(2)} µs`;
if (a < 1) return `${sign}${(a * 1e3).toFixed(3)} ms`;
return `${sign}${a.toFixed(3)} s`;
}
function fmtAbs(sec) {
if (sec === null || sec === undefined) return "—";
const a = Math.abs(sec);
if (a < 1e-6) return `${(a * 1e9).toFixed(1)} ns`;
if (a < 1e-3) return `${(a * 1e6).toFixed(2)} µs`;
if (a < 1) return `${(a * 1e3).toFixed(2)} ms`;
return `${a.toFixed(3)} s`;
}
function offsetClass(sec) {
const a = Math.abs(sec ?? 1);
if (a < 1e-5) return "good";
if (a < 1e-2) return "warn";
return "bad";
}
// ---- render ----
function render(d) {
if (typeof d.server_time === "number") {
clockBase = { serverMs: d.server_time * 1000, perfMs: performance.now() };
}
renderTracking(d.tracking);
renderGps(d.gps);
// Real PPS jitter comes from chrony's per-source stats (nanoseconds), not
// gpsd's coarse NMEA time-error estimate.
const ppsStats = d.sourcestats && d.sourcestats["PPS"];
if (ppsStats && ppsStats.std_dev != null) {
$("pps-jitter").textContent = "±" + fmtAbs(ppsStats.std_dev);
}
renderSources(d.sources, d.tracking);
renderClients(d.clients);
renderSky(d.gps ? d.gps.satellites : []);
renderSnr(d.gps ? d.gps.satellites : []);
renderSpark(d.history);
$("foot-version").textContent = "gpsntp-dashboard v" + (d.version || "?");
$("foot-updated").textContent = "updated " + new Date().toLocaleTimeString();
}
function renderTracking(t) {
const badge = $("stratum-badge");
if (!t) { $("stratum-num").textContent = "—"; badge.dataset.ok = "false"; return; }
$("stratum-num").textContent = t.stratum ?? "—";
$("ref-name").textContent = t.ref_name || "—";
const synced = t.stratum === 1 && t.leap_status === "Normal";
badge.dataset.ok = synced ? "true" : "false";
const sys = $("sys-offset");
sys.textContent = fmtOffset(t.system_time);
sys.className = "card-value " + offsetClass(t.system_time);
$("last-offset").textContent = fmtAbs(t.last_offset);
$("root-delay").textContent = fmtAbs(t.root_delay);
$("root-disp").textContent = fmtAbs(t.root_dispersion);
$("freq").textContent = t.frequency_ppm != null ? t.frequency_ppm.toFixed(3) + " ppm" : "—";
$("skew").textContent = t.skew_ppm != null ? t.skew_ppm.toFixed(3) + " ppm" : "—";
}
function renderGps(g) {
if (!g) return;
const fix = $("fix-mode");
fix.textContent = g.fix_mode === "3D" ? (g.fix_status || "3D") : (g.fix_mode || "—");
fix.className = "card-value " + (g.fix_mode === "3D" ? "good" : g.fix_mode === "2D" ? "warn" : "bad");
$("sats-line").textContent = `${g.sats_used}/${g.sats_seen} sats`;
$("hdop").textContent = g.hdop != null ? g.hdop.toFixed(2) : "—";
// Position is HIDDEN by default (GPSNTP_POSITION). A stationary time server's
// coordinates do nothing for time accuracy, but a screenshotted dashboard with
// 7 decimal places is somebody's home address. Confirm the fix, don't dox them.
const pin = `<svg aria-hidden="true" class="ic-inline"><use href="#i-pin"/></svg> `;
const loc = $("location");
if (g.lat != null && g.lon != null) {
const dp = g.position_mode === "coarse" ? 1 : 4;
loc.innerHTML = pin + `${g.lat.toFixed(dp)}, ${g.lon.toFixed(dp)}` +
(g.position_mode === "coarse" ? " <span class=\"pos-note\">approx</span>" : "");
} else if (g.has_position) {
loc.innerHTML = pin + "position locked";
} else {
loc.innerHTML = pin + "no position";
}
// PPS card: pull the PPS source stats via the sources render; here show fix time error.
const pps = $("pps-state");
if (g.fix_mode === "3D") { pps.textContent = "locked"; pps.className = "card-value good"; }
else { pps.textContent = "waiting"; pps.className = "card-value warn"; }
$("pps-jitter").textContent = g.ept != null ? "±" + fmtAbs(g.ept) : "—";
}
function renderSources(sources, tracking) {
const tb = $("sources").querySelector("tbody");
tb.innerHTML = "";
if (!sources) { tb.innerHTML = `<tr><td colspan="7" class="empty-note">chronyd unavailable</td></tr>`; return; }
for (const s of sources) {
const tr = document.createElement("tr");
if (s.state === "selected") tr.className = "sel";
if (s.mode === "refclock") tr.classList.add("refclk");
const reach = s.reach === 255 ? "377" : (s.reach ?? "—");
tr.innerHTML =
`<td>${esc(s.name)}</td>` +
`<td>${s.mode}</td>` +
`<td class="num">${s.stratum ?? "—"}</td>` +
`<td class="num">${reach}</td>` +
`<td class="num">${s.last_rx != null ? s.last_rx + "s" : "—"}</td>` +
`<td class="num">${fmtOffset(s.offset)}</td>` +
`<td><span class="pill ${s.state}">${s.state.replace("_", " ")}</span></td>`;
tb.appendChild(tr);
}
// PPS jitter into the card if PPS source present
}
function renderClients(clients) {
const el = $("clients");
$("clients-count").textContent = clients ? clients.length : "—";
if (clients === null) { el.innerHTML = `<span class="empty-note">Client list needs elevated access (not granted).</span>`; return; }
if (clients.length === 0) { el.innerHTML = `<span class="empty-note">No clients have queried yet.</span>`; return; }
el.innerHTML = "";
for (const c of clients) {
const div = document.createElement("div");
div.className = "client";
div.innerHTML = `<span class="addr">${esc(c.address)}</span>` +
`<span class="meta">${c.ntp_requests ?? 0} requests` +
`${c.last_seen != null ? " · " + c.last_seen + "s ago" : ""}</span>`;
el.appendChild(div);
}
}
// ---- sky plot ----
const SKY = { cx: 200, cy: 200, r: 178 };
function elAzToXY(el, az) {
const rr = SKY.r * (90 - Math.max(0, Math.min(90, el))) / 90;
const a = (az * Math.PI) / 180;
return [SKY.cx + rr * Math.sin(a), SKY.cy - rr * Math.cos(a)];
}
function svgEl(tag, attrs) {
const e = document.createElementNS("http://www.w3.org/2000/svg", tag);
for (const k in attrs) e.setAttribute(k, attrs[k]);
return e;
}
function drawSkyFrame(svg) {
for (const [el, cls] of [[0, "sky-ring"], [30, "sky-ring faint"], [60, "sky-ring faint"]]) {
const rr = SKY.r * (90 - el) / 90;
svg.appendChild(svgEl("circle", { cx: SKY.cx, cy: SKY.cy, r: rr, class: cls }));
}
svg.appendChild(svgEl("line", { x1: SKY.cx, y1: SKY.cy - SKY.r, x2: SKY.cx, y2: SKY.cy + SKY.r, class: "sky-cross" }));
svg.appendChild(svgEl("line", { x1: SKY.cx - SKY.r, y1: SKY.cy, x2: SKY.cx + SKY.r, y2: SKY.cy, class: "sky-cross" }));
for (const [lbl, x, y] of [["N", SKY.cx, SKY.cy - SKY.r - 5], ["S", SKY.cx, SKY.cy + SKY.r + 13],
["E", SKY.cx + SKY.r + 8, SKY.cy + 4], ["W", SKY.cx - SKY.r - 8, SKY.cy + 4]]) {
const t = svgEl("text", { x, y, class: "sky-card-label", "text-anchor": "middle" });
t.textContent = lbl; svg.appendChild(t);
}
const t30 = svgEl("text", { x: SKY.cx + 4, y: SKY.cy - SKY.r * (60 / 90) - 3, class: "sky-ring-label" });
t30.textContent = "30°"; svg.appendChild(t30);
}
function renderSky(sats) {
const svg = $("sky");
svg.innerHTML = "";
drawSkyFrame(svg);
for (const s of sats || []) {
if (s.el == null || s.az == null) continue;
const [x, y] = elAzToXY(s.el, s.az);
const rad = 4 + (s.ss ? Math.min(9, s.ss / 6) : 1.5);
const col = color(s.constellation);
svg.appendChild(svgEl("circle", { cx: x, cy: y, r: rad + 4, fill: col, class: "sat-halo" }));
const dot = svgEl("circle", {
cx: x, cy: y, r: rad, class: "sat",
fill: s.used ? col : "transparent", stroke: col, "stroke-width": s.used ? 0 : 1.6,
});
dot.addEventListener("pointerenter", (e) => showTip(e, s));
dot.addEventListener("pointerleave", hideTip);
svg.appendChild(dot);
if (s.used && rad >= 6 && s.prn != null) {
const lbl = svgEl("text", { x, y: y + 3, class: "sat-label", "text-anchor": "middle" });
lbl.textContent = s.prn; svg.appendChild(lbl);
}
}
renderLegend(sats || []);
}
function renderLegend(sats) {
const present = [...new Set(sats.map((s) => s.constellation))];
const order = ["GPS", "GLONASS", "Galileo", "BeiDou", "SBAS", "QZSS", "other"];
present.sort((a, b) => order.indexOf(a) - order.indexOf(b));
$("legend").innerHTML = present.map((c) =>
`<span><i style="background:${color(c)}"></i>${c}</span>`).join("");
}
function showTip(e, s) {
const tip = $("sky-tip");
const hold = tip.parentElement.getBoundingClientRect();
tip.hidden = false;
tip.innerHTML = `${s.constellation} #${s.prn ?? "?"}<br>el ${Math.round(s.el)}° · az ${Math.round(s.az)}°` +
`<br>${s.ss != null ? s.ss + " dB-Hz" : "no signal"}${s.used ? " · in fix" : ""}`;
tip.style.left = (e.clientX - hold.left) + "px";
tip.style.top = (e.clientY - hold.top) + "px";
}
function hideTip() { $("sky-tip").hidden = true; }
// ---- snr bars ----
function renderSnr(sats) {
const el = $("snr");
const withSig = (sats || []).filter((s) => s.ss != null && s.ss > 0)
.sort((a, b) => b.ss - a.ss);
if (withSig.length === 0) { el.innerHTML = `<div class="snr-empty">No satellite signals reported.</div>`; return; }
el.innerHTML = "";
for (const s of withSig) {
const bar = document.createElement("div");
bar.className = "snr-bar" + (s.used ? " used" : "");
const h = Math.max(3, Math.min(100, (s.ss / 55) * 100));
bar.innerHTML = `<div class="snr-fill" style="height:${h}%;background:${color(s.constellation)}"></div>` +
`<div class="snr-prn">${s.prn ?? ""}</div>`;
bar.title = `${s.constellation} #${s.prn}: ${s.ss} dB-Hz${s.used ? " (used)" : ""}`;
el.appendChild(bar);
}
}
// ---- offset history sparkline ----
function renderSpark(history) {
const svg = $("spark");
const W = 1000, H = 120, pad = 10;
const pts = (history || []).filter((p) => p.sys != null);
if (pts.length < 2) { svg.innerHTML = ""; $("spark-meta").textContent = "collecting…"; return; }
const maxAbs = Math.max(2e-7, ...pts.map((p) => Math.abs(p.sys)));
const span = maxAbs * 1.2;
const zeroY = H / 2;
const xOf = (i) => pad + (i / (pts.length - 1)) * (W - 2 * pad);
const yOf = (v) => zeroY - (v / span) * (H / 2 - pad);
let line = "";
pts.forEach((p, i) => { line += (i ? "L" : "M") + xOf(i).toFixed(1) + " " + yOf(p.sys).toFixed(1) + " "; });
const area = `M ${xOf(0).toFixed(1)} ${zeroY} ` +
pts.map((p, i) => `L ${xOf(i).toFixed(1)} ${yOf(p.sys).toFixed(1)}`).join(" ") +
` L ${xOf(pts.length - 1).toFixed(1)} ${zeroY} Z`;
const a = cssVar("--accent");
svg.innerHTML =
`<defs><linearGradient id="spark-grad" x1="0" y1="0" x2="0" y2="1">` +
`<stop offset="0%" stop-color="${a}" stop-opacity="0.32"/>` +
`<stop offset="100%" stop-color="${a}" stop-opacity="0"/></linearGradient></defs>` +
`<line x1="${pad}" y1="${zeroY}" x2="${W - pad}" y2="${zeroY}" class="spark-zero"/>` +
`<path d="${area}" class="spark-area"/>` +
`<path d="${line}" class="spark-line"/>`;
$("spark-meta").textContent =
`now ${fmtOffset(pts[pts.length - 1].sys)} · peak ±${fmtAbs(maxAbs)} · ${pts.length}s`;
}
function esc(s) { const d = document.createElement("div"); d.textContent = s ?? ""; return d.innerHTML; }
// ---- websocket with reconnect ----
function setConn(state, label) {
const c = $("conn"); c.dataset.state = state; $("conn-label").textContent = label;
}
function connect() {
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => setConn("live", "live");
ws.onmessage = (ev) => { try { render(JSON.parse(ev.data)); } catch (e) { console.error(e); } };
ws.onclose = () => { setConn("down", "reconnecting…"); setTimeout(connect, 2000); };
ws.onerror = () => ws.close();
}
connect();

View File

@ -0,0 +1,180 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>gps-ntp · time server</title>
<link rel="stylesheet" href="/static/style.css" />
<link rel="icon"
href="data:image/svg+xml,&lt;svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'&gt;&lt;text y='18' font-size='18'&gt;🛰️&lt;/text&gt;&lt;/svg&gt;" />
</head>
<body>
<!-- lucide icon sprite (inline so the dashboard works fully offline) -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="i-dish" viewBox="0 0 24 24"><path d="M4 10a7.31 7.31 0 0 0 10 10Z"/><path d="m9 15 3-3"/><path d="M17 13a6 6 0 0 0-6-6"/><path d="M21 13A10 10 0 0 0 11 3"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></symbol>
<symbol id="i-activity" viewBox="0 0 24 24"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></symbol>
<symbol id="i-zap" viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></symbol>
<symbol id="i-gauge" viewBox="0 0 24 24"><path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/></symbol>
<symbol id="i-server" viewBox="0 0 24 24"><rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/></symbol>
<symbol id="i-users" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></symbol>
<symbol id="i-pin" viewBox="0 0 24 24"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></symbol>
<symbol id="i-radio" viewBox="0 0 24 24"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></symbol>
</defs>
</svg>
<header class="topbar">
<div class="brand">
<svg class="brand-icon" aria-hidden="true"><use href="#i-dish"/></svg>
<div>
<h1 id="hostname">gps-ntp</h1>
<p class="brand-sub">GPS · PPS disciplined time server</p>
</div>
</div>
<div class="conn" id="conn" data-state="init">
<span class="conn-dot" aria-hidden="true"></span>
<span id="conn-label">connecting…</span>
</div>
</header>
<main>
<!-- Hero: live clock + stratum verdict -->
<section class="hero panel" aria-label="Current time and sync status">
<div class="clock-wrap">
<div class="clock" id="utc-clock">--:--:--<span class="clock-frac" id="utc-frac">.000</span></div>
<div class="clock-meta"><span id="utc-date"></span> · <span class="mono">UTC</span></div>
</div>
<div class="verdict">
<div class="stratum-badge" id="stratum-badge" data-ok="false">
<svg aria-hidden="true"><use href="#i-check"/></svg>
<div>
<span class="stratum-num" id="stratum-num"></span>
<span class="stratum-word">stratum</span>
</div>
</div>
<div class="ref-line">synced to <strong id="ref-name"></strong></div>
</div>
</section>
<!-- Key stat cards -->
<section class="cards" aria-label="Key metrics">
<article class="card">
<div class="card-head"><svg aria-hidden="true"><use href="#i-zap"/></svg> System offset</div>
<div class="card-value" id="sys-offset"></div>
<div class="card-sub">of true time · last <span id="last-offset"></span></div>
</article>
<article class="card">
<div class="card-head"><svg aria-hidden="true"><use href="#i-activity"/></svg> PPS lock</div>
<div class="card-value" id="pps-state"></div>
<div class="card-sub">jitter <span id="pps-jitter"></span></div>
</article>
<article class="card">
<div class="card-head"><svg aria-hidden="true"><use href="#i-dish"/></svg> GPS fix</div>
<div class="card-value" id="fix-mode"></div>
<div class="card-sub"><span id="sats-line"></span> · HDOP <span id="hdop"></span></div>
</article>
<article class="card">
<div class="card-head"><svg aria-hidden="true"><use href="#i-gauge"/></svg> Root delay</div>
<div class="card-value" id="root-delay"></div>
<div class="card-sub">dispersion <span id="root-disp"></span></div>
</article>
<article class="card">
<div class="card-head"><svg aria-hidden="true"><use href="#i-server"/></svg> Clock freq</div>
<div class="card-value" id="freq"></div>
<div class="card-sub">skew <span id="skew"></span></div>
</article>
<article class="card">
<div class="card-head"><svg aria-hidden="true"><use href="#i-users"/></svg> Clients served</div>
<div class="card-value" id="clients-count"></div>
<div class="card-sub" id="location"><svg aria-hidden="true" class="ic-inline"><use href="#i-pin"/></svg></div>
</article>
</section>
<!-- Offset history sparkline -->
<section class="panel spark-panel" aria-label="System offset history">
<div class="panel-head">
<h2><svg aria-hidden="true"><use href="#i-activity"/></svg> System offset · last 3 min</h2>
<div class="spark-meta" id="spark-meta"></div>
</div>
<svg id="spark" class="spark-big" viewBox="0 0 1000 120" preserveAspectRatio="none"
role="img" aria-label="System clock offset over the last three minutes"></svg>
</section>
<div class="grid-2">
<!-- Sky plot -->
<section class="panel sky-panel" aria-label="Satellite sky view">
<div class="panel-head">
<h2><svg aria-hidden="true"><use href="#i-dish"/></svg> Sky view</h2>
<div class="legend" id="legend"></div>
</div>
<div class="sky-hold">
<svg id="sky" viewBox="0 0 400 400" role="img" aria-label="Polar plot of satellites overhead"></svg>
<div class="sky-tip" id="sky-tip" hidden></div>
</div>
</section>
<!-- Signal bars -->
<section class="panel snr-panel" aria-label="Satellite signal strength">
<div class="panel-head"><h2><svg aria-hidden="true"><use href="#i-radio"/></svg> Signal (C/N₀ dB-Hz)</h2></div>
<div class="snr" id="snr"></div>
</section>
</div>
<!-- Sources -->
<section class="panel" aria-label="Time sources">
<div class="panel-head"><h2><svg aria-hidden="true"><use href="#i-server"/></svg> Time sources</h2></div>
<div class="table-hold">
<table class="tbl" id="sources">
<thead>
<tr><th>Source</th><th>Type</th><th class="num">Str</th><th class="num">Reach</th><th class="num">Last</th><th class="num">Offset</th><th>State</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<!-- Clients -->
<section class="panel" aria-label="Clients">
<div class="panel-head"><h2><svg aria-hidden="true"><use href="#i-users"/></svg> Network clients</h2></div>
<div id="clients" class="clients"></div>
</section>
</main>
<footer class="foot">
<!-- The maker's plate. Clockmakers signed the BACKPLATE — the brass face
only a repairer ever sees, once the case is open. A footer is the same
thing: a quiet engraved signature, not a billboard. -->
<aside class="ss-plate" aria-label="Supported Systems">
<a class="ss-plate__link" href="https://supported.systems" rel="noopener">
<img class="ss-plate__logo" src="/static/supported-systems-logo.svg"
alt="" width="52" height="39" loading="lazy" />
<span class="ss-plate__copy">
<span class="ss-plate__heading">A Supported Systems Joint</span>
<span class="ss-plate__body">
This clock is built and maintained by
<span class="ss-plate__name">Supported Systems</span> — a boutique
software studio focused on thoughtful, user-first technology. We
measure things before we believe them.
</span>
<span class="ss-plate__cta">
Visit supported.systems
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<path d="M4 8h7M8 5l3 3-3 3" stroke-width="1.5" />
</svg>
</span>
</span>
</a>
</aside>
<div class="foot-meta">
<span id="foot-version">gpsntp-dashboard</span>
<span id="foot-updated"></span>
</div>
</footer>
<script src="/static/app.js"></script>
</body>
</html>

View File

@ -0,0 +1,225 @@
:root {
--bg: #0a0e13;
--bg-2: #0e141c;
--panel: #121a23;
--panel-2: #16212d;
--border: #21303f;
--border-soft: #1a2632;
--text: #e7eef5;
--muted: #8a99a8;
--faint: #57687a;
--accent: #22d3ee;
--good: #34d399;
--warn: #f5a524;
--bad: #f4436b;
/* constellations (no purple) */
--c-gps: #34d399;
--c-glonass: #38bdf8;
--c-galileo: #f5a524;
--c-beidou: #fb7185;
--c-sbas: #a3e635;
--c-qzss: #2dd4bf;
--c-other: #94a3b8;
--shadow: 0 1px 0 rgba(255, 255, 255, 0.03), 0 12px 30px -18px rgba(0, 0, 0, 0.9);
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
background:
radial-gradient(1200px 600px at 80% -10%, rgba(34, 211, 238, 0.06), transparent 60%),
radial-gradient(900px 500px at 0% 0%, rgba(52, 211, 153, 0.05), transparent 55%),
var(--bg);
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
svg { width: 1.15em; height: 1.15em; fill: none; stroke: currentColor; stroke-width: 2;
stroke-linecap: round; stroke-linejoin: round; }
/* ---------- top bar ---------- */
.topbar {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; padding: 16px 20px;
border-bottom: 1px solid var(--border-soft);
position: sticky; top: 0; z-index: 5;
background: linear-gradient(var(--bg), rgba(10, 14, 19, 0.86));
backdrop-filter: blur(8px);
}
.brand { display: flex; align-items: center; gap: 13px; }
.brand-icon { width: 30px; height: 30px; color: var(--accent); }
.brand h1 { font-size: 19px; margin: 0; letter-spacing: 0.3px; font-family: var(--mono); }
.brand-sub { margin: 0; color: var(--muted); font-size: 12.5px; }
.conn { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted);
padding: 6px 12px; border: 1px solid var(--border); border-radius: 999px; background: var(--panel); }
.conn-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--faint); }
.conn[data-state="live"] .conn-dot { background: var(--good); box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.5);
animation: pulse 2s infinite; }
.conn[data-state="live"] { color: var(--good); }
.conn[data-state="down"] .conn-dot { background: var(--bad); }
.conn[data-state="down"] { color: var(--bad); }
@keyframes pulse { 70% { box-shadow: 0 0 0 7px rgba(52, 211, 153, 0); } 100% { box-shadow: 0 0 0 0 rgba(52, 211, 153, 0); } }
/* ---------- layout ---------- */
main { max-width: 1180px; margin: 0 auto; padding: 20px; display: grid; gap: 18px; }
.panel {
background: linear-gradient(var(--panel), var(--bg-2));
border: 1px solid var(--border-soft); border-radius: 16px; padding: 18px;
box-shadow: var(--shadow);
}
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.panel-head h2 { font-size: 14px; margin: 0; color: var(--muted); font-weight: 600;
display: flex; align-items: center; gap: 8px; text-transform: uppercase; letter-spacing: 0.6px; }
.panel-head h2 svg { color: var(--accent); }
/* ---------- hero ---------- */
.hero { display: flex; align-items: center; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
.clock { font-family: var(--mono); font-size: clamp(40px, 11vw, 76px); font-weight: 650;
letter-spacing: 1px; line-height: 1; font-variant-numeric: tabular-nums;
text-shadow: 0 0 34px rgba(34, 211, 238, 0.18); }
.clock-frac { color: var(--accent); font-size: 0.42em; }
.clock-meta { color: var(--muted); margin-top: 8px; font-size: 13.5px; }
.mono { font-family: var(--mono); }
.verdict { text-align: right; }
.stratum-badge { display: inline-flex; align-items: center; gap: 12px; padding: 12px 18px;
border-radius: 14px; border: 1px solid var(--border); background: var(--panel-2); }
.stratum-badge svg { width: 26px; height: 26px; color: var(--faint); }
.stratum-badge[data-ok="true"] { border-color: rgba(52, 211, 153, 0.5);
background: linear-gradient(rgba(52, 211, 153, 0.14), rgba(52, 211, 153, 0.04)); }
.stratum-badge[data-ok="true"] svg { color: var(--good); }
.stratum-num { font-size: 30px; font-weight: 750; font-family: var(--mono); display: block; line-height: 1; }
.stratum-badge[data-ok="true"] .stratum-num { color: var(--good); }
.stratum-word { font-size: 11px; text-transform: uppercase; letter-spacing: 2px; color: var(--muted); }
.ref-line { color: var(--muted); font-size: 13px; margin-top: 8px; }
.ref-line strong { color: var(--text); font-family: var(--mono); }
/* ---------- cards ---------- */
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 12px; }
.card { background: var(--panel); border: 1px solid var(--border-soft); border-radius: 14px; padding: 14px 15px; }
.card-head { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px;
text-transform: uppercase; letter-spacing: 0.4px; }
.card-head svg { width: 15px; height: 15px; color: var(--accent); }
.card-value { font-family: var(--mono); font-size: 25px; font-weight: 650; margin: 7px 0 3px; letter-spacing: 0.3px; }
.card-value.good { color: var(--good); }
.card-value.warn { color: var(--warn); }
.card-value.bad { color: var(--bad); }
.card-sub { color: var(--faint); font-size: 12px; }
.card-sub .ic-inline { width: 12px; height: 12px; vertical-align: -2px; }
/* ---------- sparkline ---------- */
.spark-meta { font: 12px var(--mono); color: var(--muted); }
.spark-big { width: 100%; height: 120px; display: block; }
.spark-zero { stroke: var(--border); stroke-width: 1; stroke-dasharray: 4 4; }
.spark-area { fill: url(#spark-grad); opacity: 0.9; }
.spark-line { fill: none; stroke: var(--accent); stroke-width: 1.6; vector-effect: non-scaling-stroke; }
/* ---------- grid-2 ---------- */
.grid-2 { display: grid; grid-template-columns: 1fr; gap: 18px; }
@media (min-width: 820px) { .grid-2 { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } }
/* ---------- sky ---------- */
.sky-hold { position: relative; }
#sky { width: 100%; height: auto; display: block; }
.sky-ring { fill: none; stroke: var(--border); stroke-width: 1; }
.sky-ring.faint { stroke: var(--border-soft); }
.sky-cross { stroke: var(--border-soft); stroke-width: 1; }
.sky-card-label { fill: var(--faint); font: 600 11px var(--mono); }
.sky-ring-label { fill: var(--faint); font: 10px var(--mono); }
.sat { cursor: pointer; transition: r 0.4s ease; }
.sat-halo { opacity: 0.18; }
.sat-label { fill: #05080c; font: 700 8px var(--mono); pointer-events: none; }
.legend { display: flex; flex-wrap: wrap; gap: 10px; }
.legend span { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--muted); }
.legend i { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
.sky-tip { position: absolute; pointer-events: none; background: #05090e; border: 1px solid var(--border);
border-radius: 8px; padding: 6px 9px; font: 12px/1.35 var(--mono); color: var(--text);
transform: translate(-50%, -120%); white-space: nowrap; z-index: 3; box-shadow: var(--shadow); }
/* ---------- snr bars ---------- */
.snr-panel { display: flex; flex-direction: column; }
.snr { display: flex; align-items: flex-end; gap: 5px; flex: 1; min-height: 190px; overflow-x: auto; padding-top: 6px; }
.snr-bar { flex: 0 0 auto; width: 18px; display: flex; flex-direction: column; align-items: center;
justify-content: flex-end; height: 100%; gap: 4px; }
.snr-fill { width: 100%; border-radius: 4px 4px 2px 2px; min-height: 3px; transition: height 0.5s ease; opacity: 0.55; }
.snr-bar.used .snr-fill { opacity: 1; }
.snr-prn { font: 9px var(--mono); color: var(--faint); }
.snr-empty { color: var(--faint); align-self: center; margin: auto; font-size: 13px; }
/* ---------- table ---------- */
.table-hold { overflow-x: auto; }
.tbl { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.tbl th { text-align: left; color: var(--faint); font-weight: 600; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.5px; padding: 6px 10px; border-bottom: 1px solid var(--border); }
.tbl td { padding: 9px 10px; border-bottom: 1px solid var(--border-soft); font-family: var(--mono); }
.tbl .num { text-align: right; }
.tbl tr.sel td { background: rgba(52, 211, 153, 0.06); }
.tbl tr.refclk td:first-child { color: var(--accent); }
.pill { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: 11px;
border: 1px solid var(--border); color: var(--muted); font-family: var(--mono); }
.pill.selected { color: var(--good); border-color: rgba(52, 211, 153, 0.45); background: rgba(52, 211, 153, 0.08); }
.pill.combined { color: var(--accent); border-color: rgba(34, 211, 238, 0.35); }
/* A noselect refclock (our GPS: labels the second, never chosen) is healthy,
not broken. Render it as quiet information so that red keeps meaning red. */
.pill.reference_only { color: var(--faint); border-color: var(--border-soft); }
.pill.unreachable, .pill.falseticker { color: var(--bad); border-color: rgba(244, 67, 107, 0.35); }
/* ---------- clients ---------- */
.clients { display: flex; flex-wrap: wrap; gap: 10px; }
.client { display: flex; flex-direction: column; gap: 2px; padding: 10px 13px; border: 1px solid var(--border-soft);
border-radius: 11px; background: var(--panel); min-width: 150px; }
.client .addr { font-family: var(--mono); font-size: 13px; }
.client .meta { color: var(--faint); font-size: 11.5px; }
.empty-note { color: var(--faint); font-size: 13px; }
/* ---------- footer ---------- */
.foot { max-width: 1180px; margin: 0 auto; padding: 14px 20px 30px; }
.foot-meta { display: flex; justify-content: space-between; margin-top: 14px;
color: var(--faint); font-size: 12px; font-family: var(--mono); }
/* ---------- the maker's plate ----------
* Clockmakers signed the backplate the brass face only a repairer sees once
* the case is open. This footer is that plate: a double hairline for the plate
* edge, engraved small-caps for the name, and nothing that moves. A signature
* should be quiet. Same markup as the docs site at cuckoo.warehack.ing,
* recolored from brass to this dashboard's cyan.
*/
.ss-plate__link {
display: flex; gap: 16px; align-items: center;
padding: 18px 20px; text-decoration: none; color: var(--muted);
border: 1px solid var(--border); border-radius: 10px;
box-shadow: 0 0 0 3px var(--bg), 0 0 0 4px var(--border-soft), var(--shadow);
background:
radial-gradient(120% 140% at 0% 0%, rgba(34, 211, 238, 0.07), transparent 60%),
var(--panel);
transition: color .2s, border-color .2s;
}
.ss-plate__link:hover { color: var(--text); border-color: var(--accent); }
.ss-plate__logo { flex: 0 0 auto; width: 46px; height: auto; opacity: .85;
transition: opacity .2s; }
.ss-plate__link:hover .ss-plate__logo { opacity: 1; }
.ss-plate__heading { display: block; margin-bottom: 5px; color: var(--text);
font-family: var(--mono); font-size: 12px; font-weight: 600;
text-transform: uppercase; letter-spacing: .14em; }
.ss-plate__body { display: block; font-size: 13px; line-height: 1.55; max-width: 62ch; }
.ss-plate__name { color: var(--accent); }
.ss-plate__cta { display: inline-flex; align-items: center; gap: 5px; margin-top: 8px;
font-size: 12px; color: var(--accent); }
.ss-plate__cta svg { width: 14px; height: 14px; transition: transform .2s; }
.ss-plate__link:hover .ss-plate__cta svg { transform: translateX(2px); }
@media (max-width: 560px) {
.ss-plate__link { flex-direction: column; align-items: flex-start; }
}
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
/* "approx" marker when GPSNTP_POSITION=coarse */
.pos-note { color: var(--faint); font-size: 10px; text-transform: uppercase; letter-spacing: .5px; }

View File

@ -0,0 +1,66 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 75" height="100%" width="100%">
<!-- Gradient Definitions -->
<defs>
<linearGradient id="gradient1" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#60a5fa"></stop>
<stop offset="50%" stop-color="#3b82f6"></stop>
<stop offset="100%" stop-color="#2563eb"></stop>
</linearGradient>
<linearGradient id="gradient2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#93c5fd"></stop>
<stop offset="50%" stop-color="#60a5fa"></stop>
<stop offset="100%" stop-color="#3b82f6"></stop>
</linearGradient>
<linearGradient id="flowGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#2563eb"></stop>
<stop offset="50%" stop-color="#60a5fa"></stop>
<stop offset="100%" stop-color="#2563eb"></stop>
</linearGradient>
<pattern id="circuitPattern" patternUnits="userSpaceOnUse" width="12" height="45" patternTransform="scale(1)">
<rect width="12" height="45" fill="url(#gradient1)"></rect>
<path d="M2,5 h8 M2,5 v5 M10,5 v10 M5,15 h5 M5,15 v10 M3,25 h7 M7,25 v10 M3,35 h4" stroke="#dbeafe" stroke-width="0.5" fill="none" opacity="0.7"></path>
<circle cx="2" cy="5" r="1" fill="#dbeafe" opacity="0.7"></circle>
<circle cx="10" cy="5" r="1" fill="#dbeafe" opacity="0.7"></circle>
<circle cx="5" cy="15" r="1" fill="#dbeafe" opacity="0.7"></circle>
<circle cx="3" cy="25" r="1" fill="#dbeafe" opacity="0.7"></circle>
<circle cx="7" cy="35" r="1" fill="#dbeafe" opacity="0.7"></circle>
</pattern>
<pattern id="binaryPattern" patternUnits="userSpaceOnUse" width="12" height="35" patternTransform="scale(1)">
<rect width="12" height="35" fill="#2563eb"></rect>
<text x="3" y="8" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">10</text>
<text x="3" y="14" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">01</text>
<text x="3" y="20" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">11</text>
<text x="3" y="26" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">00</text>
<text x="3" y="32" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">10</text>
</pattern>
<pattern id="punchCardPattern" patternUnits="userSpaceOnUse" width="12" height="45" patternTransform="scale(1)">
<rect width="12" height="45" fill="#3b82f6"></rect>
<path d="M0,5 h12 M0,10 h12 M0,15 h12 M0,20 h12 M0,25 h12 M0,30 h12 M0,35 h12 M0,40 h12" stroke="#93c5fd" stroke-width="0.2" fill="none"></path>
<circle cx="3" cy="7" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="9" cy="7" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="6" cy="12" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="3" cy="17" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="9" cy="22" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="6" cy="27" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="3" cy="32" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="9" cy="37" r="1" fill="#1e3a8a" opacity="0.9"></circle>
</pattern>
</defs>
<!-- Flow lines behind bars -->
<g opacity="0.3">
<path d="M6,50 C20,40 40,55 48,35 C56,50 75,30 90,55" stroke="url(#flowGradient)" stroke-width="1" fill="none"></path>
<path d="M6,60 C30,50 50,40 70,55 C80,45 90,60 90,60" stroke="url(#flowGradient)" stroke-width="1" fill="none"></path>
</g>
<!-- Bar chart graphic - the "towers" -->
<g>
<rect x="0" y="45" width="12" height="25" rx="1" ry="1" fill="url(#binaryPattern)"></rect>
<rect x="14" y="35" width="12" height="35" rx="1" ry="1" fill="#2563eb"></rect>
<rect x="28" y="25" width="12" height="45" rx="1" ry="1" fill="url(#circuitPattern)"></rect>
<rect x="42" y="20" width="12" height="50" rx="1" ry="1" fill="url(#gradient2)"></rect>
<rect x="56" y="25" width="12" height="45" rx="1" ry="1" fill="url(#punchCardPattern)"></rect>
<rect x="70" y="35" width="12" height="35" rx="1" ry="1" fill="url(#circuitPattern)"></rect>
<rect x="84" y="45" width="12" height="25" rx="1" ry="1" fill="#2563eb"></rect>
<!-- Connecting glow -->
<path d="M12,55 L14,55 M26,45 L28,45 M40,40 L42,40 M54,40 L56,40 M82,55 L84,55" stroke="#bfdbfe" stroke-width="0.8" stroke-opacity="0.6"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

19
docs-site/.dockerignore Normal file
View File

@ -0,0 +1,19 @@
node_modules/
dist/
.astro/
.env
.env.local
.env.production
.git/
.gitignore
*.log
.DS_Store
.vscode/
.idea/
# don't pull in repo-root artifacts
../artifacts/
README.md

9
docs-site/.env.example Normal file
View File

@ -0,0 +1,9 @@
# The Cuckoo Escapement — docs-site environment.
# Note: no MODE var. The compose *profile* is the mode switch
# (the warehacking reference doc is stale on this point).
COMPOSE_PROJECT_NAME=cuckoo-escapement-docs
# Production: cuckoo.warehack.ing
# Local dev: cuckoo.l.warehack.ing (internal only — never reference publicly)
DOMAIN=cuckoo.warehack.ing

22
docs-site/.gitignore vendored Normal file
View File

@ -0,0 +1,22 @@
# build output
dist/
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment
.env
.env.local
.env.production
# editor
.vscode/
.idea/
.DS_Store

47
docs-site/Dockerfile Normal file
View File

@ -0,0 +1,47 @@
# Multi-stage build for the cuckoo-escapement docs site.
#
# Stages:
# - base : Node, pnpm/npm tooling, deps installed
# - dev : runs `astro dev` with HMR for local development
# - builder : produces the static `dist/`
# - prod : caddy:alpine that serves `dist/` (no Node at runtime)
#
# `docker compose --profile dev up` → dev target
# `docker compose up` (no profile) → prod target
# Pinned through the mirror.gcr.io pass-through to dodge intermittent
# Docker Hub TLS hiccups during builds. Same content, more reliable
# fetch path. The `docker pull` resolves identically against either.
FROM mirror.gcr.io/library/node:22-alpine AS base
WORKDIR /app
COPY package.json ./
RUN --mount=type=cache,target=/root/.npm \
npm install --no-audit --no-fund
# ----- dev: astro dev server with HMR -----
FROM base AS dev
# Astro's binary is in node_modules/.bin — package.json's `dev` script
# already binds to 0.0.0.0 for HMR-behind-Caddy.
COPY . .
ENV ASTRO_TELEMETRY_DISABLED=1
EXPOSE 4321
CMD ["npm", "run", "dev"]
# ----- builder: produce dist/ -----
FROM base AS builder
COPY . .
ENV ASTRO_TELEMETRY_DISABLED=1
RUN npm run build
# ----- prod: caddy serves the static build -----
FROM mirror.gcr.io/library/caddy:2-alpine AS prod
# Caddyfile is intentionally minimal — caddy-docker-proxy on the host
# handles TLS, routing, and the public-facing reverse proxy. This
# container just serves files locally; the proxy points at it.
RUN mkdir -p /srv/docs
COPY --from=builder /app/dist /srv/docs
RUN printf ':80 {\n\troot * /srv/docs\n\tfile_server\n\ttry_files {path} {path}/ /404.html\n\tencode zstd gzip\n}\n' > /etc/caddy/Caddyfile
EXPOSE 80

59
docs-site/Makefile Normal file
View File

@ -0,0 +1,59 @@
# cuckoo-escapement docs — make targets follow the warehacking cookie-cutter.
#
# `make prod` builds the static site + brings up Caddy serving it.
# `make dev` starts the Astro dev server with HMR behind Caddy.
# `make down` stops both.
SHELL := /usr/bin/env bash
.SHELLFLAGS := -eu -o pipefail -c
.DEFAULT_GOAL := help
.PHONY: help
help: ## Show this help
@awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_-]+:.*##/ {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
.PHONY: prod
prod: ## Build + run the production docs container (Caddy serves dist/)
docker compose up -d --build docs
.PHONY: dev
dev: ## Run the Astro dev server with HMR (--profile dev)
docker compose --profile dev up --build docs-dev
.PHONY: down
down: ## Stop and remove the docs containers
docker compose --profile dev down
docker compose down
.PHONY: logs
logs: ## Tail logs (works for whichever profile is up)
docker compose logs -f --tail=100
.PHONY: build
build: ## Build the static site WITHOUT bringing up Caddy (CI gate)
docker compose build docs
.PHONY: shell
shell: ## Open a shell in the running dev container (debugging)
docker compose exec docs-dev sh
# ---- Production deploy --------------------------------------------------
#
# `make deploy` pulls origin/main on the warehack.ing prod host and rebuilds
# the docs container. Agent-forwarding (`-A`) lets the remote `git pull` use
# the operator's local SSH key for Gitea — nothing persistent is provisioned
# on the deploy host.
#
# Override DEPLOY_HOST / DEPLOY_PATH for a different deployment without
# editing this file. The defaults are the warehack.ing cookie-cutter shape
# (see ~/.claude/references/warehacking.md).
DEPLOY_HOST ?= warehack-ing@warehack.ing
DEPLOY_PATH ?= ~/cuckoo-escapement
.PHONY: deploy
deploy: ## Pull main + rebuild the docs container on the prod host
@echo "==> deploying $(DEPLOY_HOST):$(DEPLOY_PATH)"
ssh -A $(DEPLOY_HOST) "cd $(DEPLOY_PATH) && git fetch origin main && git reset --hard origin/main && cd docs-site && make prod"
@echo "==> sanity check"
@curl -s -o /dev/null -w " HTTP %{http_code} %{url_effective}\n" "https://cuckoo.warehack.ing/explanation/bug-detection/"

105
docs-site/astro.config.mjs Normal file
View File

@ -0,0 +1,105 @@
// The Cuckoo Escapement — Starlight, diátaxis-shaped.
//
// This site is a FIELD REPORT, not a tutorial. The build guides already exist
// (geerlingguy/time-pi, josh-blake/pixie) and they're good. What doesn't exist
// is a record of everything they get wrong on a Pi 4, with numbers. So the IA is
// deliberately inverted from the usual docs site: heavy on Explanation and
// Reference, and there is no Tutorial section at all.
//
// Telemetry + devToolbar off per project convention. The HMR block is required
// when the dev server runs behind Caddy (TLS-terminating proxy) — without an
// explicit host/protocol/clientPort, Vite's WebSocket drops every ~10s.
//
// Site URL comes from DOMAIN so one image serves both cuckoo.warehack.ing (prod)
// and cuckoo.l.warehack.ing (local dev).
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
import starlight from "@astrojs/starlight";
import { defineConfig } from "astro/config";
import starlightLinksValidator from "starlight-links-validator";
const domain = process.env.DOMAIN ?? "cuckoo.warehack.ing";
export default defineConfig({
site: `https://${domain}`,
telemetry: false,
devToolbar: { enabled: false },
vite: {
server: {
host: "0.0.0.0",
hmr: { host: domain, protocol: "wss", clientPort: 443 },
},
},
integrations: [
starlight({
title: "The Cuckoo Escapement",
description:
"What the Raspberry Pi time-server guides get wrong, and the numbers to prove it. " +
"GPS Stratum 1 on a Pi 4: PREEMPT_RT makes PPS jitter worse, the PPS interrupt " +
"cannot be pinned, PTP is impossible, and your dashboard is taxing your clock.",
// The mark IS the word "cuckoo" — a rebus. replacesTitle stops Starlight
// rendering the title text beside it (which would read "…escapement The
// Cuckoo Escapement"). The SVG's aria-label carries the full name.
logo: { src: "./src/assets/logo.svg", replacesTitle: true },
favicon: "/favicon.svg",
customCss: ["./src/styles/brass.css"],
social: [
{
icon: "seti:git",
label: "Source",
href: "https://git.supported.systems/warehack.ing/cuckoo-escapement",
},
],
// Diátaxis, but weighted for a field report. Explanation leads, because the
// whole point is WHY the received wisdom is wrong. There is no Tutorial —
// that would be the one thing the world does not need another of.
// NB: Starlight >=0.39 removed the inline {label, autogenerate} shorthand;
// it must be nested as {label, items: [{autogenerate}]}.
sidebar: [
{
label: "Start here",
items: [
{ label: "What this is (and isn't)", slug: "index" },
{ label: "The findings, in brief", slug: "findings" },
],
},
{
label: "Explanation",
items: [{ autogenerate: { directory: "explanation" } }],
},
{
label: "Reference",
items: [{ autogenerate: { directory: "reference" } }],
},
{
label: "How-to",
items: [{ autogenerate: { directory: "how-to" } }],
},
],
components: {
// Appends the "A Supported Systems Joint" badge under Starlight's default
// footer, without rewriting the component.
Footer: "./src/components/Footer.astro",
},
plugins: [
starlightLinksValidator({
// Broken internal links fail the build rather than shipping silently.
errorOnRelativeLinks: false,
}),
],
pagination: true,
lastUpdated: true,
}),
mdx(),
sitemap(),
],
});

View File

@ -0,0 +1,73 @@
# cuckoo-escapement docs site — two profiles.
#
# Default (no --profile flag):
# prod-style — Caddy serves the built dist/. Use for production-like
# deploys (the public site at cuckoo.warehack.ing runs this).
#
# --profile dev:
# Astro dev server with HMR. Volume mounts on src/ so edits hot-reload.
# The Vite HMR WebSocket is configured in astro.config.mjs to work
# behind the caddy-docker-proxy TLS-terminating front-end — see the
# `caddy.reverse_proxy.*` labels below for the WebSocket-friendly
# timeout configuration.
#
# Both services attach to the external `caddy` network and expose
# themselves to caddy-docker-proxy via labels. Edit DOMAIN in .env to
# switch between cuckoo.warehack.ing (prod) and cuckoo.l.warehack.ing
# (local-dev tier).
services:
docs:
profiles: ["prod", ""]
build:
context: .
target: prod
image: cuckoo-escapement-docs:prod
container_name: cuckoo-escapement-docs
restart: unless-stopped
networks:
- caddy
labels:
caddy: ${DOMAIN:-cuckoo.warehack.ing}
caddy.reverse_proxy: "{{upstreams 80}}"
# encode + gzip already in the container; let caddy pass through.
docs-dev:
profiles: ["dev"]
build:
context: .
target: dev
image: cuckoo-escapement-docs:dev
container_name: cuckoo-escapement-docs-dev
restart: unless-stopped
environment:
- DOMAIN=${DOMAIN:-cuckoo.l.warehack.ing}
- ASTRO_TELEMETRY_DISABLED=1
volumes:
# Hot-reload bind mounts. node_modules stays inside the container
# so host platform mismatches don't break native deps.
- ./astro.config.mjs:/app/astro.config.mjs:ro
- ./tsconfig.json:/app/tsconfig.json:ro
- ./src:/app/src
- ./public:/app/public
networks:
- caddy
labels:
caddy: ${DOMAIN:-cuckoo.l.warehack.ing}
caddy.reverse_proxy: "{{upstreams 4321}}"
# Vite HMR over WebSocket. Caddy's defaults close "idle" WS
# connections after ~10-15s; HMR doesn't send app-level pings, so
# we need explicit long-lived timeouts. Required for Caddy 2.10+
# (HTTP/2 WS fix). See ~/.claude/references/web-frontend.md.
caddy.reverse_proxy.flush_interval: "-1"
caddy.reverse_proxy.transport: "http"
caddy.reverse_proxy.transport.read_timeout: "0"
caddy.reverse_proxy.transport.write_timeout: "0"
caddy.reverse_proxy.transport.keepalive: "5m"
caddy.reverse_proxy.transport.keepalive_idle_conns: "10"
caddy.reverse_proxy.stream_timeout: "24h"
caddy.reverse_proxy.stream_close_delay: "5s"
networks:
caddy:
external: true

6461
docs-site/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
docs-site/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "cuckoo-escapement-docs",
"type": "module",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "astro dev --host 0.0.0.0",
"build": "astro build",
"preview": "astro preview --host 0.0.0.0",
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "^5.0.4",
"@astrojs/sitemap": "^3.7.2",
"@astrojs/starlight": "^0.39.2",
"astro": "^6.3.1",
"sharp": "^0.34.0",
"starlight-links-validator": "^0.24.0"
}
}

View File

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="The Cuckoo Escapement">
<rect width="32" height="32" rx="6" fill="#0b0d10"/>
<path d="M 12.67 4.99 L 14.64 1.56 L 17.36 1.56 L 19.33 4.99 L 19.77 5.14 L 23.38 3.52 L 25.59 5.12 L 25.17 9.06 L 25.44 9.43 L 29.31 10.24 L 30.15 12.84 L 27.50 15.77 L 27.50 16.23 L 30.15 19.16 L 29.31 21.76 L 25.44 22.57 L 25.17 22.94 L 25.59 26.88 L 23.38 28.48 L 19.77 26.86 L 19.33 27.01 L 17.36 30.44 L 14.64 30.44 L 12.67 27.01 L 12.23 26.86 L 8.62 28.48 L 6.41 26.88 L 6.83 22.94 L 6.56 22.57 L 2.69 21.76 L 1.85 19.16 L 4.50 16.23 L 4.50 15.77 L 1.85 12.84 L 2.69 10.24 L 6.56 9.43 L 6.83 9.06 L 6.41 5.12 L 8.62 3.52 L 12.23 5.14 Z" fill="none" stroke="#d9a441" stroke-width="1.3" stroke-linejoin="round"/>
<circle cx="16" cy="16" r="10" fill="none" stroke="#d9a441" stroke-width="1" opacity=".7"/>
<g fill="#e8e4dc">
<path d="M 9 21 L 3.5 17.5 L 8.5 16 Z"/>
<ellipse cx="14.5" cy="18" rx="6" ry="4.4"/>
<circle cx="20.5" cy="14" r="3.5"/>
<path d="M 23 12.2 L 29 12.4 L 23.5 14.8 Z"/>
<path d="M 23 15.3 L 28.6 16.8 L 22.6 16.4 Z"/>
</g>
<circle cx="21.3" cy="13.2" r=".7" fill="#0b0d10"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,66 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 75" height="100%" width="100%">
<!-- Gradient Definitions -->
<defs>
<linearGradient id="gradient1" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#60a5fa"></stop>
<stop offset="50%" stop-color="#3b82f6"></stop>
<stop offset="100%" stop-color="#2563eb"></stop>
</linearGradient>
<linearGradient id="gradient2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#93c5fd"></stop>
<stop offset="50%" stop-color="#60a5fa"></stop>
<stop offset="100%" stop-color="#3b82f6"></stop>
</linearGradient>
<linearGradient id="flowGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#2563eb"></stop>
<stop offset="50%" stop-color="#60a5fa"></stop>
<stop offset="100%" stop-color="#2563eb"></stop>
</linearGradient>
<pattern id="circuitPattern" patternUnits="userSpaceOnUse" width="12" height="45" patternTransform="scale(1)">
<rect width="12" height="45" fill="url(#gradient1)"></rect>
<path d="M2,5 h8 M2,5 v5 M10,5 v10 M5,15 h5 M5,15 v10 M3,25 h7 M7,25 v10 M3,35 h4" stroke="#dbeafe" stroke-width="0.5" fill="none" opacity="0.7"></path>
<circle cx="2" cy="5" r="1" fill="#dbeafe" opacity="0.7"></circle>
<circle cx="10" cy="5" r="1" fill="#dbeafe" opacity="0.7"></circle>
<circle cx="5" cy="15" r="1" fill="#dbeafe" opacity="0.7"></circle>
<circle cx="3" cy="25" r="1" fill="#dbeafe" opacity="0.7"></circle>
<circle cx="7" cy="35" r="1" fill="#dbeafe" opacity="0.7"></circle>
</pattern>
<pattern id="binaryPattern" patternUnits="userSpaceOnUse" width="12" height="35" patternTransform="scale(1)">
<rect width="12" height="35" fill="#2563eb"></rect>
<text x="3" y="8" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">10</text>
<text x="3" y="14" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">01</text>
<text x="3" y="20" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">11</text>
<text x="3" y="26" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">00</text>
<text x="3" y="32" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">10</text>
</pattern>
<pattern id="punchCardPattern" patternUnits="userSpaceOnUse" width="12" height="45" patternTransform="scale(1)">
<rect width="12" height="45" fill="#3b82f6"></rect>
<path d="M0,5 h12 M0,10 h12 M0,15 h12 M0,20 h12 M0,25 h12 M0,30 h12 M0,35 h12 M0,40 h12" stroke="#93c5fd" stroke-width="0.2" fill="none"></path>
<circle cx="3" cy="7" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="9" cy="7" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="6" cy="12" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="3" cy="17" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="9" cy="22" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="6" cy="27" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="3" cy="32" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="9" cy="37" r="1" fill="#1e3a8a" opacity="0.9"></circle>
</pattern>
</defs>
<!-- Flow lines behind bars -->
<g opacity="0.3">
<path d="M6,50 C20,40 40,55 48,35 C56,50 75,30 90,55" stroke="url(#flowGradient)" stroke-width="1" fill="none"></path>
<path d="M6,60 C30,50 50,40 70,55 C80,45 90,60 90,60" stroke="url(#flowGradient)" stroke-width="1" fill="none"></path>
</g>
<!-- Bar chart graphic - the "towers" -->
<g>
<rect x="0" y="45" width="12" height="25" rx="1" ry="1" fill="url(#binaryPattern)"></rect>
<rect x="14" y="35" width="12" height="35" rx="1" ry="1" fill="#2563eb"></rect>
<rect x="28" y="25" width="12" height="45" rx="1" ry="1" fill="url(#circuitPattern)"></rect>
<rect x="42" y="20" width="12" height="50" rx="1" ry="1" fill="url(#gradient2)"></rect>
<rect x="56" y="25" width="12" height="45" rx="1" ry="1" fill="url(#punchCardPattern)"></rect>
<rect x="70" y="35" width="12" height="35" rx="1" ry="1" fill="url(#circuitPattern)"></rect>
<rect x="84" y="45" width="12" height="25" rx="1" ry="1" fill="#2563eb"></rect>
<!-- Connecting glow -->
<path d="M12,55 L14,55 M26,45 L28,45 M40,40 L42,40 M54,40 L56,40 M82,55 L84,55" stroke="#bfdbfe" stroke-width="0.8" stroke-opacity="0.6"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 78" role="img" aria-label="The Cuckoo Escapement">
<style>
.mono { font-family: "JetBrains Mono","SF Mono",Menlo,monospace; font-weight: 700; }
.cream { fill: #e8e4dc; }
.gear { fill: none; stroke: #d9a441; stroke-width: 1.9; stroke-linejoin: round; }
.rim { fill: none; stroke: #d9a441; stroke-width: 1.3; opacity: .8; }
.tick { stroke: #d9a441; stroke-width: 1.4; opacity: .5; stroke-linecap: round; }
.head { fill: #e8e4dc; }
.eye { fill: #0b0d10; }
</style>
<!-- one ring, two readings: gear teeth outside (escapement), hour ticks inside (dial) -->
<path d="M 30.61 13.60 L 34.99 6.14 L 41.01 6.14 L 45.39 13.60 L 46.36 13.91 L 54.29 10.46 L 59.16 14.00 L 58.33 22.60 L 58.93 23.43 L 67.37 25.29 L 69.23 31.02 L 63.49 37.49 L 63.49 38.51 L 69.23 44.98 L 67.37 50.71 L 58.93 52.57 L 58.33 53.40 L 59.16 62.00 L 54.29 65.54 L 46.36 62.09 L 45.39 62.40 L 41.01 69.86 L 34.99 69.86 L 30.61 62.40 L 29.64 62.09 L 21.71 65.54 L 16.84 62.00 L 17.67 53.40 L 17.07 52.57 L 8.63 50.71 L 6.77 44.98 L 12.51 38.51 L 12.51 37.49 L 6.77 31.02 L 8.63 25.29 L 17.07 23.43 L 17.67 22.60 L 16.84 14.00 L 21.71 10.46 L 29.64 13.91 Z" class="gear"/>
<circle cx="38.0" cy="38.0" r="23.5" class="rim"/>
<g class="tick">
<line x1="38.00" y1="15.50" x2="38.00" y2="18.50"/>
<line x1="49.25" y1="18.51" x2="47.75" y2="21.11"/>
<line x1="57.49" y1="26.75" x2="54.89" y2="28.25"/>
<line x1="60.50" y1="38.00" x2="57.50" y2="38.00"/>
<line x1="57.49" y1="49.25" x2="54.89" y2="47.75"/>
<line x1="49.25" y1="57.49" x2="47.75" y2="54.89"/>
<line x1="38.00" y1="60.50" x2="38.00" y2="57.50"/>
<line x1="26.75" y1="57.49" x2="28.25" y2="54.89"/>
<line x1="18.51" y1="49.25" x2="21.11" y2="47.75"/>
<line x1="15.50" y1="38.00" x2="18.50" y2="38.00"/>
<line x1="18.51" y1="26.75" x2="21.11" y2="28.25"/>
<line x1="26.75" y1="18.51" x2="28.25" y2="21.11"/>
</g>
<!-- the cuckoo, head only, beak open mid-call -->
<g class="head">
<!-- crest: three feathers, swept back. this is what says "bird" -->
<path d="M 30 26 L 27 16 L 35 23 Z"/>
<path d="M 36 23 L 36 13 L 42 22 Z"/>
<path d="M 42 23 L 46 15 L 47 25 Z"/>
<circle cx="37" cy="39" r="13.5"/> <!-- big round head -->
<path d="M 47 33.5 L 62 34.5 L 48.5 40 Z"/> <!-- upper beak: SHORT + chunky -->
<path d="M 47.5 42 L 60 46.5 L 46 44 Z"/> <!-- lower beak, gape open -->
</g>
<circle cx="40.5" cy="35.5" r="2.2" class="eye"/>
<text x="92" y="52" class="mono cream" font-size="33">escapement</text>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,13 @@
---
// Starlight Footer override. We wrap <Default /> so all upstream behavior —
// last-updated stamp, prev/next pagination — is preserved, then append the
// maker's plate below it.
//
// Same pattern (Default + extension) used across the other warehack.ing sites,
// which keeps Starlight upgrades cheap.
import Default from "@astrojs/starlight/components/Footer.astro";
import SupportedSystemsBadge from "./SupportedSystemsBadge.astro";
---
<Default><slot /></Default>
<SupportedSystemsBadge />

View File

@ -0,0 +1,47 @@
---
// "A Supported Systems Joint" — the maker's plate.
//
// Clockmakers signed the BACKPLATE: the flat brass face of the movement that
// only shows when you open the case. It's the part a repairer sees a century
// later, not the part the owner looks at. A site footer is the same thing —
// the back of the movement — so this is an engraved plate rather than a
// marketing strip, and nothing on it moves. A signature should be quiet.
//
// Shared verbatim with the dashboard's footer (static HTML/CSS port, same
// markup and class names). Styles live in src/styles/brass.css.
---
<aside class="ss-plate" aria-label="Supported Systems">
<a class="ss-plate__link" href="https://supported.systems" rel="noopener">
<img
class="ss-plate__logo"
src="/supported-systems-logo.svg"
alt=""
width="52"
height="39"
loading="lazy"
/>
<span class="ss-plate__copy">
<span class="ss-plate__heading">A Supported Systems Joint</span>
<span class="ss-plate__body">
The Cuckoo Escapement is built and maintained by
<span class="ss-plate__name">Supported Systems</span> &mdash; a boutique
software studio focused on thoughtful, user-first technology. We measure
things before we believe them.
</span>
<span class="ss-plate__cta">
Visit supported.systems
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<path
d="M4 8h7M8 5l3 3-3 3"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"></path>
</svg>
</span>
</span>
</a>
</aside>

View File

@ -0,0 +1,8 @@
// Starlight content collection — required for content.config.ts in Astro 6.x.
import { defineCollection } from "astro:content";
import { docsLoader } from "@astrojs/starlight/loaders";
import { docsSchema } from "@astrojs/starlight/schema";
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
};

View File

@ -0,0 +1,51 @@
---
title: cpu0 is sacred
description: The CPU map this box lives by — discovered by measurement, not designed.
sidebar:
order: 6
---
This is the layout the machine ended up with:
```
cpu0 ──── PPS interrupt. Nothing else. Ever.
cpu1 ──── web tier (dashboard, Caddy)
cpu2 ──── chronyd (isolated)
cpu3 ──── gpsd + UART IRQ thread (isolated)
```
Not one of those four lines came from a guide. Each came from a measurement that
contradicted an assumption.
- **cpu0 holds the PPS interrupt** because the Pi 4's GPIO mux
[physically refuses to move it](/explanation/the-interrupt-you-cannot-move/).
That isn't a preference; it's a constraint we cannot configure away.
- **cpu2 and cpu3 are isolated** (`isolcpus=2,3`) for the timing daemons, which
want determinism more than throughput.
- **cpu1 got the web tier** only after a benchmark caught it
[taxing the clock 36% from cpu0](/explanation/the-observer-effect/).
## The rule this implies
Because the PPS interrupt cannot be relocated, **cpu0 is load-bearing for
precision in a way no other core is**. Anything you schedule there is competing
directly with the timestamp.
So: every service on this box — an exporter, a log shipper, a backup job, a cron
entry, anything you add six months from now — belongs on cpu13.
```ini
[Service]
CPUAffinity=1
Nice=10
```
It's a one-line tax, and the alternative is a slow, invisible erosion of the one
number the machine exists to produce.
:::note[Isolation alone did nothing]
Worth saying plainly: `isolcpus` on its own did **not** improve PPS jitter, and
plausibly made it worse — by evacuating cpu2/3, we concentrated *everything else*
onto cpu0/1, which is where the PPS interrupt lives. Isolation only pays once you
also keep the evacuated work away from the PPS core.
:::

View File

@ -0,0 +1,52 @@
---
title: Why PTP is off the table on a Pi 4
description: PTP's entire value is hardware timestamping. The Pi 4's NIC has no PTP hardware clock. Software PTP is a worse NTP.
sidebar:
order: 3
---
The reference builds all reach for **PTP** (IEEE 1588), and they're right to: on
the right hardware it's dramatically better than NTP.
The Pi 4 is not the right hardware. One command settles it:
```console
$ ethtool -T eth0
Capabilities:
software-transmit
software-receive
software-system-clock
PTP Hardware Clock: none
Hardware Transmit Timestamp Modes: none
Hardware Receive Filter Modes: none
```
**`PTP Hardware Clock: none`.** There isn't one. There's no `/dev/ptp0` to open.
## Why that's fatal rather than inconvenient
PTP's whole advantage is **hardware timestamping**: the network card itself
stamps the packet as it crosses the wire, in silicon, outside the operating
system. That's what removes kernel scheduling, driver latency, and queueing from
the measurement, and it's why PTP reaches nanoseconds where NTP reaches
microseconds.
Take the hardware clock away and PTP is just... a protocol. Software-timestamped
PTP has the packets stamped by the *kernel*, on the *CPU*, subject to exactly the
scheduling jitter you were trying to escape. It is a more complicated NTP with
worse tooling.
## But the guides say the Pi 4's PHY supports PTP
They do, and the *chip* does — the BCM54213PE PHY has PTP capability on paper.
It doesn't matter. The Pi 4's `bcmgenet` MAC driver doesn't expose a PHC, so
Linux has nothing to give you. And the reference builds that make PTP work feed
the PPS into the NIC through a **SYNC pin that only the CM4/CM5 break out** — a
regular Pi 4 board doesn't route it anywhere you can reach.
## So don't chase it
We spent real time on this before running `ethtool -T`, which we should have run
first. If your board reports `PTP Hardware Clock: none`, close the tab. Put the
effort into the PPS path instead — that's where the nanoseconds actually are, and
[it needs the help](/explanation/preempt-rt-made-it-worse/).

View File

@ -0,0 +1,113 @@
---
title: Why PREEMPT_RT made it worse
description: The realtime kernel force-threads interrupt handlers. The PPS driver takes its timestamp inside its handler. Those two facts multiply badly.
sidebar:
order: 1
---
Installing a realtime kernel is the prestige move in every Pi time-server guide.
It is also, on its own, the single most damaging thing we did.
| | raw PPS jitter (σ) | peak-to-peak |
|---|---|---|
| Stock kernel | 2134 ns | 11 µs |
| **PREEMPT_RT** | **6947 ns** | **38 µs** |
Three times worse. Not marginally, not within noise — **three times.**
## Why
PREEMPT_RT achieves its determinism by **force-threading interrupt handlers**.
Instead of running in hard-IRQ context (immediately, uninterruptibly, nanoseconds
after the electrical edge), a handler becomes a schedulable kernel thread that
the scheduler runs *when it gets around to it*.
For most drivers this is a good trade: you lose a little latency, you gain the
ability to preempt long-running handlers, and the *worst case* improves. That's
the entire pitch of realtime Linux, and it's a good pitch.
But look at what `pps-gpio` actually does in its handler:
```c
static irqreturn_t pps_gpio_irq_handler(int irq, void *data)
{
...
pps_get_ts(&ts); /* ← THE TIMESTAMP IS TAKEN HERE */
pps_event(info->pps, &ts, ...);
...
}
```
**The handler is the measurement.** `pps_get_ts()` is the whole point of the
driver — it captures *when the pulse arrived*. Everything downstream, every
nanosecond of accuracy chrony reports, descends from that one call.
So when PREEMPT_RT threads this handler, it doesn't defer some *work*. It defers
**the act of looking at the clock**. The timestamp is no longer taken at the
electrical edge; it's taken after thread-wakeup latency — microseconds later, and
*variably* later, which is worse.
We didn't make the system more deterministic. We inserted a scheduler between the
pulse and the clock.
## You can see it happen
On a stock kernel, the PPS interrupt has no thread at all:
```console
$ ps -eo pid,class,rtprio,psr,comm | grep irq/41
(nothing — it runs in hard-irq context)
```
Boot PREEMPT_RT and it materialises:
```console
$ ps -eo pid,class,rtprio,psr,comm | grep irq/41
239 RR 50 3 irq/41-pps@12.-1
```
That thread is the problem. It is also — and this is the cruel part — *exactly
what the guides tell you to go and pin to an isolated core.* You can only
`taskset` a thread. The advice to isolate the PPS IRQ **requires** the very
threading that destroys the timestamp.
<div />
:::danger[The trap, stated plainly]
**You can pin the PPS interrupt, or you can timestamp it fast. You cannot do
both.** Threading is the price of pinning, and on our board that price was 12×
the accuracy. A hard-IRQ handler on a *busy* CPU 0 beat a threaded-and-pinned one
on a *quiet, isolated* CPU 3 — by a mile.
:::
## The fix
Tell the kernel this particular handler must not be threaded:
```c
flags |= IRQF_NO_THREAD;
```
That's it. The timestamp goes back to hard-IRQ context, at the electrical edge,
while the rest of the system keeps every benefit of PREEMPT_RT.
| | RMS offset | raw PPS jitter |
|---|---|---|
| Stock kernel | 440 ns | 2134 ns |
| PREEMPT_RT (unpatched) | 2468 ns | 6947 ns |
| **PREEMPT_RT + `IRQF_NO_THREAD`** | **199 ns** | 2568 ns |
The patch is four lines and it's [here](/reference/the-patch/). It is, as far as
we can tell, not applied anywhere — which means **anyone running GPIO-based PPS
on a realtime kernel today is silently eating microseconds of jitter** and has no
reason to suspect it, because everything *looks* fine. chrony still says Stratum 1.
The dashboard still says locked. The number is just quietly worse.
## The lesson underneath
The realtime kernel is not "the fast kernel." It is the *predictable* kernel, and
it buys predictability by making things schedulable. If the thing you care about
is **a measurement taken inside an interrupt handler**, making it schedulable is
precisely the wrong move.
Nothing about that is obvious from the outside. It is only obvious from a number.

View File

@ -0,0 +1,75 @@
---
title: The interrupt you cannot move
description: On a Pi 4, GPIO interrupts are demuxed through pinctrl-bcm2835 and refuse an smp_affinity. The IRQ-isolation advice is unachievable here.
sidebar:
order: 2
---
Every guide says the same thing: park the PPS interrupt on its own isolated CPU,
give it realtime priority, and keep the noisy world away from it.
On a Raspberry Pi 4, **you cannot.**
```console
$ echo 3 > /proc/irq/41/smp_affinity_list
tee: /proc/irq/41/smp_affinity_list: Operation not permitted
```
## Why
Your PPS arrives on a **GPIO pin**, and GPIO interrupts on the BCM2711 are not
first-class interrupts. They are **demultiplexed** through the GPIO controller:
```console
$ grep -E 'pps|uart' /proc/interrupts
40: 3532866 0 0 0 GICv2 153 Level uart-pl011
41: 104835 0 0 0 pinctrl-bcm2835 18 Edge pps@12.-1
```
Look at the difference. The UART is a **GICv2** interrupt — a real line into the
interrupt controller, and it takes an affinity happily. The PPS is a
**`pinctrl-bcm2835`** interrupt — one of dozens of GPIO lines multiplexed behind
a single parent IRQ. There is no per-line steering to give. Every GPIO interrupt
lands wherever the GPIO controller's parent lands, together.
So the PPS interrupt goes where it goes, and no amount of configuration moves it.
## The cruel bit
There *is* one way to gain control of it: **PREEMPT_RT force-threads interrupt
handlers**, and a thread can be `taskset` anywhere. Boot a realtime kernel and the
thing you couldn't pin becomes pinnable:
```console
$ ps -eo pid,class,rtprio,psr,comm | grep irq/41
239 RR 50 3 irq/41-pps@12.-1 ← RT priority, isolated CPU 3. It worked!
```
The guides are vindicated. Except it's a trap, because
[threading the handler is what destroys the
timestamp](/explanation/preempt-rt-made-it-worse/) — `pps-gpio` takes its
measurement *inside* that handler, so putting it behind the scheduler costs more
than the isolation ever gives back.
:::danger[Pin it, or timestamp it fast. Not both.]
- **Threaded + pinned to a quiet isolated core:** RMS offset **2468 ns**
- **Hard-IRQ + unpinned on a busy CPU 0:** RMS offset **199 ns**
The fast handler on the *noisy* core beat the scheduled handler on the *quiet*
core by more than 12×. Interrupt latency dominates CPU contention, and it isn't
close.
:::
## What to do instead
Accept that the PPS interrupt lives on CPU 0, and then **treat CPU 0 as sacred**.
You can't move the interrupt, but you can move *everything else*:
```ini
# every other service gets an affinity that isn't 0
[Service]
CPUAffinity=1
```
That's the whole strategy. It's not the one in the guides, but it's the one the
hardware permits. [→ cpu0 is sacred](/explanation/cpu0-is-sacred/)

View File

@ -0,0 +1,82 @@
---
title: The observer effect
description: Our monitoring dashboard cost 36% more PPS jitter. The instrument was bending the measurement.
sidebar:
order: 5
---
We built a status dashboard for the time server. Then somebody asked the obvious
question nobody asks: **is the dashboard hurting the clock?**
It was. By 36%.
## The measurement
A/B/A, sixty-two seconds of raw `ppstest` per round, with the middle round as the
control and the third to prove it wasn't drift:
| Round | Dashboard | PPS jitter (σ) | peak-to-peak |
|---|---|---|---|
| 1 | **on** | 1912 ns | 10252 ns |
| 2 | **off** | **1304 ns** | **6914 ns** |
| 3 | **on** | 2179 ns | 11683 ns |
Round 3 reproduces round 1. It's real.
## Why
Two facts, individually harmless, catastrophic together:
1. The collector was **forking `chronyc` four times a second** — once each for
`tracking`, `sources`, `sourcestats`, `clients`. Process creation is one of the
most expensive things you can ask a scheduler to do.
2. That churn landed on **CPU 0** — the one core the PPS interrupt is welded to
and [cannot be moved off](/explanation/the-interrupt-you-cannot-move/).
The monitoring tool was standing on the neck of the thing it monitors. And it was
invisible: every metric looked fine, chrony still said Stratum 1, the dashboard
still said "locked". The number was just quietly worse.
## The fix (no timing code was touched)
**1. Batch chronyc into one process.** It reads commands from stdin, so one fork
serves all three:
```bash
printf 'tracking\nsources\nsourcestats\n' | chronyc -c
```
Tell the outputs apart by field count: 14 = tracking, 10 = sources, 8 = sourcestats.
:::caution
Passing multiple commands as **arguments** silently runs only the first.
`chronyc -c tracking sources sourcestats` returns tracking and nothing else, with
no error. Use stdin.
:::
**2. Get off CPU 0.**
```ini
[Service]
CPUAffinity=1
```
## The result
| | Dashboard off | Dashboard on | Penalty |
|---|---|---|---|
| Before | 1304 ns | 1912 / 2179 ns | **+36%** |
| After | 1437 ns | **1169 / 1450 ns** | **none — within noise** |
The box running its *entire* production stack is now quieter than it was sitting
**idle** before the fix.
## The general rule
On a machine where one core is load-bearing for precision, **every other service
you run is a tenant on the other cores**, whether it knows it or not. And process
creation is the loudest neighbour there is.
A monitoring tool must not perturb what it measures. If you have never checked
whether yours does, you do not know that it doesn't.

View File

@ -0,0 +1,62 @@
---
title: Where the precision actually lives
description: NMEA labels the second. PPS carries all the accuracy. Once you internalise that, half the tuning advice evaporates.
sidebar:
order: 4
---
A GPS receiver hands you time twice, in two completely different currencies, and
almost every tuning mistake comes from confusing them.
## NMEA tells you *which* second it is
The receiver computes the time precisely, and then it has to **shift a text
sentence out of a serial port**. At 9600 baud that takes hundreds of milliseconds,
and the delay wobbles from second to second depending on how many sentences are
enabled and what the CPU was doing.
Our NMEA-derived time sat **+160 ms** off, with hundreds of microseconds of noise.
That's not the receiver being bad. That's a UART being a UART.
## PPS tells you *exactly when* that second began
The same receiver also raises a **single electrical edge** at the top of every
second, accurate to nanoseconds. No protocol, no encoding, no serial port — just
a voltage going high at the instant the second starts.
That edge is where every nanosecond of your accuracy comes from. All of it.
## What that means in practice
chrony uses them together, and the division of labour is total:
```
refclock SHM 0 refid GPS ... noselect # NMEA: labels the second. Never the time source.
refclock PPS /dev/pps0 ... lock GPS # PPS: IS the time source.
```
The NMEA source is marked `noselect` — chrony is explicitly told *never to use it
to set the clock*. Its only job is to answer "which second is this pulse?", and
for that it merely has to be within half a second. It has an entire half-second
of slack.
:::tip[The consequence that saves you a day]
**Anything that improves NMEA and nothing else improves nothing.**
We raised the module's baud rate from 9600 → 115200, a 12× improvement to the
serial path, and measured the result:
| | PPS offset | root dispersion |
|---|---|---|
| 9600 baud | 1 ns | 7.6 µs |
| 115200 baud | 1 ns | 6.3 µs |
**Identical.** We'd improved the thing that doesn't carry the precision.
Worse: pinning gpsd to that baud later caused a
[total GPS outage after a power cut](/how-to/survive-a-power-cut/). We nearly
took the server down defending an optimisation worth nothing.
:::
Baud rate, sentence count, SBAS, update rate — all of it lives on the NMEA side of
the wall. Tune it if you enjoy tuning. Just don't expect the clock to notice.

View File

@ -0,0 +1,50 @@
---
title: The findings, in brief
description: Everything we discovered, with the numbers, on one page.
---
For people who want the whole thing in ninety seconds.
## What we built
A GPS-disciplined Stratum 1 NTP server: **Raspberry Pi 4** + **BerryGPS-IMU v4**
(u-blox CAM-M8C), PPS on GPIO18, `gpsd` + `chrony`. Final state: **RMS offset
199 ns**, root delay ~1 ns, survives a cold power cut unattended. About $130 of
parts, replacing an appliance that costs $1,500$10,000.
## What the guides get wrong on a Pi 4
| Claim | Reality |
|---|---|
| "Use PTP for real precision" | **Impossible.** `ethtool -T eth0``PTP Hardware Clock: none`. No hardware timestamping exists on this NIC. |
| "Isolate the PPS IRQ on a dedicated core" | **Not permitted.** GPIO IRQs demux through `pinctrl-bcm2835` and reject `smp_affinity`. |
| "Install PREEMPT_RT" | **Made jitter 3× worse** until patched — it threads the handler that takes the timestamp. |
| "Raise the GPS baud rate" | **Irrelevant.** PPS offset measured 1 ns at 9600 vs 115200. Identical. NMEA only *labels* the second. |
## What actually helped
| Change | RMS offset |
|---|---|
| Baseline | 823 ns |
| chrony `filter 10` + `prefer` on the PPS refclock | 440 ns |
| PREEMPT_RT + [`IRQF_NO_THREAD` patch](/reference/the-patch/) | **199 ns** |
## What we broke, and how we found it
Two failures that a reboot will never reveal. Only a **cold power cut** exposes
them, which is why you must actually pull the plug:
1. **gpsd was being started by the dashboard.** It's socket-activated; chrony
reads its *shared memory*, never its socket, so nothing else triggered it. The
monitoring page was load-bearing for the time server.
2. **Pinning gpsd's baud turned a module quirk into an outage.** The CAM-M8 keeps
config in supercap-backed RAM and reverts to 9600 on power loss. With gpsd
pinned to 115200 it came back talking to a module that wasn't listening: **no
GPS at all.** Use `GPSD_OPTIONS="-n"` and let it auto-probe.
## And the instrument was bending the measurement
Our own dashboard cost **36% more PPS jitter** by forking `chronyc` four times a
second onto CPU 0 — the one core the PPS interrupt is welded to and cannot be
moved from. Batching it to one process and pinning it off CPU 0 erased the
penalty entirely. [→ The observer effect](/explanation/the-observer-effect/)

View File

@ -0,0 +1,82 @@
---
title: Benchmark PPS jitter honestly
description: Measure the kernel's own pulse timestamps, run A/B/A, and don't let chrony's smoothing lie to you.
sidebar:
order: 3
---
Every claim on this site was produced this way. If you want to disagree with us,
disagree with these numbers.
## Don't use chrony's stats for this
`chronyc sourcestats` gives you a windowed, median-filtered, slowly-converging
estimate. That is *exactly what you want* for disciplining a clock and *exactly
what you don't want* for measuring a change you just made. It lags, it smooths,
and it will happily hide a regression for several minutes.
Measure the kernel's PPS timestamps directly instead.
## The measurement
Each PPS assert should land exactly 1.000000000 s after the last one. The
deviation from that is the jitter — nothing else.
```bash
sudo apt install pps-tools
sudo timeout 62 ppstest /dev/pps0 | awk '
/assert/ {
split($0, a, "assert "); split(a[2], b, ","); t = b[1] + 0;
if (prev > 0) {
d = (t - prev - 1.0) * 1e9;
n++; sum += d; sumsq += d*d;
if (n == 1 || d > max) max = d;
if (n == 1 || d < min) min = d;
}
prev = t
}
END {
mean = sum/n; sd = sqrt(sumsq/n - mean*mean);
printf "n=%d jitter_sd=%.0f ns p2p=%.0f ns\n", n, sd, max-min
}'
```
62 seconds gives you ~60 intervals. That's enough to see a 3× effect and not
enough to see a 5% one — size your window to the effect you're hunting.
## Always run A/B/A
This is the part people skip, and it's the part that makes the number mean
something.
A time server's behaviour drifts on the scale of minutes: the board warms up,
satellites rise and set, the DOP changes. If you measure **A then B** and B is
worse, you cannot distinguish "B is worse" from "the last five minutes were
worse."
So measure **A → B → A**:
```
round 1: feature OFF → 1304 ns
round 2: feature ON → 1912 ns
round 3: feature OFF → 1169 ns ← agrees with round 1. Now believe round 2.
```
If the two A rounds disagree with each other by more than the A-to-B difference,
**you have measured nothing** and you should go again with a longer window.
We caught our [dashboard's 36% tax](/explanation/the-observer-effect/) exactly
this way, and we *disbelieved* two other apparent wins when the bracketing rounds
refused to agree.
## Sanity check: is it even connected?
```console
$ sudo ppstest /dev/pps0
source 0 - assert 1783875773.176667184, sequence: 28
source 0 - assert 1783875774.176667944, sequence: 29
```
Sequence incrementing once a second = you have a pulse. No output = you have a
blinking LED and a wire that goes nowhere. See [Hardware](/reference/hardware/).

View File

@ -0,0 +1,111 @@
---
title: Cross-compile an RT kernel and deploy it to a headless Pi
description: Build an RPi-native aarch64 PREEMPT_RT kernel on an x86 workstation in ~40 minutes, and install it so that a failed boot doesn't cost you a trip to the SD card slot.
sidebar:
order: 2
---
Building natively on a Pi 4 takes hours. Cross-compiling on a normal workstation
takes about forty minutes. And if you've never done it, the scary part isn't the
build — it's that a bad kernel on a headless box means physically extracting the
SD card. This page addresses both.
:::tip[Or skip it]
We publish the built artifacts. See [Downloads](/reference/downloads/).
:::
## 1. Toolchain
```bash
sudo apt install crossbuild-essential-arm64 bc bison flex libssl-dev make \
libc6-dev libncurses5-dev
```
## 2. Source, matched to your running kernel
```bash
git clone --depth=1 --branch rpi-6.12.y \
https://github.com/raspberrypi/linux.git
cd linux
```
Use **Raspberry Pi's** tree, not vanilla. RPi's PREEMPT_RT is already merged in
6.12 and the board's DT/overlays live there.
## 3. Configure
```bash
export ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-
make bcm2711_defconfig # Pi 4
scripts/config --enable PREEMPT_RT
scripts/config --set-str LOCALVERSION "-rt-cuckoo"
make olddefconfig
```
Now apply [the patch](/how-to/patch-pps-gpio/) — this is the whole reason you're
building a kernel rather than installing one.
:::note[Check that MMC and EXT4 are `=y`]
`bcm2711_defconfig` builds the SD card driver and filesystem *into* the image, not
as modules. That means **you don't need an initramfs** — which removes the single
most common way a hand-built Pi kernel fails to boot.
```bash
grep -E 'CONFIG_(MMC_BCM2835|EXT4_FS)=' .config # want =y, not =m
```
:::
## 4. Build
```bash
make -j$(nproc) Image modules dtbs
```
## 5. Stage the artifacts
```bash
# Note the ABSOLUTE path. `~` does not expand inside a make variable —
# INSTALL_MOD_PATH=~/out silently installs into a literal "~" directory
# and you end up shipping an 80 KB tarball that contains nothing.
make INSTALL_MOD_PATH=/home/you/out modules_install
tar -C /home/you/out -czf rt-modules.tar.gz lib/modules/
gzip -c arch/arm64/boot/Image > kernel-rt.img.gz
```
## 6. Deploy without a rescue trip
The rule: **never overwrite the kernel that currently boots.**
```bash
scp kernel-rt.img.gz rt-modules.tar.gz pi@host:/tmp/
ssh pi@host
sudo tar -C / -xzf /tmp/rt-modules.tar.gz
zcat /tmp/kernel-rt.img.gz | sudo tee /boot/firmware/kernel-rt.img > /dev/null
```
`kernel8.img` — the stock kernel — is untouched. Then add **one** line to
`/boot/firmware/config.txt`:
```ini
kernel=kernel-rt.img
```
```bash
sudo reboot
```
:::tip[The recovery path]
If it doesn't come back: pull the SD card, mount the FAT boot partition on any
machine, **delete that one line**, put it back. The stock kernel boots. That's the
whole rollback — no initramfs to regenerate, no bootloader to repair, and it works
from a Windows laptop if that's all you have.
Test the rollback *before* you need it.
:::
## 7. Confirm
```console
$ uname -a
Linux gps-ntp 6.12.x-rt-cuckoo #1 SMP PREEMPT_RT ... aarch64
```

View File

@ -0,0 +1,76 @@
---
title: Patch pps-gpio for PREEMPT_RT
description: Rebuild one kernel module in about a minute and get your PPS timestamp back into hard-IRQ context.
sidebar:
order: 1
---
**Do this if:** you run PPS from a GPIO pin on a PREEMPT_RT kernel. Which is to
say — do this if you followed any realtime-kernel time-server guide.
[Why](/explanation/preempt-rt-made-it-worse/).
You do **not** need to rebuild the whole kernel. `pps-gpio` is a module.
## 1. Confirm you have the problem
```console
$ uname -a | grep -o PREEMPT_RT
PREEMPT_RT
$ ps -eo pid,class,rtprio,psr,comm | grep irq/.*pps
239 RR 50 3 irq/41-pps@12.-1 ← the handler is a thread. That's the bug.
```
If that `ps` prints nothing, your handler is already in hard-IRQ context and you
have nothing to fix.
## 2. Patch
In your kernel source tree, `drivers/pps/clients/pps-gpio.c`, in
`get_irqf_trigger_flags()`, just before the `return`:
```c
/* The handler timestamps the pulse, so it has to run in hard-irq
* context. Under PREEMPT_RT it would otherwise be force-threaded and
* the timestamp taken after thread wakeup latency, adding microseconds
* of jitter to an edge that should be good to nanoseconds.
*/
flags |= IRQF_NO_THREAD;
return flags;
```
## 3. Build just the module
```bash
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- \
M=drivers/pps/clients modules
```
About a minute, versus ~40 for a full kernel.
## 4. Install and reboot
```bash
scp drivers/pps/clients/pps-gpio.ko pi@host:/tmp/
ssh pi@host 'sudo install -m644 /tmp/pps-gpio.ko \
/lib/modules/$(uname -r)/kernel/drivers/pps/clients/pps-gpio.ko && \
sudo depmod -a && sudo reboot'
```
:::caution[The module must match the running kernel exactly]
`vermagic` is checked at load. If you build against a different source tree than
the running kernel, `modprobe` fails and — because `pps-gpio` is what creates
`/dev/pps0` — chrony loses its refclock entirely. Build from the *same* tree that
produced the kernel you're running, or rebuild both.
:::
## 5. Verify
```console
$ ps -eo pid,class,rtprio,psr,comm | grep irq/.*pps
(nothing — back in hard-irq context)
```
Then [measure it](/how-to/benchmark-pps-jitter/). You should see roughly a 3×
improvement in raw PPS jitter, and about a 10× improvement in chrony's RMS offset.

View File

@ -0,0 +1,84 @@
---
title: Survive a power cut
description: Two failures that only a cold power-cycle finds. Both of ours were silent, and one of them was our own monitoring dashboard.
sidebar:
order: 4
---
A time server's whole job is to be there. Ours had been up for days, Stratum 1,
199 ns — and it would **not have survived a power outage**. Two independent bugs,
neither visible from any amount of `systemctl status`.
Pull the plug. It's the only test that finds these.
## Failure 1: never pin the gpsd baud rate
Several guides tell you to reconfigure the GPS module to a higher baud rate and
then pin gpsd to match:
```ini
GPSD_OPTIONS="-n -s 115200" # ← don't
```
Here's what happens. Most u-blox modules hold their config in **volatile RAM**.
Cut the power and the module comes back at its factory **9600**. gpsd, pinned to
115200, opens the port, talks to a device that isn't listening, and reports
nothing. Not a degraded fix. **No GPS at all.** Your Stratum 1 server silently
becomes a Stratum 3 client of the internet — and stays that way until a human
notices.
Let gpsd auto-probe:
```ini
GPSD_OPTIONS="-n"
```
It sweeps the standard baud rates, finds the module wherever it landed, and comes
back on its own.
:::note[And the baud change bought nothing anyway]
We measured it: **1 ns** difference in PPS offset between 9600 and 115200. Which
makes sense — [the precision lives in the pulse, not the
sentences](/explanation/where-precision-lives/). NMEA at 9600 has plenty of time
to tell you *which* second it is. Faster serial changes nothing and costs you your
power-cut resilience.
:::
## Failure 2: your monitoring is load-bearing
`gpsd` ships with **socket activation**. It starts when something connects to port
2947. And chrony never connects to port 2947 — it reads gpsd's *shared memory*.
So on a fresh boot, nothing starts gpsd. No gpsd, no NMEA, no `refclock SHM 0`, no
second-numbering for the PPS pulses.
Ours *appeared* to work. It worked because **the dashboard** — our web status page
— polls gpsd on 2947, and the dashboard starts at boot. The monitoring was
socket-activating the thing it was monitoring. Stop the dashboard "to reduce load
on the clock" and you'd have stopped the clock.
```console
$ systemctl is-enabled gpsd.service
disabled ← this is the bug
```
```bash
sudo systemctl enable gpsd.service
```
Check `gpsd.service`, not `gpsd.socket`.
## What a healthy recovery looks like
With both fixed, and a few internet NTP servers left in `chrony.conf` as a
backstop, we cut the mains and watched:
| t+ | state |
|---|---|
| 0 s | power restored, boot |
| ~35 s | chrony up, **Stratum 3** — leaning on internet NTP. Serving time. |
| ~90 s | GPS fix acquired, NMEA flowing |
| **165 s** | PPS trusted, internet sources demoted, **Stratum 1** |
No human involved. That middle window — serving slightly-worse time instead of no
time — is why you keep the upstream servers configured even on a GPS clock.

View File

@ -0,0 +1,94 @@
---
title: What this is (and isn't)
description: A field report on GPS Stratum 1 timekeeping on a Raspberry Pi 4 — what the guides get wrong, and the measurements that prove it.
---
import { Aside, CardGrid, Card } from '@astrojs/starlight/components';
**This is not a build guide.**
Guides for building a GPS-disciplined Stratum 1 NTP server on a Raspberry Pi
already exist. [geerlingguy/time-pi](https://github.com/geerlingguy/time-pi) and
[josh-blake/pixie](https://github.com/josh-blake/pixie) are both good. Go read
them. Come back when your jitter is bad.
This site is what happened when we followed that advice on a **Raspberry Pi 4**
and measured everything: **most of it is wrong on this board**, one piece of it
is wrong on *every* board, and the single change that helped most was a one-line
kernel patch nobody has written down.
<Aside type="caution" title="n = 1">
Every number here comes from **one** Raspberry Pi 4 with **one** GPS module.
This is a field report, not a study. We're telling you what we measured, how we
measured it, and where we were wrong — so you can check it against your own
board rather than take our word for it. That's the whole point.
</Aside>
## The short version
<CardGrid>
<Card title="PREEMPT_RT made it 3× worse" icon="warning">
The realtime kernel — the marquee upgrade — **tripled our PPS jitter**
(2134 ns → 6947 ns). It force-threads interrupt handlers, and the PPS driver
takes its timestamp *inside* the handler. We put a scheduler between the
electrical edge and the clock.
[→ Why](/explanation/preempt-rt-made-it-worse/)
</Card>
<Card title="You cannot pin the PPS interrupt" icon="error">
On a Pi 4, GPIO interrupts are demuxed through `pinctrl-bcm2835` and refuse
an `smp_affinity`. The "isolate the PPS IRQ on its own core" advice is
**unachievable here** — and the only way to enable it is the very thing that
costs you the accuracy.
[→ Why](/explanation/the-interrupt-you-cannot-move/)
</Card>
<Card title="PTP is impossible on a Pi 4" icon="error">
`ethtool -T eth0` → `PTP Hardware Clock: none`. There is no hardware
timestamping. Software PTP is just a worse NTP. Don't chase it.
[→ Why](/explanation/no-ptp-on-a-pi-4/)
</Card>
<Card title="Your dashboard is taxing your clock" icon="rocket">
Ours cost **36% more PPS jitter** — by forking `chronyc` four times a second
onto the one core the PPS interrupt is welded to. The instrument was bending
the measurement.
[→ Why](/explanation/the-observer-effect/)
</Card>
</CardGrid>
## What actually moved the needle
Almost none of the things we expected.
| Change | RMS offset |
|---|---|
| Baseline | 823 ns |
| chrony: median `filter` + `prefer` on the PPS refclock | 440 ns |
| PREEMPT_RT (unpatched) | **2468 ns** ← *worse* |
| PREEMPT_RT + our `IRQF_NO_THREAD` patch | **199 ns** |
Baud rate, SBAS, CPU isolation, IRQ pinning: **noise, or actively harmful.**
The full numbers and methodology are in [the measurements](/reference/measurements/).
## The one artifact worth stealing
If you take nothing else from this site, take
[the kernel patch](/reference/the-patch/). Every person running GPIO-based PPS
on a PREEMPT_RT kernel is, right now, silently eating microseconds of jitter and
has no idea. It's four lines. It's upstreamable. It's the reason our RMS offset
is 199 ns instead of 2468 ns.
## Why "The Cuckoo Escapement"
The **escapement** is the part of a mechanical clock that takes continuous energy
and chops it into discrete, regular ticks. It's the single component that decides
whether a clock is precise or worthless. That is *exactly* what a PPS interrupt
handler does: it takes an electrical edge and turns it into one discrete
timestamp. Our entire finding is that all the precision in the system lives in
that one handler — and that the realtime kernel was putting a scheduler in front
of it.
We didn't fix a time server. We fixed the escapement.
The **cuckoo** is the other half. The bird's whole job is to pop out and announce
the hour; the PPS pulse's whole job is to pop out and announce the second. The
bird *is* the pulse. And, well — everything you were told about this turned out
to be a bit cuckoo.

View File

@ -0,0 +1,88 @@
---
title: Final configuration
description: The chrony, gpsd, kernel and systemd config this box actually runs.
sidebar:
order: 4
---
## chrony
```ini
# /etc/chrony/conf.d/gps.conf
# Coarse NMEA time from gpsd. Labels WHICH second it is. Never the time source.
refclock SHM 0 refid GPS precision 1e-1 offset 0.0 delay 0.2 poll 3 noselect
# Kernel PPS: the precise source. Median-filter 10 pulses, lock to GPS for
# second-numbering, prefer it as the reference.
refclock PPS /dev/pps0 refid PPS precision 1e-9 poll 2 lock GPS filter 10 prefer
allow 10.0.0.0/8
```
Plus, in `chrony.conf`:
```ini
user root # needed to read gpsd's SHM segments (mode 0600, root-owned)
```
:::note[Keep the internet servers]
Leave a few upstream NTP servers configured. While the GPS cold-acquires after a
power cut, chrony leans on them and serves *slightly less precise* time rather than
*no* time — then demotes them the instant PPS becomes trustworthy. We watched it
bridge a 165-second gap and promote itself back to Stratum 1 unattended. That
graceful degradation is worth the four lines.
:::
## gpsd
```ini
# /etc/default/gpsd
START_DAEMON="true"
USBAUTO="false"
DEVICES="/dev/ttyAMA0"
GPSD_OPTIONS="-n" # -n = poll immediately. NEVER pin the baud with -s.
```
```bash
systemctl enable gpsd.service # NOT just gpsd.socket — see below
```
:::danger[Two ways gpsd will betray you]
1. **Socket activation isn't enough.** chrony reads gpsd's *shared memory*, never
its socket — so nothing triggers the daemon to start. If it seems to work
anyway, something *else* is connecting to port 2947 and starting it for you.
For us that was the dashboard: our monitoring page was load-bearing for the
time server. Check with `systemctl is-enabled gpsd.service`.
2. **Never pin the baud.** [The module reverts to 9600 on power
loss](/how-to/survive-a-power-cut/), and a pinned gpsd then talks to a device
that isn't listening. Let it auto-probe.
:::
## Kernel cmdline
```
isolcpus=2,3 irqaffinity=0,1 nohz=off cpuidle.off=1 skew_tick=1
```
## systemd affinities
[cpu0 is sacred](/explanation/cpu0-is-sacred/) — it holds the PPS interrupt.
```ini
# chrony.service.d/affinity.conf
[Service]
CPUSchedulingPolicy=rr
CPUSchedulingPriority=20
CPUAffinity=2
# gpsd.service.d/affinity.conf
[Service]
CPUAffinity=3
# everything else (dashboard, Caddy, exporters, cron…)
[Service]
CPUAffinity=1
Nice=10
```

View File

@ -0,0 +1,72 @@
---
title: Downloads — prebuilt RT kernel
description: A patched PREEMPT_RT kernel for the Raspberry Pi 4, so you don't need a cross-compile toolchain.
sidebar:
order: 5
---
The only expensive part of [the patch](/reference/the-patch/) is the toolchain.
Building natively on a Pi 4 takes hours; cross-compiling needs an x86 box and a
setup session. So here's the artifact.
:::danger[Read this before you download]
- **Raspberry Pi 4 / arm64 only.** `bcm2711_defconfig`. It will not boot a Pi 5 or
a Pi 3.
- **Unsigned, community-built.** We built this on a workstation. There is no chain
of trust here beyond "we published the exact recipe and the checksums." If that
isn't good enough for your environment — and for some environments it correctly
isn't — [build it yourself](/how-to/cross-compile-rt-kernel/). It's forty
minutes.
- **Verify the checksums.** They're in `SHA256SUMS`.
:::
## Artifacts
Published on the [releases page](https://git.supported.systems/warehack.ing/cuckoo-escapement/releases):
| File | What |
|---|---|
| `kernel-rt-<ver>.img.gz` | The kernel image, gzipped (Pi OS's own format) |
| `rt-modules-<ver>.tar.gz` | Matching modules — **must** be installed with the image |
| `install-rt-kernel.sh` | Installer. Adds a *new* image, never replaces `kernel8.img` |
| `SHA256SUMS` | Checksums |
## Install
```bash
sha256sum -c SHA256SUMS
sudo ./install-rt-kernel.sh
sudo reboot
```
The installer:
1. Untars the modules into `/lib/modules/`
2. Writes the image as `/boot/firmware/kernel-rt.img`**`kernel8.img` is left
alone**
3. Appends one line, `kernel=kernel-rt.img`, to `config.txt`
**Rollback is deleting that one line.** Mount the SD card's FAT partition on any
machine, remove it, and the stock kernel boots. That's deliberate: you should never
have to make a physical trip to a headless box because of a kernel you got from a
website.
## What's in it
Raspberry Pi's `rpi-6.12.y` tree, `bcm2711_defconfig`, plus exactly two changes:
```bash
scripts/config --enable PREEMPT_RT
# + the IRQF_NO_THREAD patch in drivers/pps/clients/pps-gpio.c
```
Nothing else. The full recipe is in
[Cross-compile an RT kernel](/how-to/cross-compile-rt-kernel/), and you should be
able to reproduce this byte-for-byte modulo build timestamps.
:::note[Pinned to a tested version]
The published download always points at a kernel we have **actually booted and
benchmarked** on a Pi 4 — not simply the newest upstream. Shipping a stranger an
unvalidated kernel for a machine they may not be able to physically reach is not
something we're willing to do.
:::

View File

@ -0,0 +1,78 @@
---
title: Hardware
description: BerryGPS-IMU v4 / u-blox CAM-M8C, and the PPS pad that isn't on the header.
sidebar:
order: 3
---
## The board
**BerryGPS-IMU v4** (Ozzmaker) on a **Raspberry Pi 4**. The GPS is a **u-blox
CAM-M8C** — 72-channel M8 engine, concurrent GPS/GLONASS/Galileo/BeiDou, with an
onboard antenna and a uFL connector for an external one.
Reported by the module itself:
```console
$ ubxtool -p MON-VER
swVersion ROM CORE 3.01 (107888)
hwVersion 00080000 # M8 generation
extension FWVER=SPG 3.01
extension PROTVER=18.00
extension GPS;GLO;GAL;BDS
```
## The PPS pin is not on the header
This costs people hours, so: **the BerryGPS-IMU's PPS is not wired to any GPIO.**
The board's normal header connection carries power, the GPS UART (GPIO14/15), and
the IMU's I²C — but the timepulse comes out of a **separate `T_PULSE` pad**, and
you have to run a wire from it yourself.
The schematic confirms it: the CAM-M8C's TIMEPULSE pin goes through a 2N2222
buffer that drives both the on-board **PPS LED** and the `T_PULSE` pad. Nothing
routes it to the Pi.
:::caution[The blinking LED lies to you]
The PPS LED blinks once a second as soon as the module has a fix — **whether or
not the pulse is connected to anything**. It tells you the module is generating
PPS. It tells you nothing about whether your Pi can see it.
We swept every plausible GPIO with interrupt-driven edge detection and found
nothing, while the LED blinked away merrily. The signal existed; it just had
nowhere to go.
:::
We soldered a jumper from **`T_PULSE` → GPIO18** (physical pin 12), then:
```ini
# /boot/firmware/config.txt
dtoverlay=pps-gpio,gpiopin=18
```
Verify with a hardware-timestamped check, not a polling loop — a 100 ms pulse is
easy to miss by polling:
```console
$ sudo ppstest /dev/pps0
source 0 - assert 1783875773.176667184, sequence: 28
source 0 - assert 1783875774.176667944, sequence: 29 # 1.000000760 s later
```
## The UART needs freeing first
The Pi 4's *good* UART (PL011) is wired to **Bluetooth** by default; GPIO14/15 get
the flaky mini-UART whose baud drifts with the CPU clock. And a serial console may
be sitting on the port. Both must go:
```ini
# /boot/firmware/config.txt
enable_uart=1
dtoverlay=disable-bt
```
```ini
# /boot/firmware/cmdline.txt — remove this:
console=serial0,115200
```

View File

@ -0,0 +1,75 @@
---
title: The measurements
description: Every number on this site, with the methodology that produced it. Check our work.
sidebar:
order: 2
---
All numbers from **one** Raspberry Pi 4 + BerryGPS-IMU v4 (u-blox CAM-M8C), PPS on
GPIO18. n = 1. Check them against your own board.
## Headline progression
| Change | RMS offset | Root dispersion |
|---|---|---|
| Baseline (stock kernel, stock chrony) | 823 ns | 16.8 µs |
| chrony `filter 10` + `prefer` on PPS refclock | 440 ns | 5 µs |
| PREEMPT_RT, unpatched | **2468 ns***worse* | 11.6 µs |
| **PREEMPT_RT + [`IRQF_NO_THREAD`](/reference/the-patch/)** | **199 ns** | 6.3 µs |
## Raw PPS jitter (kernel-timestamped)
| Kernel | jitter (σ) | peak-to-peak |
|---|---|---|
| Stock | 2134 ns | 11 µs |
| PREEMPT_RT (threaded handler) | 6947 ns | 38 µs |
| PREEMPT_RT + patch (hard-irq handler) | 2568 ns | 18 µs |
## The dashboard's tax
A/B/A, 62 s per round. [Why this matters](/explanation/the-observer-effect/).
| | Dashboard off | Dashboard on |
|---|---|---|
| Before fix | 1304 ns | 1912 / 2179 ns (**+36%**) |
| After fix | 1437 ns | 1169 / 1450 ns (**no penalty**) |
## Things that did nothing
| Change | Result |
|---|---|
| Baud 9600 → 115200 | PPS offset **1 ns** either way. Identical. |
| SBAS disabled | No measurable change to PPS. |
| `isolcpus` alone | Inconclusive-to-harmful (concentrates load onto the PPS core). |
## Methodology
**Don't trust chrony's own stats for this.** `chronyc sourcestats` reports a
windowed, median-filtered figure that lags reality and hides what you're trying to
see. Measure the kernel's PPS timestamps directly:
```bash
sudo timeout 62 ppstest /dev/pps0 | awk '
/assert/ {
split($0, a, "assert "); split(a[2], b, ","); t = b[1] + 0;
if (prev > 0) {
d = (t - prev - 1.0) * 1e9; # deviation from exactly 1.000000000 s, in ns
n++; sum += d; sumsq += d*d;
if (d > max || n == 1) max = d;
if (d < min || n == 1) min = d;
}
prev = t
}
END {
mean = sum/n; sd = sqrt(sumsq/n - mean*mean);
printf "n=%d jitter_sd=%.0f ns p2p=%.0f ns\n", n, sd, max-min
}'
```
Each pulse should be exactly 1.000000000 s after the last. The deviation *is* the
jitter.
**Always run A/B/A**, never A/B. Clock behaviour drifts on the scale of minutes;
if you measure on-then-off you cannot tell a real effect from thermal drift or a
satellite geometry change. Go on → off → on, and require the two "on" rounds to
agree before you believe the middle one.

View File

@ -0,0 +1,65 @@
---
title: The kernel patch
description: Four lines that keep the PPS timestamp in hard-IRQ context under PREEMPT_RT. The single most valuable artifact here.
sidebar:
order: 1
---
If you take one thing from this site, take this.
```c
--- a/drivers/pps/clients/pps-gpio.c
+++ b/drivers/pps/clients/pps-gpio.c
@@ -156,6 +156,13 @@ get_irqf_trigger_flags(const struct pps_gpio_device_data *data)
IRQF_TRIGGER_FALLING : IRQF_TRIGGER_RISING);
}
+ /* The handler timestamps the pulse, so it has to run in hard-irq
+ * context. Under PREEMPT_RT it would otherwise be force-threaded and
+ * the timestamp taken after thread wakeup latency, adding microseconds
+ * of jitter to an edge that should be good to nanoseconds.
+ */
+ flags |= IRQF_NO_THREAD;
+
return flags;
}
```
## What it does
`pps-gpio` requests its interrupt with only the trigger flags — no
`IRQF_NO_THREAD`. On a stock kernel that's fine, because handlers run in hard-IRQ
context anyway. Under **PREEMPT_RT**, the kernel force-threads it, and since
[the handler is where the timestamp is taken](/explanation/preempt-rt-made-it-worse/),
the measurement moves behind the scheduler.
`IRQF_NO_THREAD` tells the kernel: *not this one*. The handler stays in hard-IRQ
context; everything else keeps RT's preemptibility.
## What it's worth
| | RMS offset | raw PPS jitter |
|---|---|---|
| PREEMPT_RT, unpatched | 2468 ns | 6947 ns |
| **PREEMPT_RT, patched** | **199 ns** | 2568 ns |
## Why you probably need it
As far as we can tell this is not applied anywhere. Which means **every person
running GPIO-based PPS on a PREEMPT_RT kernel is, right now, silently eating
microseconds of jitter** — and has no reason to suspect it, because nothing looks
broken. chrony still reports Stratum 1. The dashboard still says locked. The
number is just quietly, invisibly worse.
If that's you, this patch is free accuracy.
:::note[Upstreamable]
This belongs upstream, not in a blog post. It's a correctness fix for any
timestamping IRQ handler under RT, not a local hack. If you're a PPS maintainer
reading this: please take it.
:::
## How to apply it
See [Patch pps-gpio](/how-to/patch-pps-gpio/) — it's a module, so you can rebuild
just the one `.ko` in about a minute rather than the whole kernel.

View File

@ -0,0 +1,192 @@
/* The Cuckoo Escapement brass-and-cream palette for Starlight.
*
* Why this file exists: the subject is horology. Escapements, gear trains, the
* mechanical business of chopping continuous energy into trustworthy ticks. So
* the site should read like a watchmaker's bench, not a terminal: warm brass on
* near-black, cream text, the feel of an old patent drawing.
*
* We override Starlight's CSS custom properties rather than rewriting its
* components that keeps us forward-compatible with Starlight updates.
*
* Palette anchors:
* - background: near-black, faint warm tint (not the usual cold slate)
* - accent: brass links, focus rings, the gear in the logo
* - text: cream, never pure white (easier on the eyes, warmer)
* - red: reserved for the "this is wrong" callouts, of which there are many
*/
:root {
--sl-font-system-mono: "JetBrains Mono", "Fira Code", "SF Mono",
Menlo, Consolas, "DejaVu Sans Mono", monospace;
/* Starlight derives every semantic color (asides, card icons) from a HUE
* variable `--sl-color-purple-low/-/-high` are all hsl(var(--sl-hue-purple)).
* Retune the hues once and the whole derived scale follows, in BOTH themes,
* instead of overriding nine colors by hand and watching them drift apart.
*
* Stock purple (281) is the loudest thing on the page and it does not belong
* on a brass bench: `:::tip` asides and half the card icons render violet.
* Swap it for verdigris the blue-green a brass movement actually goes as it
* ages. Same job, right family.
*/
--sl-hue-purple: 172; /* was 281 (violet) → verdigris */
--sl-hue-blue: 199; /* was 234 (indigo) → steel */
--sl-hue-green: 145; /* was 101 (lime) → patina */
--sl-hue-orange: 38; /* was 41 → brass. Already close. */
--sl-hue-red: 8; /* the "this is wrong" callouts, of which there are many */
}
/* Dark is the default — this is a bench at 2am. */
:root[data-theme="dark"] {
--sl-color-bg: #0b0d10;
--sl-color-bg-nav: #0e1116;
--sl-color-bg-sidebar: #0d1014;
--sl-color-bg-inline-code: #1d1a14;
--sl-color-text: #e8e4dc;
--sl-color-text-accent: #e8bd6b;
--sl-color-accent-low: #3d2f14;
--sl-color-accent: #d9a441;
--sl-color-accent-high: #f2d79b;
--sl-color-white: #f4f1ea;
--sl-color-gray-1: #dbd6cc;
--sl-color-gray-2: #b6b0a4;
--sl-color-gray-3: #837d72;
--sl-color-gray-4: #4f4a43;
--sl-color-gray-5: #2d2a26;
--sl-color-gray-6: #1a1815;
--sl-color-hairline: #2b2721;
--sl-color-hairline-light: #3a352c;
--sl-color-hairline-shade: #201d18;
}
:root[data-theme="light"] {
--sl-color-bg: #faf7f1;
--sl-color-bg-nav: #f2ede3;
--sl-color-bg-sidebar: #f5f1e8;
--sl-color-bg-inline-code: #ece5d6;
--sl-color-text: #2b2721;
--sl-color-text-accent: #8a6417;
--sl-color-accent-low: #e8d7ac;
--sl-color-accent: #a97c1f;
--sl-color-accent-high: #5c430f;
--sl-color-white: #1a1815;
--sl-color-gray-1: #2d2a26;
--sl-color-gray-2: #4f4a43;
--sl-color-gray-3: #837d72;
--sl-color-gray-4: #b6b0a4;
--sl-color-gray-5: #dbd6cc;
--sl-color-gray-6: #ece7dd;
--sl-color-hairline: #ddd6c8;
}
/* Monospace headings — this is an engineering document, not an essay. */
h1, h2, h3, .site-title {
font-family: var(--sl-font-system-mono);
letter-spacing: -0.01em;
}
/* Inline code gets a warm plate, but not inside links or headings (where it
fights the surrounding type). */
:not(a):not(h1):not(h2):not(h3):not(h4) > code {
background: var(--sl-color-bg-inline-code);
border: 1px solid var(--sl-color-hairline);
border-radius: 4px;
padding: 0.1em 0.35em;
}
/* Measurement tables are the whole argument of this site, so let the numbers
line up and let them breathe. */
table {
font-variant-numeric: tabular-nums;
}
table td:not(:first-child),
table th:not(:first-child) {
font-family: var(--sl-font-system-mono);
font-size: 0.92em;
}
/* --- "A Supported Systems Joint" the maker's plate ---------------------
*
* Styled as the engraved backplate of a clock movement: a double hairline
* (the classic brass-plate border), warm plate fill, engraved-looking small
* caps. Shared verbatim with the dashboard footer, recolored to its palette.
* See src/components/SupportedSystemsBadge.astro for the markup + rationale.
*/
.ss-plate {
margin-top: 3.5rem;
}
.ss-plate__link {
display: flex;
gap: 1.1rem;
align-items: center;
padding: 1.25rem 1.4rem;
text-decoration: none;
color: var(--sl-color-gray-2);
/* Double hairline = brass plate edge. The outer ring is the box-shadow. */
border: 1px solid var(--sl-color-hairline-light);
border-radius: 6px;
box-shadow: 0 0 0 3px var(--sl-color-bg), 0 0 0 4px var(--sl-color-hairline);
/* Faint brushed-brass sheen, top-left, the way a plate catches bench light. */
background:
radial-gradient(120% 140% at 0% 0%, rgba(217, 164, 65, 0.06), transparent 60%),
var(--sl-color-bg-nav);
transition: color 0.2s, border-color 0.2s;
}
.ss-plate__link:hover {
color: var(--sl-color-text);
border-color: var(--sl-color-accent-low);
}
.ss-plate__logo {
flex: 0 0 auto;
width: 46px;
height: auto;
opacity: 0.85;
transition: opacity 0.2s;
}
.ss-plate__link:hover .ss-plate__logo { opacity: 1; }
.ss-plate__copy { display: block; }
.ss-plate__heading {
display: block;
margin-bottom: 0.3rem;
font-family: var(--sl-font-system-mono);
font-size: 0.8rem;
font-weight: 600;
/* Engraved: small, wide-tracked caps, the way a name is cut into brass. */
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--sl-color-white);
}
.ss-plate__body {
display: block;
font-size: 0.85rem;
line-height: 1.55;
max-width: 62ch;
}
.ss-plate__name { color: var(--sl-color-accent); }
.ss-plate__cta {
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.5rem;
font-size: 0.8rem;
color: var(--sl-color-accent);
}
.ss-plate__link:hover .ss-plate__cta svg { transform: translateX(2px); }
.ss-plate__cta svg { transition: transform 0.2s; }
@media (max-width: 32rem) {
.ss-plate__link { flex-direction: column; align-items: flex-start; }
}

View File

@ -0,0 +1,119 @@
/* l2trace docs terminal-palette overrides for Starlight.
*
* Why this file exists: l2trace's TUI uses a green-on-black terminal feel
* by default; the docs should feel like an extension of the same product.
* We override Starlight's CSS custom properties rather than rewriting
* components that keeps us forward-compatible with Starlight updates.
*
* Palette anchors:
* - background: near-black, slight green tint (matches terminal phosphor)
* - accent: soft green for links / focus rings
* - amber: reserved for warnings and "audit time" callouts
* - text: warm off-white, not pure white (easier on eyes)
*/
:root {
/* Monospace stack used throughout — code, headings, and accent UI. */
--sl-font-system-mono: "JetBrains Mono", "Fira Code", "SF Mono",
Menlo, Consolas, "DejaVu Sans Mono", monospace;
}
/* Dark theme is the default for the docs (matches the TUI). */
:root[data-theme="dark"] {
--sl-color-bg: #0a0e0a;
--sl-color-bg-nav: #0d120d;
--sl-color-bg-sidebar: #0c100c;
--sl-color-bg-inline-code: #14201a;
--sl-color-text: #e3e8e0;
--sl-color-text-accent: #7fdb7f;
--sl-color-accent-low: #1b3a1b;
--sl-color-accent: #5fcf5f;
--sl-color-accent-high: #b8f0b8;
--sl-color-white: #f1f5ee;
--sl-color-gray-1: #d6dccf;
--sl-color-gray-2: #b6bdaf;
--sl-color-gray-3: #828a7e;
--sl-color-gray-4: #4d544a;
--sl-color-gray-5: #2c3129;
--sl-color-gray-6: #1a1f18;
--sl-color-hairline: #233022;
--sl-color-hairline-light: #2c3a2b;
--sl-color-hairline-shade: #182218;
/* Asides/admonitions — re-color to match palette. */
--sl-color-orange-high: #ffd98e;
--sl-color-orange: #f0b25c;
--sl-color-orange-low: #3a2a14;
--sl-color-red-high: #ff9a8a;
--sl-color-red: #e96252;
--sl-color-red-low: #3a1714;
}
/* Light theme — softer, but still recognizably "l2trace". */
:root[data-theme="light"] {
--sl-color-text-accent: #2f7a2f;
--sl-color-accent-low: #d6f0d6;
--sl-color-accent: #2f7a2f;
--sl-color-accent-high: #173d17;
--sl-color-bg-inline-code: #ebf2e9;
}
/* Headings get a touch of monospace + slight tracking newspaper-headline
* energy in a terminal aesthetic. Body text stays in the default sans for
* readability over long passages. */
.sl-markdown-content h1,
.sl-markdown-content h2,
.sl-markdown-content h3 {
font-family: var(--sl-font-system-mono);
letter-spacing: -0.01em;
}
/* Site title in the header — monospace to set the tone immediately. */
.site-title {
font-family: var(--sl-font-system-mono);
}
/* Inline code: green-tinted background, NO background color when inside
* link text (Starlight default looks muddy there). */
.sl-markdown-content :not(a, h1, h2, h3, h4, h5, h6) > code:not(pre code) {
background: var(--sl-color-bg-inline-code);
border-radius: 0.2rem;
padding: 0.05rem 0.3rem;
border: 1px solid var(--sl-color-hairline);
}
/* SVG embeds (the TUI screenshots) need a subtle border so they don't
* float disconnected on the dark page background. */
.sl-markdown-content img[src$=".svg"] {
border: 1px solid var(--sl-color-hairline);
border-radius: 0.4rem;
background: #000;
}
/* Side-by-side theme-comparison tables: don't let cells stretch tall, and
* keep the SVGs constrained so the row stays readable on smaller screens.
* Targeting tables that contain images directly is a bit blunt but right
* for our usage the only such tables in the docs are theme-comparison
* grids on the TUI tour page. */
.sl-markdown-content table:has(img) {
table-layout: fixed;
width: 100%;
}
.sl-markdown-content table:has(img) td {
vertical-align: middle;
padding: 0.4rem;
}
.sl-markdown-content table:has(img) img {
width: 100%;
height: auto;
display: block;
/* Pull off the .sl-markdown-content img[src$=".svg"] outer border since
* the table cell already provides one double borders look noisy. */
border-width: 0;
}

5
docs-site/tsconfig.json Normal file
View File

@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}

View File

@ -0,0 +1,18 @@
diff --git a/drivers/pps/clients/pps-gpio.c b/drivers/pps/clients/pps-gpio.c
index 65d17781d..5ba2aaebe 100644
--- a/drivers/pps/clients/pps-gpio.c
+++ b/drivers/pps/clients/pps-gpio.c
@@ -156,6 +156,13 @@ get_irqf_trigger_flags(const struct pps_gpio_device_data *data)
IRQF_TRIGGER_FALLING : IRQF_TRIGGER_RISING);
}
+ /* The handler timestamps the pulse, so it has to run in hard-irq
+ * context. Under PREEMPT_RT it would otherwise be force-threaded and
+ * the timestamp taken after thread wakeup latency, adding microseconds
+ * of jitter to an edge that should be good to nanoseconds.
+ */
+ flags |= IRQF_NO_THREAD;
+
return flags;
}

79
kernel/README.md Normal file
View File

@ -0,0 +1,79 @@
# PREEMPT_RT kernel for gps-ntp (Raspberry Pi 4)
A cross-compiled RPi-native realtime kernel, plus a one-line `pps-gpio` fix
that turned out to be the whole point.
## Why
Chasing PPS jitter. The theory (from [pixie](https://github.com/josh-blake/pixie))
is that PREEMPT_RT plus CPU isolation lets you park the PPS interrupt on a
dedicated core at realtime priority.
On a **Pi 4 that doesn't work out of the box**, for two reasons we found the
hard way:
1. **The PPS interrupt can't be moved on a stock kernel.** It's a GPIO
interrupt demuxed through `pinctrl-bcm2835`, so writing `smp_affinity`
returns `Operation not permitted`. Individual GPIO lines follow the GPIO
controller's parent IRQ. Pixie's approach assumes a Pi 5.
2. **PREEMPT_RT alone made jitter 3× worse.** RT force-threads interrupt
handlers — but `pps-gpio` *timestamps the pulse inside its handler*
(`pps_get_ts()``pps_event()`). Threaded, that timestamp is taken after
thread-wakeup latency instead of at the electrical edge. We measured raw
PPS jitter go from 2134 ns → 6947 ns.
## The fix
`0001-pps-gpio-keep-timestamp-in-hard-irq-under-PREEMPT_RT.patch` adds
`IRQF_NO_THREAD` to the `pps-gpio` IRQ request, so the timestamp stays in
hard-irq context while the rest of the system keeps RT's preemptibility.
Ironically, RT *also* solved problem (1): a force-threaded IRQ is a
schedulable thread, and a thread **can** be `taskset` to an isolated core —
which the stock kernel refused. So RT unlocked the pinning the hardware
denied us, and the patch undoes the damage RT did on the way.
## Results (measured, chrony `sourcestats` / raw `ppstest`)
| Config | RMS offset | Raw PPS jitter |
|---|---|---|
| Baseline (stock kernel, stock chrony) | 823 ns | — |
| + chrony median-filter/prefer | 440 ns | 2134 ns |
| + PREEMPT_RT (threaded PPS) | 2468 ns | 6947 ns |
| **+ `IRQF_NO_THREAD` patch** | **199 ns** | **2568 ns** |
## Build (cross-compile from x86)
```bash
sudo pacman -S --needed aarch64-linux-gnu-gcc # Arch/EndeavourOS
git clone --depth=1 --branch rpi-6.18.y https://github.com/raspberrypi/linux
cd linux
export ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-
make bcm2711_defconfig
./scripts/config --enable EXPERT --enable PREEMPT_RT \
--set-str LOCALVERSION "-rt-timepi" --disable LOCALVERSION_AUTO
make olddefconfig
patch -p1 < ../0001-pps-gpio-keep-timestamp-in-hard-irq-under-PREEMPT_RT.patch
make -j$(nproc) Image modules dtbs
```
## Deploy (safe, revertible, headless-friendly)
The Pi 4's SD and ext4 drivers are built-in (`CONFIG_MMC_BCM2835=y`,
`CONFIG_EXT4_FS=y`), so **no initramfs is needed**. Install the kernel under a
*new* name and leave `kernel8.img` alone — the whole change becomes one
revertible line.
```bash
gzip -9 -c arch/arm64/boot/Image > Image.gz # match Pi OS's gzip format
scp Image.gz pi:/tmp/ && scp rt-modules.tar.gz pi:/tmp/
# on the Pi:
sudo cp /tmp/Image.gz /boot/firmware/kernel-rt.img # NEW name; kernel8.img untouched
sudo tar xzf /tmp/rt-modules.tar.gz -C / # new /lib/modules/<rel>/
sudo depmod 6.18.38-rt-timepi+
echo 'kernel=kernel-rt.img' | sudo tee -a /boot/firmware/config.txt
```
**Recovery:** if it doesn't boot, pull the card and delete the
`kernel=kernel-rt.img` line. The stock kernel returns.