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:
commit
6881489bf6
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
dist/
|
||||
*.egg-info/
|
||||
node_modules/
|
||||
48
README.md
Normal file
48
README.md
Normal 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
13
dashboard/.env.example
Normal 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
52
dashboard/Makefile
Normal 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
|
||||
11
dashboard/deploy/Caddyfile.tmpl
Normal file
11
dashboard/deploy/Caddyfile.tmpl
Normal 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
|
||||
}
|
||||
10
dashboard/deploy/caddy-affinity.conf
Normal file
10
dashboard/deploy/caddy-affinity.conf
Normal 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
|
||||
37
dashboard/deploy/deploy.sh
Normal file
37
dashboard/deploy/deploy.sh
Normal 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"
|
||||
35
dashboard/deploy/gpsntp-dashboard.service
Normal file
35
dashboard/deploy/gpsntp-dashboard.service
Normal 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
|
||||
4
dashboard/deploy/gpsntp-dashboard.sudoers
Normal file
4
dashboard/deploy/gpsntp-dashboard.sudoers
Normal 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
29
dashboard/pyproject.toml
Normal 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"]
|
||||
3
dashboard/src/gpsntp_dashboard/__init__.py
Normal file
3
dashboard/src/gpsntp_dashboard/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""Live status dashboard for a GPS/PPS-disciplined chrony Stratum 1 time server."""
|
||||
|
||||
__version__ = "2026.07.12"
|
||||
144
dashboard/src/gpsntp_dashboard/chrony.py
Normal file
144
dashboard/src/gpsntp_dashboard/chrony.py
Normal 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 []
|
||||
165
dashboard/src/gpsntp_dashboard/gpsd_reader.py
Normal file
165
dashboard/src/gpsntp_dashboard/gpsd_reader.py
Normal 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,
|
||||
}
|
||||
158
dashboard/src/gpsntp_dashboard/main.py
Normal file
158
dashboard/src/gpsntp_dashboard/main.py
Normal 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",
|
||||
)
|
||||
88
dashboard/src/gpsntp_dashboard/metrics.py
Normal file
88
dashboard/src/gpsntp_dashboard/metrics.py
Normal 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"
|
||||
295
dashboard/src/gpsntp_dashboard/static/app.js
Normal file
295
dashboard/src/gpsntp_dashboard/static/app.js
Normal 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();
|
||||
180
dashboard/src/gpsntp_dashboard/static/index.html
Normal file
180
dashboard/src/gpsntp_dashboard/static/index.html
Normal 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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><text y='18' font-size='18'>🛰️</text></svg>" />
|
||||
</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>
|
||||
225
dashboard/src/gpsntp_dashboard/static/style.css
Normal file
225
dashboard/src/gpsntp_dashboard/static/style.css
Normal 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; }
|
||||
@ -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
19
docs-site/.dockerignore
Normal 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
9
docs-site/.env.example
Normal 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
22
docs-site/.gitignore
vendored
Normal 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
47
docs-site/Dockerfile
Normal 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
59
docs-site/Makefile
Normal 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
105
docs-site/astro.config.mjs
Normal 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(),
|
||||
],
|
||||
});
|
||||
73
docs-site/docker-compose.yml
Normal file
73
docs-site/docker-compose.yml
Normal 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
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
20
docs-site/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
docs-site/public/favicon.svg
Normal file
13
docs-site/public/favicon.svg
Normal 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 |
66
docs-site/public/supported-systems-logo.svg
Normal file
66
docs-site/public/supported-systems-logo.svg
Normal 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 |
43
docs-site/src/assets/logo.svg
Normal file
43
docs-site/src/assets/logo.svg
Normal 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 |
13
docs-site/src/components/Footer.astro
Normal file
13
docs-site/src/components/Footer.astro
Normal 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 />
|
||||
47
docs-site/src/components/SupportedSystemsBadge.astro
Normal file
47
docs-site/src/components/SupportedSystemsBadge.astro
Normal 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> — 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>
|
||||
8
docs-site/src/content.config.ts
Normal file
8
docs-site/src/content.config.ts
Normal 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() }),
|
||||
};
|
||||
51
docs-site/src/content/docs/explanation/cpu0-is-sacred.md
Normal file
51
docs-site/src/content/docs/explanation/cpu0-is-sacred.md
Normal 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 cpu1–3.
|
||||
|
||||
```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.
|
||||
:::
|
||||
52
docs-site/src/content/docs/explanation/no-ptp-on-a-pi-4.md
Normal file
52
docs-site/src/content/docs/explanation/no-ptp-on-a-pi-4.md
Normal 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/).
|
||||
@ -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.
|
||||
@ -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/)
|
||||
@ -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.
|
||||
@ -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.
|
||||
50
docs-site/src/content/docs/findings.md
Normal file
50
docs-site/src/content/docs/findings.md
Normal 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/)
|
||||
82
docs-site/src/content/docs/how-to/benchmark-pps-jitter.md
Normal file
82
docs-site/src/content/docs/how-to/benchmark-pps-jitter.md
Normal 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/).
|
||||
111
docs-site/src/content/docs/how-to/cross-compile-rt-kernel.md
Normal file
111
docs-site/src/content/docs/how-to/cross-compile-rt-kernel.md
Normal 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
|
||||
```
|
||||
76
docs-site/src/content/docs/how-to/patch-pps-gpio.md
Normal file
76
docs-site/src/content/docs/how-to/patch-pps-gpio.md
Normal 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.
|
||||
84
docs-site/src/content/docs/how-to/survive-a-power-cut.md
Normal file
84
docs-site/src/content/docs/how-to/survive-a-power-cut.md
Normal 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.
|
||||
94
docs-site/src/content/docs/index.mdx
Normal file
94
docs-site/src/content/docs/index.mdx
Normal 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.
|
||||
88
docs-site/src/content/docs/reference/configuration.md
Normal file
88
docs-site/src/content/docs/reference/configuration.md
Normal 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
|
||||
```
|
||||
72
docs-site/src/content/docs/reference/downloads.md
Normal file
72
docs-site/src/content/docs/reference/downloads.md
Normal 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.
|
||||
:::
|
||||
78
docs-site/src/content/docs/reference/hardware.md
Normal file
78
docs-site/src/content/docs/reference/hardware.md
Normal 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
|
||||
```
|
||||
75
docs-site/src/content/docs/reference/measurements.md
Normal file
75
docs-site/src/content/docs/reference/measurements.md
Normal 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.
|
||||
65
docs-site/src/content/docs/reference/the-patch.md
Normal file
65
docs-site/src/content/docs/reference/the-patch.md
Normal 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.
|
||||
192
docs-site/src/styles/brass.css
Normal file
192
docs-site/src/styles/brass.css
Normal 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; }
|
||||
}
|
||||
119
docs-site/src/styles/terminal.css
Normal file
119
docs-site/src/styles/terminal.css
Normal 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
5
docs-site/tsconfig.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
@ -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
79
kernel/README.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user