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/style.css b/dashboard/src/gpsntp_dashboard/static/style.css
index b7b918c..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);
@@ -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 */
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..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://cuckoo.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"
diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs
index 435948b..5732dc2 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}`,
@@ -39,12 +40,20 @@ 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
// 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"],
@@ -99,7 +108,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"
}
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 @@
+
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/)