<?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>Andy Little</title><link>https://andylittle.net/</link><description>Recent content on Andy Little</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Wed, 29 Jul 2026 12:00:00 -0500</lastBuildDate><atom:link href="https://andylittle.net/index.xml" rel="self" type="application/rss+xml"/><item><title>Getting Fan RPM Out of a Lenovo M900 Tiny via lm-sensors</title><link>https://andylittle.net/blog/2026/nct6683-fan-rpm-lenovo-m900-tiny/</link><pubDate>Wed, 29 Jul 2026 12:00:00 -0500</pubDate><guid>https://andylittle.net/blog/2026/nct6683-fan-rpm-lenovo-m900-tiny/</guid><description>lm-sensors&amp;rsquo; nct6683 driver silently refused to bind on a Lenovo M900 Tiny running Proxmox — no error, just no fan data. The cause was a vendor/build check in the driver tripping on this box&amp;rsquo;s EC firmware, and the fix was a one-line force=1 module param made persistent.</description><content:encoded><![CDATA[<p><code>pve-m900-1</code>, a Lenovo M900 Tiny, is one of the small Proxmox boxes in my
homelab — it runs dev/test workloads and the NetFlow collector VM. It has no
spinning disk (single SSD), so the only moving part worth monitoring is the
fan. <code>sensors</code> was already showing <code>coretemp</code> and PECI temperatures just
fine, but no fan RPM at all — not a zero, just absent, as if the box didn&rsquo;t
have a fan sensor.</p>
<h2 id="the-dead-end-no-error-just-nothing">The dead end: no error, just nothing</h2>
<p>The Super I/O chip on these Tiny-series boards is a Nuvoton NCT6683, and the
matching kernel driver is <code>nct6683</code> (part of <code>lm-sensors</code>). Normally you
<code>modprobe nct6683</code>, run <code>sensors-detect</code>, and get <code>fan1</code>/<code>fan2</code> readings for
free. Here, <code>modprobe nct6683</code> returned cleanly, but <code>sensors</code> still showed no
fan entry:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">modprobe nct6683
</span></span><span class="line"><span class="cl">sensors
</span></span><span class="line"><span class="cl"><span class="c1"># coretemp-isa-0000 and peci entries present, no nct6683 block at all</span>
</span></span><span class="line"><span class="cl">dmesg <span class="p">|</span> grep -i nct6683
</span></span><span class="line"><span class="cl"><span class="c1"># (nothing)</span>
</span></span></code></pre></div><p>No error message, no log line, nothing in <code>dmesg</code> — the module just wasn&rsquo;t
registering a device. That absence of any error is the tell: this isn&rsquo;t a
missing-driver problem, it&rsquo;s a driver that loaded and then quietly declined
to attach to the hardware.</p>
<h2 id="the-cause-an-ec-firmwarevendor-check">The cause: an EC firmware/vendor check</h2>
<p>The <code>nct6683</code> driver does a sanity check against the embedded controller&rsquo;s
firmware build ID before it will bind — it&rsquo;s a safety measure, since writing
to the wrong registers on an unrecognized EC build can do bad things. This
M900 Tiny&rsquo;s EC firmware build (<code>1.0 build 12/30/15</code>) isn&rsquo;t in the driver&rsquo;s
list of known-good builds, so the check fails and the driver bails out with
<code>ENODEV</code> before ever creating the sensor device — silently, since this is
treated as &ldquo;unsupported hardware,&rdquo; not an error condition.</p>
<p>The driver exposes an escape hatch for exactly this: a <code>force</code> module
parameter that skips the vendor/build check and binds anyway.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">modprobe -r nct6683
</span></span><span class="line"><span class="cl">modprobe nct6683 <span class="nv">force</span><span class="o">=</span><span class="m">1</span>
</span></span><span class="line"><span class="cl">sensors
</span></span></code></pre></div><p>With <code>force=1</code>, <code>sensors</code> immediately reports a new block with <code>fan2</code> reading
a baseline of roughly 980 RPM, alongside the existing <code>coretemp</code>/PECI
entries.</p>
<h2 id="making-it-persistent">Making it persistent</h2>
<p>A manual <code>modprobe force=1</code> doesn&rsquo;t survive a reboot, and doesn&rsquo;t help if
<code>nct6683</code> isn&rsquo;t in the auto-loaded module list at all. Two small config
files fix both:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Force the parameter every time the module loads</span>
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;options nct6683 force=1&#34;</span> &gt; /etc/modprobe.d/nct6683.conf
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Make sure the module actually loads at boot</span>
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;nct6683&#34;</span> &gt;&gt; /etc/modules
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">update-initramfs -u
</span></span><span class="line"><span class="cl">reboot
</span></span></code></pre></div><p>After the reboot, <code>sensors</code> shows <code>fan2</code> without any manual intervention —
same as it would if the driver&rsquo;s built-in check had recognized the board in
the first place.</p>
<h2 id="reference">Reference</h2>
<table>
	<thead>
			<tr>
					<th>Item</th>
					<th>Value</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Hardware</td>
					<td>Lenovo M900 Tiny (<code>pve-m900-1</code>)</td>
			</tr>
			<tr>
					<td>Super I/O chip</td>
					<td>Nuvoton NCT6683</td>
			</tr>
			<tr>
					<td>Driver</td>
					<td><code>nct6683</code> (lm-sensors)</td>
			</tr>
			<tr>
					<td>Symptom</td>
					<td><code>modprobe</code> succeeds, no fan entry in <code>sensors</code>, nothing in <code>dmesg</code></td>
			</tr>
			<tr>
					<td>Root cause</td>
					<td>EC firmware build <code>1.0 build 12/30/15</code> fails the driver&rsquo;s vendor/build check → silent <code>ENODEV</code></td>
			</tr>
			<tr>
					<td>Fix</td>
					<td><code>force=1</code> module parameter</td>
			</tr>
			<tr>
					<td>Persistence</td>
					<td><code>/etc/modprobe.d/nct6683.conf</code> (<code>options nct6683 force=1</code>) + <code>nct6683</code> in <code>/etc/modules</code></td>
			</tr>
			<tr>
					<td>Result</td>
					<td><code>sensors</code> reports <code>fan2</code>, ~980 RPM baseline</td>
			</tr>
	</tbody>
</table>
<p>If a Super I/O sensor driver loads without error but produces zero fan/temp
entries and <code>dmesg</code> is silent, a vendor or firmware-build allowlist check
inside the driver — not a missing driver — is worth suspecting before
anything else.</p>
<hr>
<p><em>This guide was written by Claude, an AI assistant made by Anthropic, based on
a hands-on troubleshooting session working through this exact problem.</em></p>
]]></content:encoded></item><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><item><title>Installing the NVIDIA T600 Driver with PRIME Render Offload on Debian 12 (Dell Precision 3571)</title><link>https://andylittle.net/blog/2026/nvidia-t600-prime-render-offload-debian-12/</link><pubDate>Wed, 22 Jul 2026 12:00:00 -0500</pubDate><guid>https://andylittle.net/blog/2026/nvidia-t600-prime-render-offload-debian-12/</guid><description>A step-by-step guide to getting a PRIME render offload setup working on a Dell Precision 3571 — Intel iGPU driving the desktop, NVIDIA T600 on demand — plus the specific mistakes that commonly break it.</description><content:encoded><![CDATA[<p>This guide walks through installing the proprietary NVIDIA driver on a Dell
Precision 3571 (or any similar Optimus laptop with an NVIDIA T600 / Turing-class
dGPU and Intel integrated graphics) running Debian 12 &ldquo;Bookworm.&rdquo; The goal is a
proper <strong>PRIME render offload</strong> setup: the Intel iGPU drives the desktop for
good battery life, and the NVIDIA GPU stays parked until you explicitly hand it
a workload (CUDA, Blender, NVENC, 3D, etc.).</p>
<p>It also documents the specific mistakes that commonly break this install, so
you can avoid them.</p>
<p><strong>Applies to:</strong> Debian 12 (Bookworm), Optimus laptops with Intel iGPU + NVIDIA
dGPU. Examples use an NVIDIA T600 Laptop GPU (Turing, <code>TU117</code>) at PCI address
<code>01:00.0</code>. Adjust the PCI address to match your hardware (find it with
<code>lspci | grep -i nvidia</code>).</p>
<h2 id="before-you-start-know-your-starting-state">Before you start: know your starting state</h2>
<p>Before installing anything, confirm what the system is actually running. This
avoids fighting a half-configured driver. Run:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">lspci -k -s 01:00.0                <span class="c1"># which kernel driver is bound to the GPU?</span>
</span></span><span class="line"><span class="cl">lsmod <span class="p">|</span> grep -E <span class="s1">&#39;nvidia|nouveau&#39;</span>   <span class="c1"># which modules are loaded right now?</span>
</span></span><span class="line"><span class="cl">dpkg -l <span class="p">|</span> grep -i nvidia           <span class="c1"># any NVIDIA packages already installed?</span>
</span></span><span class="line"><span class="cl">ls -la /etc/X11/xorg.conf          <span class="c1"># is there a leftover X config? (there should NOT be)</span>
</span></span></code></pre></div><p>On a fresh system you will typically see the open-source <code>nouveau</code> driver
bound to the card, no NVIDIA packages installed, and no <code>/etc/X11/xorg.conf</code>.
That is the clean baseline this guide assumes.</p>
<p><strong>If <code>/etc/X11/xorg.conf</code> exists, deal with it first.</strong> A stale X config —
usually left behind by a previous <code>nvidia-xconfig</code> run — is the single most
common reason a &ldquo;clean&rdquo; reinstall still produces a black screen. Move it out
of the way: <code>sudo mv /etc/X11/xorg.conf /etc/X11/xorg.conf.bak</code>. On a
PRIME/Optimus laptop you want <strong>no</strong> hand-written <code>xorg.conf</code> at all.</p>
<h2 id="step-1--enable-the-non-free-repositories">Step 1 — Enable the <code>non-free</code> repositories</h2>
<p>The NVIDIA driver lives in Debian&rsquo;s <code>non-free</code> component. Check your sources:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cat /etc/apt/sources.list
</span></span></code></pre></div><p>Each Bookworm line should include <code>contrib non-free non-free-firmware</code>. For
example:</p>
<pre tabindex="0"><code>deb http://deb.debian.org/debian/ bookworm main contrib non-free non-free-firmware
deb http://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware
deb http://deb.debian.org/debian/ bookworm-updates main contrib non-free non-free-firmware
</code></pre><p>If your system uses the newer deb822 format instead, you&rsquo;ll have a <code>.sources</code>
file under <code>/etc/apt/sources.list.d/</code>; add <code>contrib non-free non-free-firmware</code>
to its <code>Components:</code> line rather than editing <code>sources.list</code>.</p>
<h2 id="step-2--install-kernel-headers-and-the-driver">Step 2 — Install kernel headers and the driver</h2>
<p>The kernel headers are not optional. Without them, DKMS has nothing to build
the NVIDIA kernel module against, and the install will <em>silently</em> do nothing —
you&rsquo;ll reboot and still be on nouveau with no obvious error. Install the
headers first, then the driver:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt update
</span></span><span class="line"><span class="cl">sudo apt install linux-headers-amd64
</span></span><span class="line"><span class="cl">sudo apt install nvidia-driver mesa-utils
</span></span></code></pre></div><p>The <code>nvidia-driver</code> metapackage pulls in <code>nvidia-kernel-dkms</code> and builds the
module during installation. <code>mesa-utils</code> gives you <code>glxinfo</code> for verification
later.</p>
<p><strong>Watch the two messages that appear during install</strong> — both are normal and
neither is an error. They&rsquo;re explained next.</p>
<h3 id="conflicting-nouveau-kernel-module-loaded">&ldquo;Conflicting nouveau kernel module loaded&rdquo;</h3>
<p>The installer warns that nouveau is currently loaded and conflicts with the
NVIDIA module. This is expected: nouveau is driving the card in your <em>running</em>
session and can&rsquo;t be swapped out live. The install adds a modprobe blacklist
for nouveau; the switch happens on reboot. Just acknowledge the dialog and let
the install finish.</p>
<h3 id="dkms-builds-for-the-current-kernel-and-may-skip-an-old-one">DKMS builds for the current kernel and may &ldquo;skip&rdquo; an old one</h3>
<p>If you recently ran a system upgrade, you may have more than one kernel
installed. DKMS builds the module for your current/newest kernel and may
report skipping an older one:</p>
<pre tabindex="0"><code>Building for 6.1.0-XX-amd64 6.1.0-YY-amd64
Module build for kernel 6.1.0-XX-amd64 was skipped since the
kernel headers for this kernel do not seem to be installed.
Building initial module for 6.1.0-YY-amd64
</code></pre><p>This is fine. The module is built for the kernel you&rsquo;ll actually boot (the
newest one). The skipped kernel is just an older fallback entry in GRUB. If you
want to silence the skip — for example to keep the fallback kernel bootable
with NVIDIA — install headers for that specific version:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt install linux-headers-6.1.0-XX-amd64   <span class="c1"># optional; replace with the skipped version</span>
</span></span></code></pre></div><h2 id="step-3--reboot-and-verify">Step 3 — Reboot and verify</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo reboot
</span></span></code></pre></div><p>If the machine comes back up to a normal desktop (no black screen), the
Intel-drives-the-display half is already working. Now confirm the driver swap
and the offload behavior:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">lsmod <span class="p">|</span> grep -E <span class="s1">&#39;nvidia|nouveau&#39;</span>    <span class="c1"># want nvidia* loaded, nouveau ABSENT</span>
</span></span><span class="line"><span class="cl">nvidia-smi                          <span class="c1"># want the GPU listed with its driver version</span>
</span></span><span class="line"><span class="cl">glxinfo <span class="p">|</span> grep <span class="s2">&#34;OpenGL renderer&#34;</span>    <span class="c1"># want Intel here (desktop on the iGPU = correct)</span>
</span></span></code></pre></div><p><strong>The desktop renderer reporting Intel is the goal, not a failure.</strong> Your
everyday desktop should render on the iGPU for battery life. The NVIDIA GPU
only comes into play when you explicitly offload to it (next step).</p>
<p>Now prove the NVIDIA GPU wakes on demand:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">__NV_PRIME_RENDER_OFFLOAD</span><span class="o">=</span><span class="m">1</span> <span class="nv">__GLX_VENDOR_LIBRARY_NAME</span><span class="o">=</span>nvidia glxinfo <span class="p">|</span> grep <span class="s2">&#34;OpenGL renderer&#34;</span>
</span></span></code></pre></div><p>This one should report the NVIDIA T600. If it does, PRIME render offload is
working end to end.</p>
<h2 id="step-4--using-the-gpu-offload-invocation">Step 4 — Using the GPU (offload invocation)</h2>
<p>To run any application on the NVIDIA GPU, prefix it with the two offload
environment variables:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">__NV_PRIME_RENDER_OFFLOAD</span><span class="o">=</span><span class="m">1</span> <span class="nv">__GLX_VENDOR_LIBRARY_NAME</span><span class="o">=</span>nvidia &lt;application&gt;
</span></span></code></pre></div><p>For example, to launch a game or a 3D app on the dGPU while everything else
stays on Intel. For convenience you can wrap this in a small shell alias or a
<code>prime-run</code>-style helper script.</p>
<h2 id="step-5--suspendresume-and-power-tuning">Step 5 — Suspend/resume and power tuning</h2>
<p>Debian&rsquo;s NVIDIA package ships <code>/etc/modprobe.d/nvidia-options.conf</code> with
several power-management options present but commented out. Two of them matter
on a laptop. Open the file:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo nano /etc/modprobe.d/nvidia-options.conf
</span></span></code></pre></div><p>Uncomment these two lines (remove the leading <code>#</code>):</p>
<pre tabindex="0"><code>options nvidia-current NVreg_PreserveVideoMemoryAllocations=1
options nvidia-current NVreg_EnableS0ixPowerManagement=1
</code></pre><table>
	<thead>
			<tr>
					<th>Option</th>
					<th>What it does</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>NVreg_PreserveVideoMemoryAllocations=1</code></td>
					<td>Saves the GPU&rsquo;s VRAM contents to system RAM on suspend and restores them on resume. Prevents corrupted output or a hung GPU after waking — essential on a laptop that suspends frequently. Also what makes the <code>nvidia-suspend</code> / <code>nvidia-hibernate</code> / <code>nvidia-resume</code> services function.</td>
			</tr>
			<tr>
					<td><code>NVreg_EnableS0ixPowerManagement=1</code></td>
					<td>Integrates the driver with the modern s2idle / s0ix low-power standby path used by current Intel laptops (including Alder Lake–based machines like the Precision 3571), rather than assuming legacy deep S3 sleep.</td>
			</tr>
	</tbody>
</table>
<p>Rebuild the initramfs and reboot so the options take effect:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo update-initramfs -u
</span></span><span class="line"><span class="cl">sudo reboot
</span></span></code></pre></div><p>After the reboot, test a suspend/resume cycle and confirm the display comes
back cleanly:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">systemctl <span class="nb">suspend</span>
</span></span><span class="line"><span class="cl"><span class="c1"># wake the machine, confirm the screen restores correctly</span>
</span></span></code></pre></div><h2 id="optional--fully-power-down-the-dgpu-when-idle">Optional — Fully power down the dGPU when idle</h2>
<p>You may notice the NVIDIA GPU stays in a low but non-zero power state even
when nothing is offloaded to it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cat /sys/bus/pci/devices/0000:01:00.0/power/runtime_status   <span class="c1"># may read &#34;active&#34;</span>
</span></span></code></pre></div><p>To have the driver cut power to the GPU entirely (D3cold) when idle, add the
aggressive dynamic power-management option as a <strong>separate, deliberate
change</strong>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;options nvidia &#34;NVreg_DynamicPowerManagement=0x02&#34;&#39;</span> <span class="p">|</span> sudo tee /etc/modprobe.d/nvidia-pm.conf
</span></span><span class="line"><span class="cl">sudo update-initramfs -u
</span></span><span class="line"><span class="cl">sudo reboot
</span></span></code></pre></div><p>After rebooting, re-check <code>runtime_status</code> (you want <code>suspended</code>) <strong>and
re-test <code>systemctl suspend</code></strong>. D3cold is the one setting most likely to
interfere with clean suspend/resume or dGPU wake on some configurations. If
anything misbehaves, simply remove the file
(<code>sudo rm /etc/modprobe.d/nvidia-pm.conf</code>), rebuild the initramfs, and reboot
to revert.</p>
<p>Change one power option at a time and re-test suspend after each. It&rsquo;s the
only reliable way to know which knob caused a regression.</p>
<h2 id="pitfalls--what-not-to-do">Pitfalls — what <em>not</em> to do</h2>
<table>
	<thead>
			<tr>
					<th>Don&rsquo;t</th>
					<th>Why it breaks things</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>nvidia-xconfig</code></td>
					<td>Generates an <code>/etc/X11/xorg.conf</code> that hard-pins X to the NVIDIA driver as the primary display. On an Optimus laptop the panel is wired to the Intel iGPU, so this causes a black screen or forces the dGPU to render the entire desktop (killing battery). Worse, the file <em>persists after you purge the driver</em>, so the system stays broken even after removal. Never run it on this class of hardware.</td>
			</tr>
			<tr>
					<td><code>apt install nvidia-driver-full</code></td>
					<td>That package name is an Ubuntu-ism — it does not exist in Debian&rsquo;s repositories. In Debian the package is simply <code>nvidia-driver</code>. Mixing a real metapackage with a nonexistent one leaves a partial, confusing state.</td>
			</tr>
			<tr>
					<td>Skipping <code>linux-headers-amd64</code></td>
					<td>Without matching kernel headers, DKMS can&rsquo;t build the kernel module and the install quietly no-ops. You reboot and you&rsquo;re still on nouveau, with no obvious error to point at.</td>
			</tr>
			<tr>
					<td>Hand-writing an <code>xorg.conf</code></td>
					<td>On a PRIME/Optimus setup, modern X auto-detects correctly. A manual config almost always fights the offload arrangement. Keep it absent.</td>
			</tr>
	</tbody>
</table>
<h2 id="reference--working-configuration-summary">Reference — working configuration summary</h2>
<table>
	<thead>
			<tr>
					<th>Item</th>
					<th>Value</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Hardware</td>
					<td>Dell Precision 3571, Intel iGPU + NVIDIA T600 Laptop GPU (Turing, TU117)</td>
			</tr>
			<tr>
					<td>OS</td>
					<td>Debian 12 (Bookworm)</td>
			</tr>
			<tr>
					<td>Driver</td>
					<td><code>nvidia-driver</code> (Bookworm <code>non-free</code>), DKMS-built against <code>linux-headers-amd64</code></td>
			</tr>
			<tr>
					<td>Mode</td>
					<td>PRIME render offload — Intel drives the desktop, NVIDIA on demand</td>
			</tr>
			<tr>
					<td>Offload invocation</td>
					<td><code>__NV_PRIME_RENDER_OFFLOAD=1 __GLX_VENDOR_LIBRARY_NAME=nvidia &lt;app&gt;</code></td>
			</tr>
			<tr>
					<td>Power options (<code>/etc/modprobe.d/nvidia-options.conf</code>)</td>
					<td><code>NVreg_PreserveVideoMemoryAllocations=1</code>, <code>NVreg_EnableS0ixPowerManagement=1</code></td>
			</tr>
			<tr>
					<td>Optional dGPU power-down</td>
					<td><code>NVreg_DynamicPowerManagement=0x02</code> (D3cold), added separately and suspend-tested</td>
			</tr>
	</tbody>
</table>
<h2 id="quick-verification-checklist">Quick verification checklist</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># After the full setup, all of these should hold:</span>
</span></span><span class="line"><span class="cl">lsmod <span class="p">|</span> grep nouveau        <span class="c1"># (empty)  -&gt; nouveau blacklisted</span>
</span></span><span class="line"><span class="cl">nvidia-smi                  <span class="c1"># lists the T600 + driver version</span>
</span></span><span class="line"><span class="cl">glxinfo <span class="p">|</span> grep renderer     <span class="c1"># Intel    -&gt; desktop on iGPU</span>
</span></span><span class="line"><span class="cl"><span class="nv">__NV_PRIME_RENDER_OFFLOAD</span><span class="o">=</span><span class="m">1</span> <span class="nv">__GLX_VENDOR_LIBRARY_NAME</span><span class="o">=</span>nvidia glxinfo <span class="p">|</span> grep renderer   <span class="c1"># T600</span>
</span></span><span class="line"><span class="cl">systemctl <span class="nb">suspend</span>           <span class="c1"># resumes cleanly</span>
</span></span></code></pre></div><hr>
<p><em>This guide was written by Claude, an AI assistant made by Anthropic, based on
a hands-on troubleshooting session working through this exact setup step by
step.</em></p>
]]></content:encoded></item><item><title>How This Site Is Built and Deployed</title><link>https://andylittle.net/blog/2026/how-this-site-is-built-and-deployed/</link><pubDate>Wed, 22 Jul 2026 11:00:00 -0500</pubDate><guid>https://andylittle.net/blog/2026/how-this-site-is-built-and-deployed/</guid><description>The stack behind andylittle.net: Hugo, PaperMod, and a GitHub Actions pipeline that deploys straight to my existing web host.</description><content:encoded><![CDATA[<p>I wanted a simple, fast personal site for writing about projects and whatever
else I&rsquo;m tinkering with, without dragging in a database, a CMS, or a hosting
bill I didn&rsquo;t already have. Here&rsquo;s what it&rsquo;s running on.</p>
<h2 id="hugo--papermod">Hugo + PaperMod</h2>
<p>The site is built with <a href="https://gohugo.io/">Hugo</a>, a static site generator
written in Go. Content is plain Markdown files with a bit of front matter;
Hugo turns that into a folder of HTML at build time. No server-side rendering,
no runtime dependencies, just files.</p>
<p>For the look and feel I&rsquo;m using <a href="https://github.com/adityatelange/hugo-PaperMod">PaperMod</a>,
a clean, fast community theme, pulled in as a git submodule rather than
forked. That keeps me on the upstream release train and out of the business
of maintaining CSS.</p>
<p>The site has three sections: <strong>Blog</strong> (this), <strong>About</strong>, and <strong>Projects</strong>.
Blog posts get dated permalinks (<code>/blog/2026/my-post/</code>), which keeps URLs
stable even if I ever reorganize or retitle things.</p>
<h2 id="deploying-without-a-new-host">Deploying without a new host</h2>
<p>I already have web hosting, so standing up something new just to serve static
files felt unnecessary. Instead, deployment is a GitHub Actions workflow that:</p>
<ol>
<li>Checks out the repo (with the theme submodule)</li>
<li>Installs the pinned Hugo extended release</li>
<li>Runs <code>hugo --gc --minify --cleanDestinationDir</code> to build the site</li>
<li>Copies the resulting <code>public/</code> directory to my host over <code>scp</code>, authenticated
with an SSH key</li>
</ol>
<p>Every push to <code>main</code> rebuilds and redeploys the site — no manual upload step,
no FTP client. The SSH host, username, private key, and target directory all
live as GitHub Actions repository secrets, never in the repo itself.</p>
<p>One thing that tripped me up: modern <code>scp</code> defaults to speaking the SFTP
protocol under the hood, and on my host that SFTP subsystem resolves relative
paths from a different root than an interactive SSH shell does. Uploads kept
failing with &ldquo;no such file or directory&rdquo; against a path that very much
existed. Forcing the legacy <code>scp</code> protocol (<code>-O</code>) fixed it — a good reminder
that &ldquo;it worked over SSH&rdquo; and &ldquo;it&rsquo;ll work over scp&rdquo; aren&rsquo;t always the same
claim.</p>
<h2 id="whats-next">What&rsquo;s next</h2>
<p>Mostly just writing more. The repo is set up so a new post is one command
away:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">hugo new blog/my-post-title.md
</span></span></code></pre></div><p>&hellip;and one <code>git push</code> away from being live.</p>
]]></content:encoded></item><item><title>Hello, World</title><link>https://andylittle.net/blog/2026/hello-world/</link><pubDate>Wed, 22 Jul 2026 10:00:00 -0500</pubDate><guid>https://andylittle.net/blog/2026/hello-world/</guid><description>The first post on the new andylittle.net.</description><content:encoded><![CDATA[<p>Welcome to the new andylittle.net, built with <a href="https://gohugo.io/">Hugo</a> and the
<a href="https://github.com/adityatelange/hugo-PaperMod">PaperMod</a> theme.</p>
<p>More to come.</p>
]]></content:encoded></item><item><title>About</title><link>https://andylittle.net/about/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://andylittle.net/about/</guid><description>About Andy Little</description><content:encoded><![CDATA[<p>Hi, I&rsquo;m Andy Little.</p>
<p>This is where your bio goes — who you are, what you do, and what this site is about.</p>
<!-- TODO: write your actual bio -->
]]></content:encoded></item><item><title>Projects</title><link>https://andylittle.net/projects/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://andylittle.net/projects/</guid><description>Things I&amp;rsquo;ve built and worked on</description><content:encoded><![CDATA[<p>A list of projects I&rsquo;ve built or contributed to.</p>
<h2 id="example-project">Example Project</h2>
<p>A short description of the project, what it does, and a link to it.</p>
<!-- TODO: replace with your real projects -->
]]></content:encoded></item></channel></rss>