<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Networking on Andy Little</title><link>https://andylittle.net/tags/networking/</link><description>Recent content in Networking on Andy Little</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Wed, 22 Jul 2026 13:30:00 -0500</lastBuildDate><atom:link href="https://andylittle.net/tags/networking/index.xml" rel="self" type="application/rss+xml"/><item><title>Turning a Home Network into Real Infrastructure</title><link>https://andylittle.net/blog/2026/home-network-observability-automation/</link><pubDate>Wed, 22 Jul 2026 13:30:00 -0500</pubDate><guid>https://andylittle.net/blog/2026/home-network-observability-automation/</guid><description>A small Proxmox cluster, a managed switch or two, some IP cameras, and Home Assistant eventually stop being a pile of gadgets and start being infrastructure. Here&amp;rsquo;s how I built observability, a live topology graph, and an AI-assisted ops layer for mine — without letting anything have unsupervised write access to the network.</description><content:encoded><![CDATA[<p>At some point a home network stops being &ldquo;a router and some WiFi&rdquo; and starts
being infrastructure: a small Proxmox cluster, a couple of managed switches, a
NAS, Home Assistant, some IP cameras, a pile of single-purpose VMs. Once it
gets to that size, the two things a data-center network takes for granted —
<em>knowing what&rsquo;s actually out there</em> and <em>knowing when something breaks</em> —
stop being free. This is the story of building both, plus a layer that lets
Claude Code act as an ops copilot for the network without handing it the
keys unsupervised.</p>
<h2 id="the-problem-it-grew-past-i-just-know">The problem: it grew past &ldquo;I just know&rdquo;</h2>
<p>For a while, the mental model of the network lived entirely in my head. That
works until a switch port flaps, a VM migrates, or a device gets a new IP,
and you find out about it by symptom rather than by signal — the &ldquo;why is the
Wi-Fi pod offline&rdquo; question that turns into twenty minutes of SSHing into
things to check link status one port at a time.</p>
<p>The fix was to stop treating the network as a black box and start treating
it like the rest of my infrastructure: observable, documented, and — where
it&rsquo;s safe to do so — automatable.</p>
<h2 id="a-small-internal-webapp">A small internal webapp</h2>
<p>The centerpiece is a small internal dashboard with a handful of tabs:</p>
<ul>
<li><strong>Topology</strong> — a live, auto-generated map of the LAN: router, switches,
and every device, drawn from an actual scan rather than a diagram someone
has to remember to update.</li>
<li><strong>Graph</strong> — the same topology, but as an actual graph database (Neo4j)
rebuilt from scratch every 15 minutes. It&rsquo;s deliberately <em>current-state
only</em>, not a history store — every sync wipes and rebuilds the graph, so
a query always reflects what&rsquo;s plugged in right now. The tab embeds the
real Neo4j Browser so I can run ad-hoc Cypher queries against my own LAN
(&ldquo;what&rsquo;s connected to this switch port,&rdquo; &ldquo;show me every device with no
hostname&rdquo;) instead of grepping through ARP tables.</li>
<li><strong>Monitoring</strong> — Grafana/Prometheus, for the metrics side of things.</li>
<li><strong>Switch</strong> — port status and traffic for the managed switches, without
opening their web UIs.</li>
<li><strong>Syslog</strong> — the one that ended up mattering most in practice.</li>
</ul>
<h3 id="syslog-making-the-logs-actually-watch-something">Syslog: making the logs actually watch something</h3>
<p>Every host on the network already ships logs to a central syslog collector —
hundreds of sources&rsquo; worth. The problem was that nothing was <em>reading</em> them.
The answer to &ldquo;why did the WiFi pod drop&rdquo; was sitting in a switch&rsquo;s log the
whole time (a port link-down event), completely unwatched.</p>
<p>The Syslog tab adds two things on top of the raw collector: a searchable,
multi-host log viewer (term/regex filters, time ranges — with hundreds of
sources and a lot of history, filtering has to happen server-side, not in
the browser), and user-defined <strong>notification rules</strong>. A small engine runs
every minute, tails only the new bytes each watched log has grown by,
matches them against the rules, and — on a match, respecting a per-rule
cooldown so one flapping port doesn&rsquo;t turn into a hundred pages — fires a
webhook into Home Assistant, which turns it into a phone notification.</p>
<p>It&rsquo;s a small feature, but it&rsquo;s the difference between &ldquo;the network told me
something broke&rdquo; and &ldquo;I noticed something was broken.&rdquo;</p>
<h2 id="giving-claude-code-supervised-hands-on-the-network">Giving Claude Code supervised hands on the network</h2>
<p>The more interesting piece, for me, was building an MCP (Model Context
Protocol) layer so Claude Code can act on the network directly instead of me
relaying <code>show</code> command output back and forth by hand. There&rsquo;s a small set
of MCP servers, each scoped to one thing:</p>
<ul>
<li>One that talks to the router — reads configuration and running state, and
can apply changes (like adding a static host mapping) over SSH.</li>
<li>One that reads switch port status and traffic over SNMP.</li>
<li>One that lists and controls VMs/containers on the Proxmox cluster via its
API.</li>
<li>One that reads the centralized logs.</li>
<li>A generic SSH connector for anything else on the LAN.</li>
</ul>
<p>The point isn&rsquo;t &ldquo;let an AI reconfigure my network unsupervised&rdquo; — it&rsquo;s
narrowing the interface. Each server exposes a specific, reviewable set of
actions instead of a raw shell, so when I&rsquo;m working through something like a
VLAN migration or a switch swap with Claude Code, it can genuinely read the
live state of the router and switches and reason about port assignments and
firewall rules against what&rsquo;s <em>actually</em> deployed — not against my
possibly-stale mental model of it — while every actual change still goes
through review before it&rsquo;s applied.</p>
<h2 id="the-vlan-problem-consumer-mesh-wifi-cant-solve">The VLAN problem consumer mesh WiFi can&rsquo;t solve</h2>
<p>The most concrete recent project this stack enabled: putting IoT devices and
IP cameras on their own isolated VLAN, firewalled off from the trusted
network, while leaving everything else alone.</p>
<p>The obvious approach — tag a dedicated SSID to a VLAN on the existing mesh
WiFi — turned out to be a dead end. Consumer-tier mesh systems generally
don&rsquo;t support mapping individual SSIDs to VLANs; that&rsquo;s an enterprise-AP
feature. Rather than replace a perfectly good mesh system, the fix was to
add a couple of cheap, dedicated access points running OpenWrt, configured
as <strong>dumb bridges</strong> rather than routers: no DHCP, no routing, no firewall of
their own, just WiFi bridged straight onto a switch port that&rsquo;s hard-set to
the IoT VLAN. The switch does all the VLAN tagging; the access point doesn&rsquo;t
need to know VLANs exist at all.</p>
<p>On the router side, that VLAN gets its own subnet, its own DHCP scope, and a
firewall policy that defaults to <em>drop</em> everything inbound except:</p>
<ul>
<li>established/related traffic (so replies to connections IoT devices
themselves initiated still work),</li>
<li>a narrow allow rule for the one thing that has to reach in — MQTT traffic
to the home-automation host, so IoT devices can publish to it,</li>
<li>and outbound internet access, since these devices still need it for NTP,
firmware updates, and cloud features.</li>
</ul>
<p>Everything else from the IoT VLAN toward the trusted network is dropped by
default. Traffic in the other direction (trusted → IoT) stays unrestricted,
since it&rsquo;s the automation/monitoring hosts polling <em>into</em> the IoT VLAN, not
the reverse, that needs to keep working.</p>
<p>The one wrinkle worth calling out for anyone doing this themselves: mDNS and
similar local-discovery protocols don&rsquo;t cross VLAN boundaries. Anything that
relies on discovery (some smart-home integrations) needs a static
IP/reservation instead of &ldquo;just works&rdquo; auto-discovery once it&rsquo;s segmented.</p>
<h2 id="naming-things-like-infrastructure-not-like-guesses">Naming things like infrastructure, not like guesses</h2>
<p>A smaller but satisfying piece of this: formalizing a DNS naming standard.
Some of my hostnames dated back to a previous job&rsquo;s naming convention (CLLI
codes, if you know, you know) and had long since stopped meaning anything to
anyone but me. The new standard is boring on purpose — a short role code
plus a number for physical hosts (router, switch, storage, etc.), the
purpose itself as the name for single-role VMs, and a separate tier of
service aliases that point at whichever host currently provides a service
and move with it if that ever changes. It&rsquo;s a small thing, but &ldquo;the name
tells you what it is&rdquo; pays for itself every time you&rsquo;re debugging at 11pm.</p>
<h2 id="where-this-leaves-things">Where this leaves things</h2>
<p>None of this is exotic — a graph database, a syslog watcher, some MCP
servers, and a VLAN. What made it worth doing was treating a home network
like it deserves the same basic care as any other system I run: know what&rsquo;s
on it, get told when it breaks, and keep the blast radius of any one
compromised device small. The AI-copilot layer is the part I&rsquo;d call
genuinely new — not because it does anything a human couldn&rsquo;t, but because
it turns &ldquo;let me SSH into six things to check&rdquo; into a conversation grounded
in what the network actually looks like right now.</p>
]]></content:encoded></item><item><title>Building a GPS-Disciplined NTP Server on a Raspberry Pi</title><link>https://andylittle.net/blog/2026/gps-disciplined-ntp-raspberry-pi/</link><pubDate>Wed, 22 Jul 2026 13:00:00 -0500</pubDate><guid>https://andylittle.net/blog/2026/gps-disciplined-ntp-raspberry-pi/</guid><description>How a Raspberry Pi 5, a cheap u-blox GPS module, and chrony turned into a home stratum-1 time server — the wiring mistakes, the serial-port gotcha nobody documents, and the PPS upgrade that took accuracy from ±50ms to ±350ns.</description><content:encoded><![CDATA[<p>I wanted an NTP server for the home network that doesn&rsquo;t depend on internet
access to know what time it is. The public pool servers are fine, but they&rsquo;re
one more thing that goes dark if the WAN link does — and it felt like a fun
excuse to wire a GPS module to a Raspberry Pi and let satellites do the timekeeping
instead.</p>
<p>The end state: a Pi 5 (<code>pi5-2</code>) serving NTP to the LAN, disciplined by a u-blox
NEO-series GPS module. The GPS&rsquo;s NMEA sentences over serial say <em>which</em> second
it is; a PPS (pulse-per-second) line on GPIO18 says <em>exactly when</em> that second
starts. <code>chrony</code> locks onto the PPS pulse as its primary source, and the whole
LAN gets genuine stratum-1 time.</p>
<h2 id="hardware">Hardware</h2>
<ul>
<li>Raspberry Pi 5</li>
<li>A cheap u-blox NEO-6M/7M/8M GPS breakout board</li>
<li>An outdoor active GPS antenna (added later — more on why below)</li>
</ul>
<p>Wiring to the GPIO header:</p>
<table>
	<thead>
			<tr>
					<th>Module pin</th>
					<th>Pi pin</th>
					<th>Notes</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>VCC</td>
					<td>Physical pin 2 (<strong>5V</strong>)</td>
					<td>Not 3.3V. Most of these blue breakout boards have an onboard regulator that needs 5V in; 3.3V isn&rsquo;t enough to power the chip.</td>
			</tr>
			<tr>
					<td>GND</td>
					<td>Physical pin 6</td>
					<td></td>
			</tr>
			<tr>
					<td>TX</td>
					<td>Physical pin 10 (RXD0 / GPIO15)</td>
					<td></td>
			</tr>
			<tr>
					<td>RX</td>
					<td>Physical pin 8 (TXD0 / GPIO14)</td>
					<td></td>
			</tr>
			<tr>
					<td>PPS</td>
					<td>Physical pin 12 (GPIO18)</td>
					<td>Added later, for the PPS upgrade. Most NEO breakouts label this pin; if yours doesn&rsquo;t break it out, the pad is next to the antenna connector on the u-blox chip. The module only pulses PPS once it has a fix.</td>
			</tr>
	</tbody>
</table>
<p>The first debugging lesson, before any software was involved: if the module&rsquo;s
status LED never blinks, check the power pin voltage before anything else. A
GPS module that looks &ldquo;dead&rdquo; is very often just under-powered.</p>
<h2 id="software-setup">Software setup</h2>
<p>The UART on the GPIO header defaults to being the serial console/login shell,
so that has to be freed up first:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">raspi-config nonint do_serial_cons <span class="m">1</span>   <span class="c1"># disable login shell over serial</span>
</span></span><span class="line"><span class="cl">raspi-config nonint do_serial_hw <span class="m">0</span>     <span class="c1"># enable the UART hardware (adds</span>
</span></span><span class="line"><span class="cl">                                        <span class="c1"># dtparam=uart0=on on Pi 5)</span>
</span></span><span class="line"><span class="cl">reboot                                 <span class="c1"># required for the overlay to apply</span>
</span></span></code></pre></div><p>Then install the two pieces that do the actual work:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">apt install gpsd gpsd-clients chrony
</span></span></code></pre></div><p><code>gpsd</code> talks to the GPS module and parses its NMEA output; <code>chrony</code> is the NTP
daemon that turns that (plus PPS, later) into disciplined system time and
serves it to the LAN.</p>
<p>Point gpsd at the device in <code>/etc/default/gpsd</code>:</p>
<pre tabindex="0"><code>DEVICES=&#34;/dev/ttyAMA0&#34;
GPSD_OPTIONS=&#34;-n&#34;
</code></pre><p>And give chrony a GPS refclock in <code>/etc/chrony/conf.d/gps.conf</code>:</p>
<pre tabindex="0"><code>refclock SHM 0 refid NMEA precision 0.05 poll 3 filter 8 offset 0.142
allow 192.168.1.0/24
local stratum 10
</code></pre><p>Then <code>systemctl enable --now gpsd.socket gpsd.service chrony</code>.</p>
<p>That looks simple. Getting there wasn&rsquo;t — two gotchas ate most of the
debugging time, and neither is obvious from the standard docs.</p>
<h2 id="gotcha-1-devserial0-is-a-trap-on-the-pi-5">Gotcha #1: <code>/dev/serial0</code> is a trap on the Pi 5</h2>
<p>The conventional wisdom is &ldquo;use <code>/dev/serial0</code>, it&rsquo;s the stable alias for the
GPIO UART.&rdquo; On this Pi 5 + Bookworm image, that&rsquo;s wrong. <code>serial0</code> resolves to
<code>ttyAMA10</code> — the SoC&rsquo;s <em>internal</em> PL011 UART, not the RP1 UART actually wired
to the GPIO14/15 header pins. The header UART is <code>ttyAMA0</code>.</p>
<p>This is a nasty one to debug because every surface-level check says it should
work: the device exists, gpsd can open it without error, and <code>pinctrl</code> shows
the pins correctly muxed to TXD0/RXD0. It just silently receives zero bytes,
because it&rsquo;s listening to a UART with nothing physically connected to it.</p>
<p>I confirmed it by comparing <code>/sys/class/tty/ttyAMA*/device/of_node</code> against
<code>/proc/device-tree/aliases/serial0</code> (they pointed at different device-tree
nodes), and with a physical loopback test — a jumper wire between pins 8 and
10, then writing and reading <code>/dev/ttyAMA0</code> directly to prove the header UART
was the one actually moving bytes.</p>
<p><strong>Fix: use <code>/dev/ttyAMA0</code>, not <code>/dev/serial0</code>, on this board.</strong></p>
<h2 id="gotcha-2-gpsds-chrony-sock-integration-is-pps-only">Gotcha #2: gpsd&rsquo;s chrony <code>SOCK</code> integration is PPS-only</h2>
<p>gpsd ships wired up to feed chrony directly over a Unix socket at
<code>/run/chrony.&lt;device&gt;.sock</code> (chrony creates the socket; gpsd connects to it as
a client, which is the opposite of what the naming suggests). It looks like
the obvious modern integration path. On a module with no PPS wire yet, it just
doesn&rsquo;t work — <code>refclock SOCK /run/chrony.ttyAMA0.sock</code> sits at reachability 0
forever, with no error logged anywhere.</p>
<p>Digging through the strings in the <code>gpsd</code> binary turned up the reason:
<code>&quot;PPS:%s using chrony socket ...&quot;</code> — that path only activates once gpsd has a
PPS source. No PPS wire, no socket traffic, ever, silently.</p>
<p><strong>Fix: use the older <code>refclock SHM 0</code> interface</strong>, which gpsd always
populates with coarse NMEA/UBX-derived time regardless of whether PPS is
present. That&rsquo;s the <code>refclock SHM 0 refid NMEA ...</code> line above.</p>
<h2 id="calibrating-the-nmea-offset">Calibrating the NMEA offset</h2>
<p>Without PPS, NMEA time arrives late over the serial link by a fixed amount —
on this setup, a very stable +142ms (std dev under 2ms once the outdoor
antenna gave a solid fix). That&rsquo;s calibrated out with the <code>offset</code> parameter:</p>
<ol>
<li>Let chrony run with a good GPS fix for a few minutes.</li>
<li>Read the <code>Offset</code> column for the GPS source in <code>chronyc sourcestats</code> — its
offset relative to the NTP-disciplined system clock.</li>
<li>Put that value (in seconds, same sign) on the <code>refclock SHM</code> line&rsquo;s
<code>offset</code> parameter and restart chrony.</li>
<li>Verify with <code>chronyc sources</code>: the GPS offset should now sit within a few
milliseconds of the pool servers.</li>
</ol>
<p>The <code>precision 0.05</code> on that line claims a conservative ±50ms error bar —
small enough that chrony selects GPS as primary, large enough that if the
NMEA latency ever drifts substantially, the pool servers out-vote it and
chrony marks it a falseticker instead of quietly following bad time.</p>
<p>Indoors, the module struggled to get a fix at all. An outdoor active antenna
fixed that — a solid fix on ~10 satellites is what made everything past this
point (including PPS) practical.</p>
<h2 id="reading-chronyc-sources">Reading <code>chronyc sources</code></h2>
<p>If you haven&rsquo;t stared at chrony output before, the symbols are worth
knowing:</p>
<pre tabindex="0"><code>MS Name/IP address         Stratum Poll Reach LastRx Last sample
===============================================================================
#- NMEA                          0   3   377     2   +1234us[+1230us] +/- 50ms
^* time.pool.example.org         2   6   377    23   -123us[ -089us] +/-  12ms
^+ ntp2.pool.example.org         2   6   377    41   +456us[ +460us] +/-  15ms
</code></pre><ul>
<li><code>#</code> marks a reference clock (GPS); <code>^</code> marks a network peer.</li>
<li><code>*</code> is the currently selected source; <code>+</code> is a candidate in agreement;
<code>-</code> is reachable but not currently selected/combined.</li>
<li>The trailing <code>+/- Nms</code> is chrony&rsquo;s estimated error bound for that source —
this is what the <code>precision</code> setting on a refclock line ultimately informs.</li>
</ul>
<h2 id="the-pps-upgrade">The PPS upgrade</h2>
<p>NMEA-only gets you agreement with the pool to a couple of milliseconds, but
the true accuracy is only as good as that +142ms latency is stable — and it
can shift if gpsd, the sentence mix, or the module changes. PPS fixes this
properly: it&rsquo;s a hardware pulse on the <em>exact</em> top of each second, so
sub-second timing stops depending on a measured constant entirely. The NMEA
sentence still supplies <em>which</em> second it is; PPS supplies the precise edge
within it.</p>
<p><strong>1. Enable the PPS overlay.</strong> In <code>/boot/firmware/config.txt</code> (Bookworm&rsquo;s
path — not <code>/boot/config.txt</code>):</p>
<pre tabindex="0"><code>dtoverlay=pps-gpio,gpiopin=18
</code></pre><p><code>reboot</code> for the overlay to load. GPIO18 is just the convention
<code>dtoverlay=pps-gpio</code> defaults to; any free GPIO works if <code>gpiopin=</code> matches
the wire.</p>
<p><strong>2. Confirm the kernel sees pulses:</strong></p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">apt install pps-tools
</span></span><span class="line"><span class="cl">lsmod <span class="p">|</span> grep pps_gpio          <span class="c1"># pps_gpio module loaded</span>
</span></span><span class="line"><span class="cl">dmesg <span class="p">|</span> grep pps                <span class="c1"># &#34;new PPS source pps@12&#34; + &#34;Registered IRQ&#34;</span>
</span></span><span class="line"><span class="cl">ppstest /dev/pps-gps            <span class="c1"># one &#34;assert&#34; line per second, timed</span>
</span></span></code></pre></div><p>If <code>ppstest</code> prints nothing, either the module has no fix yet (most NEO
boards don&rsquo;t pulse PPS without one) or the wiring/<code>gpiopin</code> is wrong.</p>
<p><strong>3. Gotcha: <code>/dev/ppsN</code> numbering is a boot-time race.</strong> On the Pi 5, the
Ethernet controller&rsquo;s PTP clock (<code>ptp0</code>) and the GPIO PPS both register as
<code>/dev/pps*</code>, and which number each gets depends on probe order at boot. On
this box the GPIO PPS came up as <code>pps0</code> and the Ethernet PTP clock as <code>pps1</code>
— the <em>opposite</em> of what I&rsquo;d first assumed, and the opposite of how it had
been before adding PPS. Trusting a bare device number risks chrony silently
locking onto the Ethernet PTP clock after a reboot: no error, just quietly
wrong time.</p>
<p>The fix is to match on the stable device-tree <code>name</code> instead of the number,
via a udev rule (<code>/etc/udev/rules.d/10-pps-gps.rules</code>):</p>
<pre tabindex="0"><code>SUBSYSTEM==&#34;pps&#34;, ATTR{name}==&#34;pps@12.-1&#34;, SYMLINK+=&#34;pps-gps&#34;
</code></pre><p>Then <code>udevadm control --reload-rules &amp;&amp; udevadm trigger --subsystem-match=pps</code>,
and confirm <code>/dev/pps-gps</code> points at whichever <code>ppsN</code> is actually the GPIO
source. Check the <code>name</code> on your own board with <code>cat /sys/class/pps/pps*/name</code>
— the GPIO one is the <code>pps@NN</code> entry, the Ethernet one is <code>ptp0</code>.</p>
<p><strong>4. Add the PPS refclock to chrony.</strong> Keep the existing NMEA line (it still
supplies the integer second) and add a native PPS refclock locked to it, in
<code>/etc/chrony/conf.d/gps.conf</code>:</p>
<pre tabindex="0"><code>refclock SHM 0 refid NMEA precision 0.05 poll 3 filter 8 offset 0.142
refclock PPS /dev/pps-gps refid PPS lock NMEA precision 1e-7 poll 3 prefer
</code></pre><ul>
<li><code>lock NMEA</code> tells chrony to take the ambiguous PPS second-number from the
NMEA refclock — so the calibrated <code>offset 0.142</code> still matters, just for a
much looser reason: it only needs to land NMEA within ±0.2s of the pulse to
keep the lock, and 142ms is comfortably inside that.</li>
<li><code>prefer</code> makes PPS the source chrony actually steers to; the pool servers
and NMEA become agreement/sanity checks rather than the trusted source.</li>
<li><code>precision 1e-7</code> (~100ns) tells chrony this is a high-quality source, so it
gets weighted well above NMEA and the pool.</li>
</ul>
<p>Restart chrony. In <code>chronyc sources -v</code>, <code>PPS</code> should now be the selected
source (<code>#*</code>) with an estimated error in the hundreds of nanoseconds, <code>NMEA</code>
reachable but not combined (<code>#-</code>, it&rsquo;s just supplying the lock), and the pool
servers as agreement checks (<code>^-</code>). <code>journalctl -u chrony</code> logs <code>Selected source PPS</code>.</p>
<p><strong>Simpler alternative:</strong> once PPS is wired, gpsd&rsquo;s <code>SOCK</code> integration from
gotcha #2 finally has a PPS source to report, so <code>refclock SOCK /run/chrony.ttyAMA0.sock refid GPS precision 1e-7 prefer</code> works too, letting
gpsd handle the PPS plumbing. I kept the native <code>refclock PPS</code> line instead —
it&rsquo;s explicit about the NMEA lock, doesn&rsquo;t depend on gpsd&rsquo;s internal PPS
handling, and sidesteps needing gpsd to pick the correct device. SOCK is one
line if you&rsquo;d rather use it.</p>
<h2 id="where-accuracy-ended-up">Where accuracy ended up</h2>
<table>
	<thead>
			<tr>
					<th>Setup</th>
					<th>Selected source</th>
					<th>Estimated error</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>NMEA only, uncalibrated</td>
					<td>Pool servers (GPS untrusted)</td>
					<td>GPS offset unbounded/unknown</td>
			</tr>
			<tr>
					<td>NMEA only, calibrated <code>offset</code></td>
					<td>GPS (NMEA)</td>
					<td>~±50ms (agreement to 1-2ms with pool, but only as good as the +142ms latency staying stable)</td>
			</tr>
			<tr>
					<td>NMEA + PPS</td>
					<td>GPS (PPS)</td>
					<td>~±350ns</td>
			</tr>
	</tbody>
</table>
<p>The calibrated NMEA constant went from &ldquo;the thing being trusted&rdquo; to a wide
safety margin that only has to hold the PPS second-lock — a much easier job
than being the actual accuracy source.</p>
<h2 id="verifying-its-all-working">Verifying it&rsquo;s all working</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">gpspipe -w -n <span class="m">20</span>        <span class="c1"># raw gpsd JSON: satellites, fix mode, position</span>
</span></span><span class="line"><span class="cl">cgps -s                  <span class="c1"># live satellite dashboard</span>
</span></span><span class="line"><span class="cl">ppstest /dev/pps-gps     <span class="c1"># confirm PPS pulses (one assert/sec) reach the kernel</span>
</span></span><span class="line"><span class="cl">chronyc sources -v       <span class="c1"># confirm PPS is selected (#*), NMEA reachable (#-)</span>
</span></span><span class="line"><span class="cl">chronyc sourcestats
</span></span></code></pre></div><p>If <code>chronyc sources</code> shows <code>#*</code> next to <code>PPS</code>, the whole chain — antenna,
module, kernel PPS, chrony — is doing its job, and every other device on the
LAN syncing against this Pi is getting time that isn&rsquo;t waiting on an internet
connection to be right.</p>
]]></content:encoded></item></channel></rss>