There’s a particular sound you never want to hear from a machine with two Tesla V100s bolted into it. A sharp zap, then half a second later the smell of magic smoke. I heard both, lunged for the PC’s main power switch and killed everything.

Then I just stood there for a bit, not wanting to look. The carrier board those V100s sit on cost around $500 and isn’t the kind of thing you pick up at the local Jaycar; if I’d cooked it, the whole datacentre-under-the-desk project was dead in the water. That proper stomach-drop moment where you know you’ve done something dumb and you’re about to find out exactly how dumb.

This is the story of what let the smoke out, and how a soldering iron, a $3 microcontroller, MicroPython and Claude Code didn’t just get the board repaired, they left me with a cooling system that’s genuinely better than the one that came from the factory.

How I got here

I’d been tuning the cooling on my dual-V100 board, the same box I wrote up in A Datacentre Under the Desk. The board has five fan headers and I wanted to understand how they behaved: did they all do the same temperature-based speed control, or were some fixed, some managed?

The dual-V100 carrier board sitting in the case, blue foam sides and the fans that started all this

That’s the board, both V100s on their carrier down the middle with the fans bolted to it. Pretty happy little setup, right up until it wasn’t.

So with the machine running I started hot-plugging a couple of the fans onto different headers to compare how fast each one drove them, then running a light GPU load to warm the cards up a bit and get the fans moving so I could gauge the difference.

Yeah. That was the mistake. Somewhere in the middle of moving fans between live headers under load: zap, smoke, hand on the power switch. In my defence… no, there’s no defence really. Hot-plugging fans on a powered board is exactly as silly as it sounds, I just didn’t expect the fan controller to be the fragile bit.

The casualty

Once the adrenaline faded I went hunting for the damage. Careful inspection under good light turned up the culprit, a small 20-pin TSSOP down among the fan-header circuitry:

The burned fan-controller chip, an STC8G1K08 in TSSOP-20

A quick continuity check settled what it was. Multimeter on beep-test mode confirmed the PWM pins on the various fan headers were connected directly to a couple of separate pins on this chip. Between that and what I’d been doing when it let go, it was pretty safe to say: this was the fan controller, and it was dead.

The markings read STC / 8G1K08 / 381-TSSOP20. A quick search landed on the STC8G1K08, which was good news and bad news. Good, because now I knew exactly what it was. Bad, because it turns out it’s not a fixed-function fan controller at all, it’s an 8051-core microcontroller. That dead chip had been running custom firmware. So I couldn’t just buy another one and drop it in, a blank replacement would sit there doing nothing.

Still, the chip was the only casualty and both V100s were fine, so as far as expensive mistakes go I’d got off pretty lightly.

A hunch about the sensors

Knowing it was the fan brain gave me a lead though. It almost certainly also read the board’s temperature sensors, the little ones mounted on flex PCBs directly under each V100 SXM2 module, right against the hot side. If I could figure out how those sensors talked to the dead MCU, I could talk to them myself.

I peeled one up for a closer look:

A temperature-sensor flex board lifted from under the V100 SXM2 socket

Even under a microscope I couldn’t read any useful markings on the sensor itself. But it’s a 6-pin IC, and (after a chat with Gemini to sanity-check my instinct) a 6-pin part is far more likely to be a digital I²C sensor than an analog thermistor. That was the whole ballgame: if the STC had I²C sensors wired to it, then I had a standard bus I could re-host on another microcontroller. An analog thermistor tied to the dead chip’s ADC would’ve left me nothing to reconnect to.

Removing the corpse

First the dead MCU had to come off. For a TSSOP like this my preferred technique is crude but reliable: put a big blob of solder on both sides of the chip, right down all the pins, then hold the iron on one side to melt the whole row at once and lever the chip up a little with a screwdriver. Repeat on the other side, then back and forth a couple of times until it lifts free.

Blob-and-lever removal in progress

A bit of flux to clean up the pads, an isopropyl wipe, and the footprint was clean and bare:

The vacated footprint, pads intact

Pick your fighter: RP2040-Zero

I didn’t want to wait for an exact replacement to ship, and I really didn’t want to crawl back to the dark ages of writing C for an 8051. So I dug through my parts drawer for the smallest MCU dev board I had on hand: an RP2040-Zero.

The RP2040 is basically the perfect chip for this job, cheap, trivial to power, and with first-class MicroPython support. From the STC8G1K08 datasheet I pulled the pinout:

STC8G1K08 TSSOP-20 pinout

That confirmed the power pins (pin 8 VCC, pin 10 GND), which a careful multimeter check on the powered-up board showed were 5 V. So those got wired to the 5 V input and ground on the RP2040 board, tapping VCC off a nearby capacitor which was heaps easier to solder to than the tiny TSSOP pad.

The pinout also told me something reassuring before I’d probed anything: the dead chip had only two I²C-capable pin pairs (pins 3 & 4, and 13 & 14). That backed up the 6-pin-sensor hunch, the STC really did have a hardware I²C block.

Wiring it in

Here’s where I made a small error I’ll own up to: I’d already re-seated the V100 and torqued it back down properly before beep-testing the sensor pins back to the TSSOP footprint. Whoops. So I couldn’t confirm which pads the sensors actually landed on.

No matter, let software sort it out. There were only two I²C-capable pairs on the footprint, so I just wired both pairs to the Pico and planned to scan each one. Then the pins that ran to the fan PWM lines (which I’d traced to nearby resistors) got two more wires. Everything landed on the RP2040-Zero, with the sensor/I²C lines needing fine ribbon cable soldered onto the TSSOP pads for now:

The RP2040-Zero wired in against the V100 cooler

Then it was BOOTSEL mode, drop the MicroPython UF2 onto the drive, and open Claude Code in a fresh fans/ project folder.

~15 minutes with Claude

This is the part that still surprises me. The wiring was the slow, careful bit. Once the electronics were in, the entire firmware bring-up (finding the bus, identifying the sensors, writing an async control loop and a PWM fan driver, tuning a temperature curve, and deploying it to auto-run on boot) was about fifteen minutes of active back-and-forth with Claude. And I did most of it while wandering around the house doing other things in the evening; the wall-clock time was closer to fifty minutes, but the bulk of that was Claude sitting idle waiting for me to come back to the keyboard.

What follows is that session, close to verbatim (tool calls condensed to one-liners, output trimmed to the payoff). Timestamps are relative to the first prompt.


[T+0:00] — me: we have a rp2 pico micropython device attached, with mpremote working to interact. I’m hoping it’s got a working i2c bus attached on either pins 29 and 28, or 27 and 26 - but I don’t know which pair or which orientation of scl/sda within each pair. inspect and search, maybe check for pull-ups on each pair first, then try scanning for devices in each orientation

[T+0:06] — Claude: I’ll start by confirming the device connection and inspecting what’s available. $ mpremote devs/dev/ttyACM0 … MicroPython Board in FS mode

[T+0:57] — Claude: Found it — this is a permissions issue, not a busy port. /dev/ttyACM0 is root:dialout, but your user anl isn’t in the dialout group (mpremote devs works because it only enumerates, doesn’t open the port). You have sudo, so I’ll use the full path with sudo.

[T+1:07] — Claude: Now the pull-up check. My approach: enable each pin’s internal pull-down and read it. RP2040 internal pulls are ~50–80 kΩ; a real I2C bus has external pull-ups of 2.2–10 kΩ that will overpower the internal pull-down and read 1. A floating pin with only the internal pull-down reads 0.

[T+1:27]

GP26 | pd=1 pu=1 → EXT PULL-UP
GP27 | pd=1 pu=1 → EXT PULL-UP
GP28 | pd=0 pu=0 → no ext pull-up
GP29 | pd=0 pu=0 → no ext pull-up

[T+1:42] (scans both orientations of both pairs)

SCL=GP27 SDA=GP26 -> ['0x48', '0x49']
SCL=GP26 SDA=GP27 -> []
SCL=GP29 SDA=GP28 -> []
SCL=GP28 SDA=GP29 -> []

[T+2:05] — Claude:

Result: working I2C bus found on GP26/GP27

Orientation: SDA = GP26, SCL = GP27 · Devices: 0x48 and 0x49 (stable at 100 kHz and 400 kHz)… 0x48/0x49 are shared by a few common parts — most likely a temp sensor (LM75/TMP102/PCT2075 family)… I can read a register or two to identify them if you want.

[T+3:02] — me: record the findings in a hardware.md - just the success pins, ignore the other ones. record the identified devices. can we use hardware i2c to communicate with these?

[T+3:29] — Claude: These are LM75-family temperature sensors. The giveaways: reg 0x02 = 0x4B00 (75.0 °C) and reg 0x03 = 0x5000 (80.0 °C) are the textbook LM75 power-on defaults for T_HYST and T_OS, and reg 0x00 decodes to ~23 °C (room temperature)… Read live temperatures → 0x48 = 23.5 °C, 0x49 = 23.0 °C. So hardware I2C communication with both sensors works fully.

The sensors were exactly what I’d bet on. From there it was a short hop to a working controller, I’ll skip to the highlights:

[T+6:39] — me: yep fix my user permissions. then start to write a main.py that’s an asyncio loop reading each sensor every 0.5 seconds. also start a manifest.py with require aiorepl … and wire it into main as well during startup.

[T+8:10] — Claude: main.py verified — sensor loop runs and aiorepl imports cleanly. Now the 4-pin fan PWM driver on GP14/GP15… PWM control frequency 25 kHz, duty 0–100%. On the RP2040, GP14 and GP15 are both on PWM slice 7 (channels A/B), so they share a frequency but take independent duty. (Verified on hardware: both pins land at exactly 25 kHz.)

[T+12:07] — me: yes add to hardware doc. don’t deploy just yet. we want the fans to be controlled based on temp, say a low duty cycle at room temp, medium at 50 degrees, max at 70 degrees - I’m not sure if there’s standard duty cycles for these kind of fans?

[T+12:50] — Claude: Is there a standard? Partly. Standardized: only the PWM frequency — 25 kHz. Not standardized: the actual duty→RPM curve. Every manufacturer maps it differently… Minimum ~20–30 %: below that most fans stop responding, and many won’t start from standstill at all. (Proposes a piecewise curve — 25 % floor at room temp, 60 % at 50 °C, 100 % at 70 °C — with a spin-up kickstart.)

[T+14:08] — me: both track the hotter for now. eventually I might add a third fan then we can get more complex

[T+15:38] — Claude: All verified on hardware (via mount — nothing written to the device’s flash)… main.py reads both LM75 sensors, takes the hotter, applies the curve to both fans. If both reads fail → fail-safe to 100 % cooling.

A while later, after I’d wandered back to the keyboard, I told it to deploy for real. That surfaced one last gremlin, which Claude tracked down itself:

[T+~41] — me: yes deploy it and run to confirm it works

[T+~44] — Claude: The blocker is a pre-existing boot.py — on reset it crashes with ImportError: no module named 'network', which aborts the boot sequence before main.py ever runs. That boot.py isn’t mine — it was already on the board, and this firmware build doesn’t include the network module.

Oh yeah, that boot.py was on me. I’d been using this same Pico a while back to test my USB network PR for MicroPython, and left the old boot.py behind from that (it fires up network.USB_NET(), which this plain firmware build has no idea about). Mystery solved, so I just told Claude to delete it.

[T+~50] — Claude: Deployed and confirmed working from a cold hard reset (equivalent to power-up):

fan control @ 500ms + aiorepl
--> 0x48=24.000C 0x49=23.375C | hot=24.000C duty=25.0%
    0x48=24.000C 0x49=23.250C | hot=24.000C duty=25.0%
    ... (every 500 ms)

main.py now starts automatically on boot, reads both LM75 sensors, drives both fans off the hotter one, and holds the 25 % floor at room temp.


And that was the MVP: a dead factory fan controller, replaced by a $3 dev board running a temperature-driven control loop I’d built conversationally in about a quarter of an hour of actual effort. This is why I love working with MicroPython so much, it’s so rapid when you don’t need to compile and flash code for every little change, and Claude can drive mpremote comfortably for live exploration, introspection and debugging.

Better than the original

The really satisfying part is what came after the repair. Because the fan brain is now a general-purpose microcontroller I control end-to-end, with a host PC on the other end of the USB link, the board can do things the original STC firmware never could:

  • Tunable PWM profiles to match the actual fans. The duty→RPM curve isn’t standardised, so instead of living with whatever the factory baked in, the fan curve is now just data I can dial in for the specific fans in the case; floor, breakpoints and spin-up kickstart all adjustable.

  • Ramp profiles calibrated against real core temperature. The LM75s are thermal-padded to the underside of the SXM2 modules, so they track the module case rather than the die itself, and that case-to-junction gap grows with load. So I log the Pico’s sensor readings against nvidia-smi’s actual GPU temperatures, fit the offset, and shape the ramp to the temperature that actually matters (the silicon) rather than the case reading the original controller was stuck with.

  • Host-side GPU profiles that move power and cooling together. A management service on the PC bundles a GPU power limit, an SM-clock policy and a fan curve into named profiles (full, medium, quiet), switchable live from a CLI without interrupting a running CUDA or vLLM workload. Ask for quiet and it drops the power cap, clocks and fan noise in one move; ask for full throughput and it opens everything back up.

  • Layered fail-safes the original never had. The Pico is now a fail-safe of its own: if the host goes silent for 5 seconds it reverts to its own autonomous LM75 fan curve, so the cards are never left unmanaged. Behind that sit an independent host watchdog and the GPU’s own hardware thermal slowdown.

The result of all that tuning: a sustained 4-hour full-power run now holds both cards in the mid-60s to low-70s °C on HBM with zero throttle events, comfortably better than where the factory setup ever left them!

A chip died in a puff of smoke, and what grew back in its place is smarter than what was there before. Honestly, I’m almost glad I zapped it. Almost. Don’t hot-plug fans on live headers though.