From 674ad25662add9762be3baded887752de3c8f872 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 14 Jul 2026 10:51:35 -0600 Subject: [PATCH 1/5] Move to escapement.warehack.ing; fix GFM tables in MDX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The landing page's headline results table was rendering as a paragraph of raw pipe characters. Astro applies remark-gfm to .md but it wasn't reaching the MDX pipeline, so every .md page rendered tables fine and only index.mdx was broken — which is exactly why it went unnoticed. mdx({remarkPlugins:[remarkGfm]}) fixes it. cuckoo.warehack.ing stays as a permanent redirect; it was live briefly. --- README.md | 2 +- dashboard/src/gpsntp_dashboard/static/index.html | 2 +- dashboard/src/gpsntp_dashboard/static/style.css | 2 +- docs-site/.env.example | 6 +++--- docs-site/Makefile | 2 +- docs-site/astro.config.mjs | 13 +++++++++---- docs-site/docker-compose.yml | 16 +++++++++++----- docs-site/package-lock.json | 1 + docs-site/package.json | 1 + 9 files changed, 29 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 82f28fe..befffba 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **What the Raspberry Pi time-server guides get wrong, and the numbers to prove it.** -Docs: **[cuckoo.warehack.ing](https://cuckoo.warehack.ing)** +Docs: **[escapement.warehack.ing](https://escapement.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 diff --git a/dashboard/src/gpsntp_dashboard/static/index.html b/dashboard/src/gpsntp_dashboard/static/index.html index 0d8ef86..46d1086 100644 --- a/dashboard/src/gpsntp_dashboard/static/index.html +++ b/dashboard/src/gpsntp_dashboard/static/index.html @@ -174,7 +174,7 @@ - + How this clock works diff --git a/dashboard/src/gpsntp_dashboard/static/style.css b/dashboard/src/gpsntp_dashboard/static/style.css index b7b918c..a172bea 100644 --- a/dashboard/src/gpsntp_dashboard/static/style.css +++ b/dashboard/src/gpsntp_dashboard/static/style.css @@ -225,7 +225,7 @@ main { max-width: 1180px; margin: 0 auto; padding: 20px; display: grid; gap: 18p * 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, + * should be quiet. Same markup as the docs site at escapement.warehack.ing, * recolored from brass to this dashboard's cyan. */ .ss-plate__link { diff --git a/docs-site/.env.example b/docs-site/.env.example index e77a21f..38f021f 100644 --- a/docs-site/.env.example +++ b/docs-site/.env.example @@ -4,6 +4,6 @@ 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 +# Production: escapement.warehack.ing +# Local dev: escapement.l.warehack.ing (internal only — never reference publicly) +DOMAIN=escapement.warehack.ing diff --git a/docs-site/Makefile b/docs-site/Makefile index fbc9515..75d0c99 100644 --- a/docs-site/Makefile +++ b/docs-site/Makefile @@ -56,4 +56,4 @@ 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/preempt-rt-made-it-worse/" + @curl -s -o /dev/null -w " HTTP %{http_code} %{url_effective}\n" "https://escapement.warehack.ing/explanation/preempt-rt-made-it-worse/" diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 435948b..b22ed46 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -10,16 +10,17 @@ // 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). +// Site URL comes from DOMAIN so one image serves both escapement.warehack.ing (prod) +// and escapement.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 remarkGfm from "remark-gfm"; import starlightLinksValidator from "starlight-links-validator"; -const domain = process.env.DOMAIN ?? "cuckoo.warehack.ing"; +const domain = process.env.DOMAIN ?? "escapement.warehack.ing"; export default defineConfig({ site: `https://${domain}`, @@ -99,7 +100,11 @@ export default defineConfig({ pagination: true, lastUpdated: true, }), - mdx(), + // GFM tables do NOT render in .mdx without this, even though they work fine + // 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(), ], }); diff --git a/docs-site/docker-compose.yml b/docs-site/docker-compose.yml index cb28879..ad03415 100644 --- a/docs-site/docker-compose.yml +++ b/docs-site/docker-compose.yml @@ -2,7 +2,7 @@ # # 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). +# deploys (the public site at escapement.warehack.ing runs this). # # --profile dev: # 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 # themselves to caddy-docker-proxy via labels. Edit DOMAIN in .env to -# switch between cuckoo.warehack.ing (prod) and cuckoo.l.warehack.ing +# switch between escapement.warehack.ing (prod) and escapement.l.warehack.ing # (local-dev tier). services: @@ -28,10 +28,16 @@ services: networks: - caddy labels: - caddy: ${DOMAIN:-cuckoo.warehack.ing} + caddy: ${DOMAIN:-escapement.warehack.ing} caddy.reverse_proxy: "{{upstreams 80}}" # 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: profiles: ["dev"] build: @@ -41,7 +47,7 @@ services: container_name: cuckoo-escapement-docs-dev restart: unless-stopped environment: - - DOMAIN=${DOMAIN:-cuckoo.l.warehack.ing} + - DOMAIN=${DOMAIN:-escapement.l.warehack.ing} - ASTRO_TELEMETRY_DISABLED=1 volumes: # Hot-reload bind mounts. node_modules stays inside the container @@ -53,7 +59,7 @@ services: networks: - caddy labels: - caddy: ${DOMAIN:-cuckoo.l.warehack.ing} + caddy: ${DOMAIN:-escapement.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 diff --git a/docs-site/package-lock.json b/docs-site/package-lock.json index 3966f5a..7ba2fd5 100644 --- a/docs-site/package-lock.json +++ b/docs-site/package-lock.json @@ -12,6 +12,7 @@ "@astrojs/sitemap": "^3.7.2", "@astrojs/starlight": "^0.39.2", "astro": "^6.3.1", + "remark-gfm": "^4.0.1", "sharp": "^0.34.0", "starlight-links-validator": "^0.24.0" } diff --git a/docs-site/package.json b/docs-site/package.json index c74adc2..96dc08d 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -14,6 +14,7 @@ "@astrojs/sitemap": "^3.7.2", "@astrojs/starlight": "^0.39.2", "astro": "^6.3.1", + "remark-gfm": "^4.0.1", "sharp": "^0.34.0", "starlight-links-validator": "^0.24.0" } From 48c99432fcd50243b35494985d88ac9c5ba9df92 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 14 Jul 2026 10:56:23 -0600 Subject: [PATCH 2/5] Dashboard: mobile layout fix (grid min-content blowout) --- .../src/gpsntp_dashboard/static/index.html | 2 +- .../src/gpsntp_dashboard/static/style.css | 85 ++++++++++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/dashboard/src/gpsntp_dashboard/static/index.html b/dashboard/src/gpsntp_dashboard/static/index.html index 46d1086..0d8ef86 100644 --- a/dashboard/src/gpsntp_dashboard/static/index.html +++ b/dashboard/src/gpsntp_dashboard/static/index.html @@ -174,7 +174,7 @@ - + How this clock works diff --git a/dashboard/src/gpsntp_dashboard/static/style.css b/dashboard/src/gpsntp_dashboard/static/style.css index a172bea..0e3071b 100644 --- a/dashboard/src/gpsntp_dashboard/static/style.css +++ b/dashboard/src/gpsntp_dashboard/static/style.css @@ -100,8 +100,22 @@ 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); } } /* ---------- layout ---------- */ -main { max-width: 1180px; margin: 0 auto; padding: 20px; display: grid; gap: 18px; } +/* `grid-template-columns: minmax(0, 1fr)` is load-bearing, not decoration. + * + * 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 { + min-width: 0; background: linear-gradient(var(--panel), var(--bg-2)); border: 1px solid var(--border-soft); border-radius: 16px; padding: 18px; box-shadow: var(--shadow); @@ -225,7 +239,7 @@ main { max-width: 1180px; margin: 0 auto; padding: 20px; display: grid; gap: 18p * 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 escapement.warehack.ing, + * should be quiet. Same markup as the docs site at cuckoo.warehack.ing, * recolored from brass to this dashboard's cyan. */ .ss-plate__link { @@ -254,10 +268,77 @@ main { max-width: 1180px; margin: 0 auto; padding: 20px; display: grid; gap: 18p .ss-plate__cta svg { width: 14px; height: 14px; transition: transform .2s; } .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) { .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; } } /* "approx" marker when GPSNTP_POSITION=coarse */ From 61d9c1d0b2d2972ce5c91fee5506a11cdff34796 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 14 Jul 2026 10:58:36 -0600 Subject: [PATCH 3/5] =?UTF-8?q?Light-theme=20logo=20variant=20=E2=80=94=20?= =?UTF-8?q?the=20wordmark=20was=20invisible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starlight renders the logo as an , so CSS can't reach inside it. The single cream-on-near-black SVG left a cream wordmark on a cream page. Ship a light variant with warm ink and the darker brass the light theme already uses. --- docs-site/astro.config.mjs | 10 ++++++- docs-site/src/assets/logo-light.svg | 43 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 docs-site/src/assets/logo-light.svg diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index b22ed46..4424090 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -45,7 +45,15 @@ export default defineConfig({ // 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 }, + // + // Two variants, because Starlight renders the logo as an 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", customCss: ["./src/styles/brass.css"], diff --git a/docs-site/src/assets/logo-light.svg b/docs-site/src/assets/logo-light.svg new file mode 100644 index 0000000..1586853 --- /dev/null +++ b/docs-site/src/assets/logo-light.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + escapement + From 1ed8f720d66000ff619da83ad06484f0d6e33655 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 14 Jul 2026 14:50:27 -0600 Subject: [PATCH 4/5] Correct the PTP page: no PTP on a 4B, but the CM4 has it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reader sent Geerling's CM4 PTP write-up, which shows two CM4s syncing to 10-15ns. Our headline overclaimed ('impossible on a Pi 4') and our mechanism was wrong (we blamed the bcmgenet MAC for not exposing a PHC). The real story is better: the CM4 (BCM54210PE) and the 4B (BCM54213PE) report the IDENTICAL PHY ID 0x600d84a2, so the kernel cannot tell them apart by asking the chip. bcm-phy-ptp.c disambiguates by MDIO bus address instead — addr 0 = CM4, proceed; addr 1 = 4B, return NULL. Ours is addr 1. It is not a kernel config gap and not our RT patch: CONFIG_BCM_NET_PHYPTP=y and CONFIG_NETWORK_PHY_TIMESTAMPING=y are both set. The driver loads, looks at the board, and declines. Correction is visible on the page rather than quietly patched. Credit to Geerling. --- docs-site/astro.config.mjs | 2 +- .../docs/explanation/no-ptp-on-a-pi-4.md | 145 ++++++++++++++---- docs-site/src/content/docs/findings.md | 11 +- docs-site/src/content/docs/index.mdx | 7 +- 4 files changed, 131 insertions(+), 34 deletions(-) diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 4424090..5732dc2 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -40,7 +40,7 @@ export default defineConfig({ 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.", + "cannot be pinned, PTP needs a CM4 not a 4B, 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 diff --git a/docs-site/src/content/docs/explanation/no-ptp-on-a-pi-4.md b/docs-site/src/content/docs/explanation/no-ptp-on-a-pi-4.md index 14fe93c..04a3390 100644 --- a/docs-site/src/content/docs/explanation/no-ptp-on-a-pi-4.md +++ b/docs-site/src/content/docs/explanation/no-ptp-on-a-pi-4.md @@ -1,14 +1,33 @@ --- -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. +title: No PTP on a Pi 4 Model B (but yes on a CM4) +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. 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. +:::caution[Corrected — 2026-07-14] +This page used to be called *"Why PTP is off the table on a Pi 4"* and blamed the +`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. -The Pi 4 is not the right hardware. One command settles it: +Our measurement was real, but it is specific to the **Pi 4 Model B**, and the +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 $ ethtool -T eth0 @@ -17,36 +36,106 @@ Capabilities: software-receive software-system-clock PTP Hardware Clock: none -Hardware Transmit Timestamp Modes: none -Hardware Receive Filter Modes: none + +$ ls /dev/ptp* +ls: cannot access '/dev/ptp*': No such file or directory ``` -**`PTP Hardware Clock: none`.** There isn't one. There's no `/dev/ptp0` to open. +On a CM4 that same command reports `PTP Hardware Clock: 0`, and +`/sys/class/ptp/ptp0/clock_name` says `bcm_phy_ptp`. Same family, same era, +different answer. -## Why that's fatal rather than inconvenient +## Two different chips — that report the same ID -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. +| board | PHY | hardware PTP | +|---|---|---| +| Compute Module 4 | BCM54210PE | **yes** | +| Compute Module 5 | — | **yes**, out of the box | +| Pi 4 Model B | BCM54213PE | no | -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. +Here's the part that makes this worth a page. **Both PHYs report an identical ID:** -## But the guides say the Pi 4's PHY supports PTP +```console +$ cat /sys/class/mdio_bus/unimac-mdio--19/unimac-mdio--19:01/phy_id +0x600d84a2 +``` -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. +That value is `PHY_ID_BCM54213PE` in `include/linux/brcmphy.h` — and a CM4 +reports it too. The silicon genuinely differs; the identifier does not. So the +kernel *cannot* tell these boards apart by asking the chip who it is. -## So don't chase it +## So it tells them apart by where they're plugged in -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 +From the Raspberry Pi kernel, `drivers/net/phy/bcm-phy-ptp.c`, comments and all: + +```c +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/). + +**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. +::: diff --git a/docs-site/src/content/docs/findings.md b/docs-site/src/content/docs/findings.md index ee7cb67..a46b007 100644 --- a/docs-site/src/content/docs/findings.md +++ b/docs-site/src/content/docs/findings.md @@ -12,11 +12,18 @@ 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 parts, replacing an appliance that costs $1,500–$10,000. -## What the guides get wrong on a Pi 4 +## What the guides get wrong on a Pi 4 Model B + +:::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 | |---|---| -| "Use PTP for real precision" | **Impossible.** `ethtool -T eth0` → `PTP Hardware Clock: none`. No hardware timestamping exists on this NIC. | +| "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`. | | "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. | diff --git a/docs-site/src/content/docs/index.mdx b/docs-site/src/content/docs/index.mdx index 07cd3bb..8ab6809 100644 --- a/docs-site/src/content/docs/index.mdx +++ b/docs-site/src/content/docs/index.mdx @@ -76,9 +76,10 @@ board rather than take our word for it. That's the whole point. costs you the accuracy. [→ Why](/explanation/the-interrupt-you-cannot-move/) - - `ethtool -T eth0` → `PTP Hardware Clock: none`. There is no hardware - timestamping. Software PTP is just a worse NTP. Don't chase it. + + The CM4 and the 4B report the **same PHY ID**, so the kernel tells them apart + by MDIO address and hands the 4B a `return NULL`. The driver is compiled in; + it looks at your board and declines. Buy a CM4 if you want PTP. [→ Why](/explanation/no-ptp-on-a-pi-4/) From 1a751469b385be7ed28762af95dc4cba04d941d8 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 29 Aug 2026 21:49:32 -0600 Subject: [PATCH 5/5] docs-site: deploy via git-pull on dell01, not the dead warehack.ing/docker-2 host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs run on dell01 now; the apex warehack.ing SSH name points at the web relay (no port 22) and docker-2 is superseded. Also fix the fetch bug: 'git fetch origin main' only writes FETCH_HEAD, so 'reset --hard origin/main' fails on a fresh checkout — use bare 'git fetch origin'. --- docs-site/Makefile | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs-site/Makefile b/docs-site/Makefile index 75d0c99..af563f6 100644 --- a/docs-site/Makefile +++ b/docs-site/Makefile @@ -39,21 +39,21 @@ shell: ## Open a shell in the running dev container (debugging) # ---- 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. +# `make deploy` SSHes to dell01 (where the docs run, behind the shared +# caddy-docker-proxy), has it pull origin/main from Gitea via the forwarded +# agent key, and rebuilds the container. Nothing persistent is provisioned. # -# Override DEPLOY_HOST / DEPLOY_PATH for a different deployment without -# editing this file. The defaults are the warehack.ing cookie-cutter shape +# `git fetch origin` (bare) — NOT `git fetch origin main`, which only writes +# FETCH_HEAD, leaving origin/main unresolved so the reset fails on a fresh +# checkout. Override DEPLOY_HOST / DEPLOY_PATH for a different target. # (see ~/.claude/references/warehacking.md). -DEPLOY_HOST ?= warehack-ing@warehack.ing -DEPLOY_PATH ?= ~/cuckoo-escapement +DEPLOY_HOST ?= dell01 +DEPLOY_PATH ?= warehack-ing/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://escapement.warehack.ing/explanation/preempt-rt-made-it-worse/" +deploy: ## Pull origin/main on dell01 + rebuild the docs container + @echo "==> deploying on $(DEPLOY_HOST):$(DEPLOY_PATH)" + ssh -A $(DEPLOY_HOST) "cd $(DEPLOY_PATH) && git fetch --quiet origin && git reset --hard origin/main && cd docs-site && make prod" + @echo "==> sanity check (title of a real page; bogus path should 404)" + @curl -sS "https://escapement.warehack.ing/explanation/preempt-rt-made-it-worse/" | grep -oE "[^<]*" || echo " title not found"