Compare commits
No commits in common. "main" and "2026.07.14" have entirely different histories.
main
...
2026.07.14
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**What the Raspberry Pi time-server guides get wrong, and the numbers to prove it.**
|
**What the Raspberry Pi time-server guides get wrong, and the numbers to prove it.**
|
||||||
|
|
||||||
Docs: **[escapement.warehack.ing](https://escapement.warehack.ing)**
|
Docs: **[cuckoo.warehack.ing](https://cuckoo.warehack.ing)**
|
||||||
|
|
||||||
We built a GPS-disciplined Stratum 1 NTP server on a Raspberry Pi 4, followed the
|
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
|
published advice, and measured everything. Most of that advice is wrong on this
|
||||||
|
|||||||
@ -100,22 +100,8 @@ svg { width: 1.15em; height: 1.15em; fill: none; stroke: currentColor; stroke-wi
|
|||||||
@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); } }
|
@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 ---------- */
|
/* ---------- layout ---------- */
|
||||||
/* `grid-template-columns: minmax(0, 1fr)` is load-bearing, not decoration.
|
main { max-width: 1180px; margin: 0 auto; padding: 20px; display: grid; gap: 18px; }
|
||||||
*
|
|
||||||
* A grid column defaults to `auto`, whose MINIMUM is min-content — and grid items
|
|
||||||
* refuse to shrink below it. The sources table has seven columns, giving it a
|
|
||||||
* ~700px min-content, and that number propagates up and pins `main` to 771px
|
|
||||||
* inside a 375px phone. Every panel then overflows and the document gets a
|
|
||||||
* horizontal scrollbar. `.table-hold { overflow-x: auto }` cannot rescue it,
|
|
||||||
* because the wrapper's own ancestors still won't shrink.
|
|
||||||
*
|
|
||||||
* The 0 in minmax(0, 1fr) overrides that automatic min-content floor. Same reason
|
|
||||||
* .panel needs min-width: 0. Remove either and mobile breaks again.
|
|
||||||
*/
|
|
||||||
main { max-width: 1180px; margin: 0 auto; padding: 20px;
|
|
||||||
display: grid; grid-template-columns: minmax(0, 1fr); gap: 18px; }
|
|
||||||
.panel {
|
.panel {
|
||||||
min-width: 0;
|
|
||||||
background: linear-gradient(var(--panel), var(--bg-2));
|
background: linear-gradient(var(--panel), var(--bg-2));
|
||||||
border: 1px solid var(--border-soft); border-radius: 16px; padding: 18px;
|
border: 1px solid var(--border-soft); border-radius: 16px; padding: 18px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
@ -268,77 +254,10 @@ main { max-width: 1180px; margin: 0 auto; padding: 20px;
|
|||||||
.ss-plate__cta svg { width: 14px; height: 14px; transition: transform .2s; }
|
.ss-plate__cta svg { width: 14px; height: 14px; transition: transform .2s; }
|
||||||
.ss-plate__link:hover .ss-plate__cta svg { transform: translateX(2px); }
|
.ss-plate__link:hover .ss-plate__cta svg { transform: translateX(2px); }
|
||||||
|
|
||||||
/* ---------- mobile ----------
|
|
||||||
*
|
|
||||||
* This is a status page. On a phone you are almost always asking ONE question:
|
|
||||||
* "is the clock still good?" So the hero answer — time, stratum, lock — must be
|
|
||||||
* readable without scrolling, and everything else can wait below the fold.
|
|
||||||
*
|
|
||||||
* The tables and the signal bars scroll sideways WITHIN their panels rather than
|
|
||||||
* being crushed into unreadability. That's deliberate: a squeezed IPv6 address
|
|
||||||
* wrapped over four lines is worse than one you swipe.
|
|
||||||
*/
|
|
||||||
@media (max-width: 700px) {
|
|
||||||
main { padding: 14px; gap: 14px; }
|
|
||||||
.panel { padding: 14px; border-radius: 14px; }
|
|
||||||
|
|
||||||
/* A panel head is a title plus its readout. Side by side they fight for a
|
|
||||||
narrow line and the title wraps mid-phrase; stacked, both stay whole. */
|
|
||||||
.panel-head { flex-direction: column; align-items: flex-start; gap: 6px; }
|
|
||||||
.spark-meta { font-size: 11px; }
|
|
||||||
|
|
||||||
/* Stack the clock above the stratum badge, and left-align the badge so the
|
|
||||||
eye travels straight down instead of ping-ponging left-right. */
|
|
||||||
.hero { flex-direction: column; align-items: flex-start; gap: 14px; }
|
|
||||||
.verdict { text-align: left; width: 100%; }
|
|
||||||
.ref-line { margin-top: 6px; }
|
|
||||||
|
|
||||||
/* clamp() already scales this, but 11vw on a narrow phone leaves the seconds
|
|
||||||
hard against the edge. Give it a slightly bigger floor and tighter tracking. */
|
|
||||||
.clock { font-size: clamp(38px, 13vw, 60px); letter-spacing: 0; }
|
|
||||||
|
|
||||||
/* Two columns, not one: six stat cards single-file is a lot of thumb. */
|
|
||||||
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
|
||||||
.card { padding: 12px; }
|
|
||||||
.card-value { font-size: 21px; }
|
|
||||||
|
|
||||||
.spark-big { height: 90px; }
|
|
||||||
.snr { min-height: 150px; }
|
|
||||||
|
|
||||||
/* Swipe the source table rather than wrap it. The shadow hint tells the reader
|
|
||||||
there IS more to the right — otherwise a cut-off column just looks broken. */
|
|
||||||
.table-hold {
|
|
||||||
overflow-x: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
background:
|
|
||||||
linear-gradient(to right, var(--panel) 30%, transparent) left / 24px 100% no-repeat,
|
|
||||||
linear-gradient(to left, var(--panel) 30%, transparent) right / 24px 100% no-repeat,
|
|
||||||
radial-gradient(farthest-side at 0 50%, rgba(0,0,0,.35), transparent) left / 12px 100% no-repeat,
|
|
||||||
radial-gradient(farthest-side at 100% 50%, rgba(0,0,0,.35), transparent) right / 12px 100% no-repeat;
|
|
||||||
background-attachment: local, local, scroll, scroll;
|
|
||||||
}
|
|
||||||
.tbl { min-width: 560px; }
|
|
||||||
|
|
||||||
.topbar { padding: 12px 14px; }
|
|
||||||
.brand h1 { font-size: 17px; }
|
|
||||||
.brand-sub { font-size: 11.5px; }
|
|
||||||
.brand-icon { width: 26px; height: 26px; }
|
|
||||||
|
|
||||||
.foot { padding: 10px 14px 24px; }
|
|
||||||
/* Three items on one line is unreadable at 375px — let them stack. */
|
|
||||||
.foot-meta { flex-direction: column; align-items: flex-start; gap: 6px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.ss-plate__link { flex-direction: column; align-items: flex-start; }
|
.ss-plate__link { flex-direction: column; align-items: flex-start; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Comfortable thumb targets. The WCAG 2.2 minimum is 24px; 44px is the iOS HIG
|
|
||||||
figure and the one that actually feels right when you're standing up. */
|
|
||||||
@media (pointer: coarse) {
|
|
||||||
.foot-docs, .ss-plate__link { min-height: 44px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
|
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
|
||||||
|
|
||||||
/* "approx" marker when GPSNTP_POSITION=coarse */
|
/* "approx" marker when GPSNTP_POSITION=coarse */
|
||||||
|
|||||||
@ -4,6 +4,6 @@
|
|||||||
|
|
||||||
COMPOSE_PROJECT_NAME=cuckoo-escapement-docs
|
COMPOSE_PROJECT_NAME=cuckoo-escapement-docs
|
||||||
|
|
||||||
# Production: escapement.warehack.ing
|
# Production: cuckoo.warehack.ing
|
||||||
# Local dev: escapement.l.warehack.ing (internal only — never reference publicly)
|
# Local dev: cuckoo.l.warehack.ing (internal only — never reference publicly)
|
||||||
DOMAIN=escapement.warehack.ing
|
DOMAIN=cuckoo.warehack.ing
|
||||||
|
|||||||
@ -56,4 +56,4 @@ deploy: ## Pull main + rebuild the docs container on the prod host
|
|||||||
@echo "==> deploying $(DEPLOY_HOST):$(DEPLOY_PATH)"
|
@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"
|
ssh -A $(DEPLOY_HOST) "cd $(DEPLOY_PATH) && git fetch origin main && git reset --hard origin/main && cd docs-site && make prod"
|
||||||
@echo "==> sanity check"
|
@echo "==> sanity check"
|
||||||
@curl -s -o /dev/null -w " HTTP %{http_code} %{url_effective}\n" "https://escapement.warehack.ing/explanation/preempt-rt-made-it-worse/"
|
@curl -s -o /dev/null -w " HTTP %{http_code} %{url_effective}\n" "https://cuckoo.warehack.ing/explanation/preempt-rt-made-it-worse/"
|
||||||
|
|||||||
@ -10,17 +10,16 @@
|
|||||||
// when the dev server runs behind Caddy (TLS-terminating proxy) — without an
|
// when the dev server runs behind Caddy (TLS-terminating proxy) — without an
|
||||||
// explicit host/protocol/clientPort, Vite's WebSocket drops every ~10s.
|
// explicit host/protocol/clientPort, Vite's WebSocket drops every ~10s.
|
||||||
//
|
//
|
||||||
// Site URL comes from DOMAIN so one image serves both escapement.warehack.ing (prod)
|
// Site URL comes from DOMAIN so one image serves both cuckoo.warehack.ing (prod)
|
||||||
// and escapement.l.warehack.ing (local dev).
|
// and cuckoo.l.warehack.ing (local dev).
|
||||||
|
|
||||||
import mdx from "@astrojs/mdx";
|
import mdx from "@astrojs/mdx";
|
||||||
import sitemap from "@astrojs/sitemap";
|
import sitemap from "@astrojs/sitemap";
|
||||||
import starlight from "@astrojs/starlight";
|
import starlight from "@astrojs/starlight";
|
||||||
import { defineConfig } from "astro/config";
|
import { defineConfig } from "astro/config";
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import starlightLinksValidator from "starlight-links-validator";
|
import starlightLinksValidator from "starlight-links-validator";
|
||||||
|
|
||||||
const domain = process.env.DOMAIN ?? "escapement.warehack.ing";
|
const domain = process.env.DOMAIN ?? "cuckoo.warehack.ing";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: `https://${domain}`,
|
site: `https://${domain}`,
|
||||||
@ -40,20 +39,12 @@ export default defineConfig({
|
|||||||
description:
|
description:
|
||||||
"What the Raspberry Pi time-server guides get wrong, and the numbers to prove it. " +
|
"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 " +
|
"GPS Stratum 1 on a Pi 4: PREEMPT_RT makes PPS jitter worse, the PPS interrupt " +
|
||||||
"cannot be pinned, PTP needs a CM4 not a 4B, and your dashboard is taxing your clock.",
|
"cannot be pinned, PTP is impossible, and your dashboard is taxing your clock.",
|
||||||
|
|
||||||
// The mark IS the word "cuckoo" — a rebus. replacesTitle stops Starlight
|
// The mark IS the word "cuckoo" — a rebus. replacesTitle stops Starlight
|
||||||
// rendering the title text beside it (which would read "…escapement The
|
// rendering the title text beside it (which would read "…escapement The
|
||||||
// Cuckoo Escapement"). The SVG's aria-label carries the full name.
|
// Cuckoo Escapement"). The SVG's aria-label carries the full name.
|
||||||
//
|
logo: { src: "./src/assets/logo.svg", replacesTitle: true },
|
||||||
// Two variants, because Starlight renders the logo as an <img> and CSS
|
|
||||||
// cannot reach inside it. The single cream-on-black logo was invisible in
|
|
||||||
// the light theme — cream wordmark on a cream page.
|
|
||||||
logo: {
|
|
||||||
light: "./src/assets/logo-light.svg",
|
|
||||||
dark: "./src/assets/logo.svg",
|
|
||||||
replacesTitle: true,
|
|
||||||
},
|
|
||||||
favicon: "/favicon.svg",
|
favicon: "/favicon.svg",
|
||||||
customCss: ["./src/styles/brass.css"],
|
customCss: ["./src/styles/brass.css"],
|
||||||
|
|
||||||
@ -108,11 +99,7 @@ export default defineConfig({
|
|||||||
pagination: true,
|
pagination: true,
|
||||||
lastUpdated: true,
|
lastUpdated: true,
|
||||||
}),
|
}),
|
||||||
// GFM tables do NOT render in .mdx without this, even though they work fine
|
mdx(),
|
||||||
// in plain .md — Astro's remark-gfm doesn't reach the MDX pipeline here. The
|
|
||||||
// failure is silent and ugly: the table falls through as a paragraph of raw
|
|
||||||
// pipe characters. It shipped that way on the landing page. Don't remove.
|
|
||||||
mdx({ remarkPlugins: [remarkGfm] }),
|
|
||||||
sitemap(),
|
sitemap(),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
#
|
#
|
||||||
# Default (no --profile flag):
|
# Default (no --profile flag):
|
||||||
# prod-style — Caddy serves the built dist/. Use for production-like
|
# prod-style — Caddy serves the built dist/. Use for production-like
|
||||||
# deploys (the public site at escapement.warehack.ing runs this).
|
# deploys (the public site at cuckoo.warehack.ing runs this).
|
||||||
#
|
#
|
||||||
# --profile dev:
|
# --profile dev:
|
||||||
# Astro dev server with HMR. Volume mounts on src/ so edits hot-reload.
|
# Astro dev server with HMR. Volume mounts on src/ so edits hot-reload.
|
||||||
@ -13,7 +13,7 @@
|
|||||||
#
|
#
|
||||||
# Both services attach to the external `caddy` network and expose
|
# Both services attach to the external `caddy` network and expose
|
||||||
# themselves to caddy-docker-proxy via labels. Edit DOMAIN in .env to
|
# themselves to caddy-docker-proxy via labels. Edit DOMAIN in .env to
|
||||||
# switch between escapement.warehack.ing (prod) and escapement.l.warehack.ing
|
# switch between cuckoo.warehack.ing (prod) and cuckoo.l.warehack.ing
|
||||||
# (local-dev tier).
|
# (local-dev tier).
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@ -28,16 +28,10 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- caddy
|
- caddy
|
||||||
labels:
|
labels:
|
||||||
caddy: ${DOMAIN:-escapement.warehack.ing}
|
caddy: ${DOMAIN:-cuckoo.warehack.ing}
|
||||||
caddy.reverse_proxy: "{{upstreams 80}}"
|
caddy.reverse_proxy: "{{upstreams 80}}"
|
||||||
# encode + gzip already in the container; let caddy pass through.
|
# encode + gzip already in the container; let caddy pass through.
|
||||||
|
|
||||||
# The site was briefly live at cuckoo.warehack.ing before the rename. Keep
|
|
||||||
# the old host as a permanent redirect rather than letting any link that
|
|
||||||
# escaped rot. {uri} preserves the path, so deep links survive too.
|
|
||||||
caddy_1: cuckoo.warehack.ing
|
|
||||||
caddy_1.redir: https://escapement.warehack.ing{uri} permanent
|
|
||||||
|
|
||||||
docs-dev:
|
docs-dev:
|
||||||
profiles: ["dev"]
|
profiles: ["dev"]
|
||||||
build:
|
build:
|
||||||
@ -47,7 +41,7 @@ services:
|
|||||||
container_name: cuckoo-escapement-docs-dev
|
container_name: cuckoo-escapement-docs-dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- DOMAIN=${DOMAIN:-escapement.l.warehack.ing}
|
- DOMAIN=${DOMAIN:-cuckoo.l.warehack.ing}
|
||||||
- ASTRO_TELEMETRY_DISABLED=1
|
- ASTRO_TELEMETRY_DISABLED=1
|
||||||
volumes:
|
volumes:
|
||||||
# Hot-reload bind mounts. node_modules stays inside the container
|
# Hot-reload bind mounts. node_modules stays inside the container
|
||||||
@ -59,7 +53,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- caddy
|
- caddy
|
||||||
labels:
|
labels:
|
||||||
caddy: ${DOMAIN:-escapement.l.warehack.ing}
|
caddy: ${DOMAIN:-cuckoo.l.warehack.ing}
|
||||||
caddy.reverse_proxy: "{{upstreams 4321}}"
|
caddy.reverse_proxy: "{{upstreams 4321}}"
|
||||||
# Vite HMR over WebSocket. Caddy's defaults close "idle" WS
|
# Vite HMR over WebSocket. Caddy's defaults close "idle" WS
|
||||||
# connections after ~10-15s; HMR doesn't send app-level pings, so
|
# connections after ~10-15s; HMR doesn't send app-level pings, so
|
||||||
|
|||||||
1
docs-site/package-lock.json
generated
1
docs-site/package-lock.json
generated
@ -12,7 +12,6 @@
|
|||||||
"@astrojs/sitemap": "^3.7.2",
|
"@astrojs/sitemap": "^3.7.2",
|
||||||
"@astrojs/starlight": "^0.39.2",
|
"@astrojs/starlight": "^0.39.2",
|
||||||
"astro": "^6.3.1",
|
"astro": "^6.3.1",
|
||||||
"remark-gfm": "^4.0.1",
|
|
||||||
"sharp": "^0.34.0",
|
"sharp": "^0.34.0",
|
||||||
"starlight-links-validator": "^0.24.0"
|
"starlight-links-validator": "^0.24.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,6 @@
|
|||||||
"@astrojs/sitemap": "^3.7.2",
|
"@astrojs/sitemap": "^3.7.2",
|
||||||
"@astrojs/starlight": "^0.39.2",
|
"@astrojs/starlight": "^0.39.2",
|
||||||
"astro": "^6.3.1",
|
"astro": "^6.3.1",
|
||||||
"remark-gfm": "^4.0.1",
|
|
||||||
"sharp": "^0.34.0",
|
"sharp": "^0.34.0",
|
||||||
"starlight-links-validator": "^0.24.0"
|
"starlight-links-validator": "^0.24.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
<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: #2b2721; }
|
|
||||||
.gear { fill: none; stroke: #a97c1f; stroke-width: 1.9; stroke-linejoin: round; }
|
|
||||||
.rim { fill: none; stroke: #a97c1f; stroke-width: 1.3; opacity: .8; }
|
|
||||||
.tick { stroke: #a97c1f; stroke-width: 1.4; opacity: .5; stroke-linecap: round; }
|
|
||||||
.head { fill: #2b2721; }
|
|
||||||
.eye { fill: #faf7f1; }
|
|
||||||
</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>
|
|
||||||
|
Before Width: | Height: | Size: 2.6 KiB |
@ -1,33 +1,14 @@
|
|||||||
---
|
---
|
||||||
title: No PTP on a Pi 4 Model B (but yes on a CM4)
|
title: Why PTP is off the table on a Pi 4
|
||||||
description: The CM4 does hardware PTP. The Pi 4 Model B does not — and both report the identical PHY ID, so the kernel tells them apart by MDIO address and declines ours by name.
|
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:
|
sidebar:
|
||||||
order: 3
|
order: 3
|
||||||
---
|
---
|
||||||
|
|
||||||
:::caution[Corrected — 2026-07-14]
|
The reference builds all reach for **PTP** (IEEE 1588), and they're right to: on
|
||||||
This page used to be called *"Why PTP is off the table on a Pi 4"* and blamed the
|
the right hardware it's dramatically better than NTP.
|
||||||
`bcmgenet` MAC driver for not exposing a PHC. **The headline overclaimed and the
|
|
||||||
mechanism was wrong.** A reader sent us
|
|
||||||
[Jeff Geerling's CM4 PTP write-up](https://www.jeffgeerling.com/blog/2022/ptp-and-ieee-1588-hardware-timestamping-on-raspberry-pi-cm4/),
|
|
||||||
which shows the **Compute Module 4 doing hardware PTP** — two of them synced to
|
|
||||||
within 10–15 nanoseconds.
|
|
||||||
|
|
||||||
Our measurement was real, but it is specific to the **Pi 4 Model B**, and the
|
The Pi 4 is not the right hardware. One command settles it:
|
||||||
actual reason is far better than "unsupported." Corrected version below. This is
|
|
||||||
the site working as intended.
|
|
||||||
:::
|
|
||||||
|
|
||||||
PTP's entire value is **hardware timestamping**: the network silicon stamps the
|
|
||||||
packet as it crosses the wire, outside the operating system. That's what takes
|
|
||||||
scheduling, driver latency and queueing out of the measurement, and it's why PTP
|
|
||||||
reaches nanoseconds where NTP reaches microseconds.
|
|
||||||
|
|
||||||
With no PHC (PTP Hardware Clock), PTP is just a protocol — packets stamped by the
|
|
||||||
*kernel*, on the *CPU*, subject to precisely the jitter you were trying to escape.
|
|
||||||
A more complicated NTP with worse tooling.
|
|
||||||
|
|
||||||
On our board:
|
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ ethtool -T eth0
|
$ ethtool -T eth0
|
||||||
@ -36,106 +17,36 @@ Capabilities:
|
|||||||
software-receive
|
software-receive
|
||||||
software-system-clock
|
software-system-clock
|
||||||
PTP Hardware Clock: none
|
PTP Hardware Clock: none
|
||||||
|
Hardware Transmit Timestamp Modes: none
|
||||||
$ ls /dev/ptp*
|
Hardware Receive Filter Modes: none
|
||||||
ls: cannot access '/dev/ptp*': No such file or directory
|
|
||||||
```
|
```
|
||||||
|
|
||||||
On a CM4 that same command reports `PTP Hardware Clock: 0`, and
|
**`PTP Hardware Clock: none`.** There isn't one. There's no `/dev/ptp0` to open.
|
||||||
`/sys/class/ptp/ptp0/clock_name` says `bcm_phy_ptp`. Same family, same era,
|
|
||||||
different answer.
|
|
||||||
|
|
||||||
## Two different chips — that report the same ID
|
## Why that's fatal rather than inconvenient
|
||||||
|
|
||||||
| board | PHY | hardware PTP |
|
PTP's whole advantage is **hardware timestamping**: the network card itself
|
||||||
|---|---|---|
|
stamps the packet as it crosses the wire, in silicon, outside the operating
|
||||||
| Compute Module 4 | BCM54210PE | **yes** |
|
system. That's what removes kernel scheduling, driver latency, and queueing from
|
||||||
| Compute Module 5 | — | **yes**, out of the box |
|
the measurement, and it's why PTP reaches nanoseconds where NTP reaches
|
||||||
| Pi 4 Model B | BCM54213PE | no |
|
microseconds.
|
||||||
|
|
||||||
Here's the part that makes this worth a page. **Both PHYs report an identical ID:**
|
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.
|
||||||
|
|
||||||
```console
|
## But the guides say the Pi 4's PHY supports PTP
|
||||||
$ cat /sys/class/mdio_bus/unimac-mdio--19/unimac-mdio--19:01/phy_id
|
|
||||||
0x600d84a2
|
|
||||||
```
|
|
||||||
|
|
||||||
That value is `PHY_ID_BCM54213PE` in `include/linux/brcmphy.h` — and a CM4
|
They do, and the *chip* does — the BCM54213PE PHY has PTP capability on paper.
|
||||||
reports it too. The silicon genuinely differs; the identifier does not. So the
|
It doesn't matter. The Pi 4's `bcmgenet` MAC driver doesn't expose a PHC, so
|
||||||
kernel *cannot* tell these boards apart by asking the chip who it is.
|
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 it tells them apart by where they're plugged in
|
## So don't chase it
|
||||||
|
|
||||||
From the Raspberry Pi kernel, `drivers/net/phy/bcm-phy-ptp.c`, comments and all:
|
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
|
||||||
```c
|
effort into the PPS path instead — that's where the nanoseconds actually are, and
|
||||||
struct bcm_ptp_private *bcm_ptp_probe(struct phy_device *phydev)
|
|
||||||
{
|
|
||||||
switch (BRCM_PHY_MODEL(phydev)) {
|
|
||||||
case PHY_ID_BCM54210E:
|
|
||||||
break;
|
|
||||||
#ifdef PHY_ID_BCM54213PE
|
|
||||||
case PHY_ID_BCM54213PE:
|
|
||||||
switch (phydev->mdio.addr) {
|
|
||||||
case 0: // CM4 - this is a BCM54210PE which supports PTP
|
|
||||||
break;
|
|
||||||
case 1: // 4B - this is a BCM54213PE which doesn't
|
|
||||||
return NULL;
|
|
||||||
default: // Unknown - assume it's BCM54210PE
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
```
|
|
||||||
|
|
||||||
The disambiguator is the **MDIO bus address**. A CM4's PHY sits at address 0; a
|
|
||||||
Pi 4 Model B's sits at address 1. Ours:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ ls -d /sys/class/mdio_bus/unimac-mdio--19/unimac-mdio--19:*
|
|
||||||
/sys/class/mdio_bus/unimac-mdio--19/unimac-mdio--19:01
|
|
||||||
^^ address 1 — the 4B branch
|
|
||||||
```
|
|
||||||
|
|
||||||
Address 1 → `return NULL` → no PHC is ever registered. That is the whole story.
|
|
||||||
|
|
||||||
## It is not your kernel, and there is no option to flip
|
|
||||||
|
|
||||||
This is the part worth internalising, because it's a trap that looks exactly like
|
|
||||||
a misconfiguration. All the support is *present*:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ grep -E "CONFIG_NETWORK_PHY_TIMESTAMPING|CONFIG_BCM_NET_PHYPTP" .config
|
|
||||||
CONFIG_NETWORK_PHY_TIMESTAMPING=y
|
|
||||||
CONFIG_BCM_NET_PHYPTP=y
|
|
||||||
```
|
|
||||||
|
|
||||||
The driver is compiled in. The PHY ID is a recognised constant. The code path
|
|
||||||
exists, and it runs. **It runs, looks at your board, and declines.** No kernel
|
|
||||||
option changes that, no patch, no `rpi-update` — the refusal is a deliberate and
|
|
||||||
correct statement about the silicon in front of it.
|
|
||||||
|
|
||||||
It also means our [`IRQF_NO_THREAD` kernel](/reference/the-patch/) is not the
|
|
||||||
culprit. That was the obvious suspicion when we re-opened this, so we checked it
|
|
||||||
first. It isn't.
|
|
||||||
|
|
||||||
## What to actually do
|
|
||||||
|
|
||||||
**On a Pi 4 Model B:** stop. Spend the effort on the PPS path, which is where the
|
|
||||||
nanoseconds actually live — and
|
|
||||||
[it needs the help](/explanation/preempt-rt-made-it-worse/).
|
[it needs the help](/explanation/preempt-rt-made-it-worse/).
|
||||||
|
|
||||||
**If you want PTP on a Pi:** buy a **CM4 or CM5**, not a 4B. You get `/dev/ptp0`,
|
|
||||||
`ethtool -T` reports hardware transmit and receive timestamping, and on the CM4 IO
|
|
||||||
board you can drive a PPS out of pin 9. Geerling's post is the guide; we're not
|
|
||||||
going to rewrite it.
|
|
||||||
|
|
||||||
**Either way, run `ethtool -T` first.** We didn't, and burned real time on a
|
|
||||||
question that one command answers.
|
|
||||||
|
|
||||||
:::note[Credit]
|
|
||||||
The correction, and every CM4 figure here, comes from
|
|
||||||
[Jeff Geerling's write-up](https://www.jeffgeerling.com/blog/2022/ptp-and-ieee-1588-hardware-timestamping-on-raspberry-pi-cm4/)
|
|
||||||
and the kernel patches it links to. We had the right conclusion for the wrong
|
|
||||||
reason, and stated it too broadly. Checking beats believing — including when the
|
|
||||||
thing being checked is us.
|
|
||||||
:::
|
|
||||||
|
|||||||
@ -12,18 +12,11 @@ A GPS-disciplined Stratum 1 NTP server: **Raspberry Pi 4** + **BerryGPS-IMU v4**
|
|||||||
199 ns**, root delay ~1 ns, survives a cold power cut unattended. About $130 of
|
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.
|
parts, replacing an appliance that costs $1,500–$10,000.
|
||||||
|
|
||||||
## What the guides get wrong on a Pi 4 Model B
|
## What the guides get wrong on a Pi 4
|
||||||
|
|
||||||
:::note[Model B, specifically]
|
|
||||||
These are properties of the **Pi 4 Model B**, not the whole Pi 4 family. The
|
|
||||||
**CM4 and CM5 do hardware PTP** — [we got that one wrong at first, and corrected
|
|
||||||
it](/explanation/no-ptp-on-a-pi-4/). If a claim here matters to you, check it on
|
|
||||||
*your* board. That's the entire point of this site.
|
|
||||||
:::
|
|
||||||
|
|
||||||
| Claim | Reality |
|
| Claim | Reality |
|
||||||
|---|---|
|
|---|---|
|
||||||
| "Use PTP for real precision" | **Not on a 4B — but yes on a CM4.** Both report the *same* PHY ID, so the kernel disambiguates by MDIO address and hands the 4B a `return NULL`. `ethtool -T` → `PTP Hardware Clock: none`. |
|
| "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`. |
|
| "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. |
|
| "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. |
|
| "Raise the GPS baud rate" | **Irrelevant.** PPS offset measured −1 ns at 9600 vs 115200. Identical. NMEA only *labels* the second. |
|
||||||
|
|||||||
@ -76,10 +76,9 @@ board rather than take our word for it. That's the whole point.
|
|||||||
costs you the accuracy.
|
costs you the accuracy.
|
||||||
[→ Why](/explanation/the-interrupt-you-cannot-move/)
|
[→ Why](/explanation/the-interrupt-you-cannot-move/)
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="No PTP on a 4B — but yes on a CM4" icon="error">
|
<Card title="PTP is impossible on a Pi 4" icon="error">
|
||||||
The CM4 and the 4B report the **same PHY ID**, so the kernel tells them apart
|
`ethtool -T eth0` → `PTP Hardware Clock: none`. There is no hardware
|
||||||
by MDIO address and hands the 4B a `return NULL`. The driver is compiled in;
|
timestamping. Software PTP is just a worse NTP. Don't chase it.
|
||||||
it looks at your board and declines. Buy a CM4 if you want PTP.
|
|
||||||
[→ Why](/explanation/no-ptp-on-a-pi-4/)
|
[→ Why](/explanation/no-ptp-on-a-pi-4/)
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Your dashboard is taxing your clock" icon="rocket">
|
<Card title="Your dashboard is taxing your clock" icon="rocket">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user