<?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>Linux on Andy Little</title><link>https://andylittle.net/tags/linux/</link><description>Recent content in Linux 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/tags/linux/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>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></channel></rss>