atomic14 A collection of slightly mad projects, instructive/educational videos, and generally interesting stuff. Building projects around the Arduino and ESP32 platforms - we'll be exploring AI, Computer Vision, Audio, 3D Printing - it may get a bit eclectic... https://www.atomic14.com/ Sat, 18 Apr 2026 13:52:34 +0000 Sat, 18 Apr 2026 13:52:34 +0000 Jekyll v3.10.0 Hacking a Cheap PIR Night Light Into Something Actually Useful <p>I’ve been picking up a whole bunch of these motion sensitive night lights and, let’s just say, they haven’t been great. The range is poor, the battery life is nowhere near the advertised “charge it four times a year”, and the timeout is so short it’s not even enough time to get to the toilet.</p> <p>I thought it might be interesting to crack one open and see if I could improve it.</p> <p><img src="/assets/article_images/2026-04-11/pile.webp" alt="A pile of cheap motion sensor night lights" /></p> <p>I’ve got both flavours — <a href="https://s.click.aliexpress.com/e/_c2JvLwPT">round ones</a> and <a href="https://s.click.aliexpress.com/e/_c4KK6t4t">oval ones</a> — and internally they’re basically identical, so whatever I do to one should work for both. (Those are affiliate links — if you pick a pair up through them, I get a few pennies at no extra cost to you.)</p> <h1 id="the-plan">The Plan</h1> <p>I’ve got a drawer full of these nice little RC-WL0516 microwave radar sensors. They’re much more sensitive than a PIR, they see through plastic, and you can tweak the sensitivity and timeout with a couple of passives. The idea is to rip the original motion sensing front end out of one of these night lights and drop the radar module in instead.</p> <p><img src="/assets/article_images/2026-04-11/radar.webp" alt="RC-WL0516 microwave radar modules" /></p> <h1 id="cracking-one-open">Cracking One Open</h1> <p>The top just pops off with a bit of a squeeze. Inside, there’s a PIR sensor, an eight pin mystery chip with all the identifying marks sanded off, a light dependent resistor, and some charging circuitry.</p> <p><img src="/assets/article_images/2026-04-11/opened.webp" alt="Inside the night light, with U2 mystery chip close-up" /></p> <p>Popping the single screw out releases the board. Underneath is a tiny 350 mAh lithium cell — and no sign of a protection board on it, which is always reassuring.</p> <p><img src="/assets/article_images/2026-04-11/battery.webp" alt="The 350 mAh lithium cell" /></p> <p>With the board out, the layout is pretty clear. PIR sensor dead centre, ring of LEDs around it, mystery chip off to one side next to the LDR, and the charging circuitry tucked down by the micro USB port.</p> <p><img src="/assets/article_images/2026-04-11/board_hero.webp" alt="The stripped PCB showing PIR sensor, U2 chip, LDR and LEDs" /></p> <h1 id="doing-a-big-clive">Doing a Big Clive</h1> <p>Time to do my best <a href="https://www.youtube.com/@bigclivedotcom">Big Clive</a> impression and reverse engineer the circuit. It’s a single layer board, so it’s all quite tractable — the positive side of every LED is tied straight to battery plus, and the negative side goes through the mystery chip down to battery minus.</p> <p><img src="/assets/article_images/2026-04-11/trace.webp" alt="Board with tracks traced out over the top" /></p> <p>The PIR plus and PIR minus pins go straight into the mystery chip. The LDR is part of a potential divider with R5 feeding another pin on the chip, with a decoupling cap on the rail. Over on the charging side there’s a TP4054 with a 3.3k programming resistor — that’s about 300 mA of charge current — and an LED to show the state. That’s it. Highly optimised BOM, one big mystery IC doing all the work.</p> <p><img src="/assets/article_images/2026-04-11/schematic.webp" alt="Reverse engineered schematic" /></p> <p>What’s interesting is there’s no current limiting on the LEDs. Nothing at all. The chip is clearly driving them directly, and presumably there’s some limit built in to the chip itself.</p> <h1 id="how-much-current">How Much Current?</h1> <p>Before doing anything else, I wanted to know how hard this thing is actually driving the LEDs. Desolder the battery, hook the pads up to a bench supply with the current limit set to 100 mA, and trigger the PIR.</p> <p><img src="/assets/article_images/2026-04-11/bench.webp" alt="Board running on the bench supply with the LEDs lit" /></p> <p>68 mA. So there is some built-in limiting on the mystery chip after all. This mystery IC is doing a lot of heavy lifting.</p> <h1 id="playing-with-the-radar-module">Playing with the Radar Module</h1> <p>These RC-WL0516 modules are lovely. They have pads for a light dependent resistor so they only trigger in the dark, and a space to solder on a capacitor to extend the timeout.</p> <p>I wired one up on a breadboard, hung an LED off the output through a current limit resistor, and fed it 5 V.</p> <p><img src="/assets/article_images/2026-04-11/breadboard_led.webp" alt="Radar module on a breadboard driving a red LED" /></p> <p>Stand still, the LED goes off. Move, it comes back on. The default timeout is about three seconds. As I mentioned, three seconds is nowhere near enough time to get to the toilet, so I soldered a 0.22 µF capacitor to the CTM pad to extend it.</p> <p><img src="/assets/article_images/2026-04-11/capacitor.webp" alt="Soldering a 0.22 µF capacitor onto the CTM pad" /></p> <p>Much better. I also wired up an LDR so it only triggers in the dark, and confirmed the motion detection works really nicely — much more sensitive than the original PIR ever was.</p> <h1 id="the-modification">The Modification</h1> <p>Here’s the plan. Strip the PIR sensor, the mystery chip, and its supporting passives off the original board. Sit the radar module on top where there’s a nice flat empty space. Drive the LEDs through a current limiting resistor and a small MOSFET controlled by the radar module’s output pin. Power everything from the existing battery pads.</p> <p><img src="/assets/article_images/2026-04-11/plan.webp" alt="The radar module test fitted on the stripped PCB" /></p> <p>Microscope out, and with a bit of soldering the whole thing came together more neatly than I expected. I took the opportunity to upgrade the battery too — swapping the tiny 350 mAh cell for a much more generous 820 mAh one.</p> <p><img src="/assets/article_images/2026-04-11/modded.webp" alt="The modified board with the 820 mAh battery, LEDs lit" /></p> <p>The board slides back into its case, the battery tucks in alongside, the USB port still lines up with the hole in the shell, the screw goes back in, the diffuser clips in, and… good as new. You’d never know anything had happened.</p> <p><img src="/assets/article_images/2026-04-11/final_diffuser.webp" alt="Finished, diffuser on, LEDs glowing" /></p> <h1 id="does-it-actually-work">Does It Actually Work?</h1> <p>Stuck it on the stairs, turned the lights off, and waited. Walk past — it lights up. Stand still — it goes out after a sensible amount of time. Sensitivity is miles better than it was, the battery should last a lot longer, and the timeout is long enough to be useful.</p> <p><img src="/assets/article_images/2026-04-11/hero_stairs.webp" alt="The modified night light installed on the stairs, glowing" /></p> <p>It works. Now I just need to do the rest of them…</p> <p>If you want to see the whole thing in motion — the teardown, the reverse engineering, the soldering, and the moment it finally lights up on the stairs — <a href="https://www.youtube.com/watch?v=eVac2GMyddk">go and watch it on YouTube</a>. Likes and subscribes are always appreciated.</p> <lite-youtube videoid="eVac2GMyddk" playlabel="Hacking a Cheap PIR Night Light"></lite-youtube> Sat, 11 Apr 2026 00:00:00 +0000 https://www.atomic14.com/2026/04/11/pir-night-light-upgrade.html https://www.atomic14.com/2026/04/11/pir-night-light-upgrade.html I learned to solve a Rubik's cube and it was incredibly disappointing <p>I recently decided to learn how to solve the Rubik’s cube. As a self-confessed geek, it felt like something I should be able to do.</p> <p>I found a pretty good <a href="https://easiestsolve.com/step-1-the-daisy/">website</a> that breaks it down into eight steps, from “the Daisy” all the way through to “Finish Him!”. I memorised the moves. Pretty soon I could solve it without looking at the website.</p> <p>It was incredibly disappointing.</p> <p><img src="/assets/article_images/2026-04-05/rubiks.webp" alt="Rubiks be gone!" /></p> <p>I was just mechanically following “algorithms” I’d memorised. No skill, no insight. Pattern match, apply algorithm, repeat.</p> <p>So I thought, this can’t be how speed cubers do it. Steps 1-3 are painfully tedious. There must be a faster way.</p> <p>This led me to F2L, solving the first two layers simultaneously. The first resource I found suggested memorising 42 algorithms - my response - yeah, I’m not doign that…</p> <p>Then I found a few suggestions on solving F2L “intuitively”, which sounded more my style. Maybe this approach would actually involve some thinking.</p> <p>It’s definitely faster and requires a bit more brainpower, but ultimately? Still pattern matching. I wanted to understand <em>why</em> the algorithms work, not just memorise better ones.</p> <p>Frustrated, I did what everyone does now. I asked ChatGPT.</p> <p>After the obligatory “That’s a brilliant insight!” it gave me this analogy:</p> <ul> <li>Beginner: following GPS directions</li> <li>Intermediate: memorised routes</li> <li>Advanced: understand the map</li> <li>Expert: improvise new routes instantly</li> </ul> <p>I keep coming back to this because it maps so well onto the junior-to-senior engineer journey.</p> <p>You start out following instructions. At some point you start pattern matching. This problem looks like that problem I solved last year. Eventually the memorised playbook isn’t enough and you start synthesising new solutions from first principles. You develop taste. Judgment. The ability to look at a system you’ve never seen and know where the problems are going to be.</p> <p>That last part is what I find interesting about coding agents right now. They’re very good at levels one and two. Following instructions, applying known patterns at scale, faster and more consistently (well, most of the time…) than any person could.</p> <p>And sometimes watching an agent churn out code gives me that same Rubik’s cube feeling. It works. It passes the tests. But I’m just sitting here approving it. What am I even doing here?</p> <p>But then you hit a problem where the agent can’t see what you see. It solves the ticket but puts the logic in the wrong place. It writes code that works today but will be a nightmare in six months. And you remember what you’re doing here. That’s the difference between mechanically producing a solution and deeply understanding the solution.</p> <p>For now, experienced engineers are the ones providing the taste and judgment that makes the difference between code that works and code that works well.</p> <p>I’m never going to be a speed cuber. But maybe, with the help of agents, I might end up being a speed coder…</p> Sun, 05 Apr 2026 00:00:00 +0000 https://www.atomic14.com/2026/04/05/rubiks-cube.html https://www.atomic14.com/2026/04/05/rubiks-cube.html Why Won't My Board Reset? <p>I’ve got a slightly annoying problem with one of my dev boards. The reset button does absolutely nothing.</p> <p>These are the boards I got made by PCBWay — they did the PCBs and they came out really nice. I did the assembly myself. But somewhere along the way, something went wrong on one of them.</p> <lite-youtube videoid="VdJXJQXXZoo" playlabel="Why won't my board reset?"></lite-youtube> <h1 id="a-tale-of-two-boards">A Tale of Two Boards</h1> <p>I loaded both boards with a fast blink sketch to make the problem obvious. On the working board, hitting the reset button stops the flashing immediately — exactly what you’d expect. On the broken board? Nothing. The LED just keeps happily blinking away.</p> <p>This is more than just a minor annoyance. To get an ESP32 into programming mode, you hold down the boot button and then press reset. Without a working reset button, I’m constantly having to unplug and re-plug the USB cable. Not ideal.</p> <h1 id="checking-the-schematic-and-pcb">Checking the Schematic and PCB</h1> <p>First things first — let’s make sure the design is actually correct.</p> <p>The schematic looks fine: the reset switch is connected to the EN (enable) pin through the standard RC circuit — a resistor and a capacitor. Nothing unusual there.</p> <p>The PCB layout checks out too. Tracing the path from the reset switch down to ground, up through the RC network, and all the way to the EN pin — everything is routed correctly.</p> <p>So the design isn’t the problem. Time to get the multimeter out.</p> <h1 id="buzzing-out-the-circuit">Buzzing Out the Circuit</h1> <p>I started probing the connections on the board. The EN pin to the resistor — good. EN pin to the capacitor — good. The trace from the RC network to the switch — also good.</p> <p>Then I put the probes across the switch itself and pressed the button.</p> <p>Nothing. No continuity at all.</p> <p>The boot button on the same board? Works perfectly. But the reset button is completely dead.</p> <h1 id="under-the-microscope">Under the Microscope</h1> <p>Time for a closer look. Under the microscope, the soldering on the ESP32’s EN pin looks fine — which the continuity test already confirmed. The RC components look good too.</p> <p>The button itself? It looks solidly soldered. Visually, I couldn’t see any obvious problems. Comparing it with the boot button, the boot button’s joints were a bit shinier, but nothing that would explain a total failure.</p> <p>It’s funny how boards look under the microscope — from a distance, this board looks perfectly clean, but zoom in and there’s all sorts of hairs and disgusting stuff.</p> <p><img src="/assets/article_images/2026-04-04/yuck.webp" alt="Yuck" /></p> <h1 id="reflow-and-test">Reflow and Test</h1> <p>With no obvious visual fault, I decided to reflow the solder joints on the reset button. Applied some flux, heated up each joint, and could feel solid connections on both sides of the switch.</p> <p>Buzzed it out again. Still nothing. The button clicks mechanically, but there’s no electrical continuity when pressed.</p> <p>At this point, it’s clear: <strong>the button itself is the problem.</strong></p> <h1 id="the-fix-just-add-alcohol">The Fix: Just Add Alcohol</h1> <p>Looking more closely, the inside of the button seemed to be full of flux residue. On a whim, I soaked the board in IPA (isopropyl alcohol) to try and clean it out.</p> <p>After a good soak, I probed the button again — and it worked perfectly. Every press gave clean, reliable continuity.</p> <p>Plugging the board back in, the blink sketch was running. Hit the reset button and… the flashing stopped. Success!</p> <h1 id="lesson-learned">Lesson Learned</h1> <p>The whole problem was flux residue inside the tactile switch, preventing the internal contacts from making a clean connection. The solder joints were fine, the traces were fine, the schematic was fine — it was just a dirty button.</p> <p>A remarkably simple fix in the end. A bit of cleaning was all it needed.</p> <p>The lesson? Use more alcohol — that’s the conclusion I’m taking from this…</p> Sat, 04 Apr 2026 00:00:00 +0000 https://www.atomic14.com/2026/04/04/why-no-reset.html https://www.atomic14.com/2026/04/04/why-no-reset.html Is SOT666 Really Standard? <h1 id="is-sot666-really-standard-spoiler-not-really">Is SOT666 Really Standard? Spoiler: Not Really</h1> <p>I’ve been investigating some quality assurance failures on my ESP32 rainbow boards and tracked a few issues down to a USBLC6 ESD protection IC in a SOT666 package. Under the microscope, it looked like the footprint didn’t quite match the package — like it had missed alignment and hadn’t soldered on properly.</p> <lite-youtube videoid="z3s550opeZ0" playlabel="Is SOT666 Really Standard?"></lite-youtube> <p>I fixed the immediate problem by reflowing the solder and pushing the device into place. But this should be automatic — the pick and place machine puts the component down and solder tension should pull it into alignment. So I started wondering: is the footprint actually correct?</p> <h1 id="the-problem-footprint-vs-datasheet">The Problem: Footprint vs Datasheet</h1> <p>Opening up the schematic in KiCad, the USBLC6 is assigned a SOT666 footprint. That’s the correct package — so far so good.</p> <p><img src="/assets/article_images/2026-03-22/sot-666-kicad.webp" alt="KiCad schematic showing the USBLC6 with SOT666 footprint" /></p> <p>But when I compared the KiCad footprint against the recommended footprint in the USBLC6 datasheet, things looked pretty different.</p> <p><img src="/assets/article_images/2026-03-22/footprint-comparison.webp" alt="KiCad footprint vs datasheet recommended footprint" /></p> <p>The KiCad footprint for SOT666 is completely different from the footprint in the datasheet — even though they’re both supposed to be SOT666.</p> <h1 id="so-is-kicad-wrong">So Is KiCad Wrong?</h1> <p>Maybe. But it’s not that simple.</p> <p>If you Google “SOT666” you’ll find all sorts of different footprints from different manufacturers. Here’s how they stack up:</p> <ul> <li><strong>Nexperia’s SOT666 definition</strong> — looks very similar to KiCad’s version. In fact, I think KiCad’s footprint is based on this one.</li> <li><strong>ST’s USBLC6 datasheet</strong> — has its own recommended footprint with quite different dimensions.</li> <li><strong>Diodes Incorporated’s SOT666</strong> — yet another completely different suggested pad layout.</li> </ul> <p><img src="/assets/article_images/2026-03-22/different-footprints.webp" alt="Different SOT666 footprints from different manufacturers" /></p> <p>So you’ve got a “standard” package name — SOT666 — but every manufacturer seems to have their own opinion about what the footprint should look like.</p> <h1 id="checking-easyeda-for-comparison">Checking EasyEDA for Comparison</h1> <p>I thought it might be instructive to check another EDA package. In EasyEDA, the SOT666 footprint for the USBLC6-2P6 is different again:</p> <ul> <li><strong>Pad width (PW1):</strong> 0.3 mm — this actually matches the ST datasheet</li> <li><strong>Pad length (PL1):</strong> 0.85 mm — the ST datasheet says 0.99 mm, so still different</li> </ul> <p><img src="/assets/article_images/2026-03-22/easy-eda-footprint.webp" alt="What does EasyEDA think?" /></p> <p>Three different tools, three different footprints, all for the same “standard” package. Not exactly confidence-inspiring.</p> <h1 id="the-lesson-always-follow-the-datasheet">The Lesson: Always Follow the Datasheet</h1> <p>The basic conclusion is that although these packages share the same name, the footprints are not really standardised. Everything is randomly different.</p> <p><strong>The advice: look in the datasheet and use what’s in the datasheet.</strong> Don’t rely on the standard package names because they might not quite match up with your specific component.</p> <h1 id="creating-a-custom-footprint-in-kicad">Creating a Custom Footprint in KiCad</h1> <p>Fortunately, it’s really easy to create footprints in KiCad. Using the footprint wizard with the SOIC template, I just needed to plug in the values from the ST datasheet:</p> <ul> <li><strong>Pad count:</strong> 6 (two rows of 3)</li> <li><strong>Pitch:</strong> 0.5 mm</li> <li><strong>Pad width:</strong> 0.3 mm</li> <li><strong>Pad length:</strong> 0.99 mm</li> <li><strong>Row spacing:</strong> 1.61 mm (0.62 mm gap + 0.99 mm pad length)</li> </ul> <p><img src="/assets/article_images/2026-03-22/kicad-wizard.webp" alt="KiCad footprint wizard with datasheet values" /></p> <p>A quick sanity check with the ruler confirmed everything matched the datasheet — the 0.62 mm gap between rows and 2.6 mm overall dimensions were spot on.</p> <h1 id="updating-the-pcb">Updating the PCB</h1> <p>It took a bit of finagling — I had to make some traces slightly thinner so they didn’t collide — but the new footprint dropped into the design nicely.</p> <p><img src="/assets/article_images/2026-03-22/updated-pcb.webp" alt="Updated PCB with the new footprint" /></p> <p>The 3D preview looks good. Obviously the proof of the pudding will be the next production run, but it matches the datasheet now — so fingers crossed it will eliminate this class of QA errors.</p> <h1 id="wrapping-up">Wrapping Up</h1> <p>If you’re designing PCBs, don’t assume that a standard package name means a standard footprint. Always cross-reference with your specific component’s datasheet. It only takes a few minutes to create a custom footprint in KiCad, and it could save you from chasing down mysterious assembly failures.</p> <lite-youtube videoid="z3s550opeZ0" playlabel="Is SOT666 Really Standard?"></lite-youtube> Sun, 15 Mar 2026 00:00:00 +0000 https://www.atomic14.com/2026/03/15/is-sot666-standard.html https://www.atomic14.com/2026/03/15/is-sot666-standard.html MAX30102 $3 Heart Rate Monitor <h1 id="building-a-heart-rate-monitor-with-an-esp32-c3-and-max30102">Building a Heart Rate Monitor with an ESP32-C3 and MAX30102</h1> <p>You know how it is. You buy one cute little module from AliExpress and then think “it looks lonely, I’ll get another”. Before you know it, you’ve got a drawer full of cute little modules. So it’s time to put one of them to work.</p> <lite-youtube videoid="0OV5aCOnXBA" playlabel="Cheap Heart Rate Monitor"></lite-youtube> <h1 id="the-esp32-c3-board">The ESP32-C3 Board</h1> <p>I’ve got a bunch of these tiny ESP32-C3 boards kicking around. Let’s take a look at one under the microscope.</p> <p>The brains of the operation is an ESP32-C3 with built-in flash — four megabytes of it, which is pretty decent.</p> <p><img src="/assets/article_images/2026-03-15/back.webp" alt="ESP32-C3 board under the microscope" /></p> <p>There’s a 3.3 volt regulator on board, and the soldering looks nicely done. It’s a pretty clean, well-designed little board. The antenna is a ceramic one on the side — though I’ve read a few people saying that on these tiny compact boards you don’t get much Wi-Fi signal due to the antenna placement being a bit too close to the buttons and other components. So we’ll avoid Wi-Fi for this project.</p> <p><img src="/assets/article_images/2026-03-15/front.webp" alt="ESP32-C3 board under the microscope" /></p> <h1 id="the-max30102-heart-rate-and-blood-oxygen-sensor">The MAX30102 Heart Rate and Blood Oxygen Sensor</h1> <p>For the actual sensor, I’m using a MAX30102 module — a heart rate and blood oxygen monitoring board that I picked up in a recent AliExpress order.</p> <p><img src="/assets/article_images/2026-03-15/max30102.webp" alt="MAX30102 sensor module" /></p> <p>The board has a 3.3 volt boost regulator and a 1.8 volt regulator. Some people have reported finding a 2.8 volt regulator instead of 1.8 on some boards — it’s worth checking yours is correct. Mine was fine.</p> <h1 id="how-the-sensor-works">How the Sensor Works</h1> <p>The really interesting part is the sensor itself. Under the microscope you can see:</p> <p><img src="/assets/article_images/2026-03-15/max30102-closeup.webp" alt="Close-up of the MAX30102 sensor" /></p> <ul> <li><strong>Two LEDs</strong> behind a window — a red LED and an infrared LED</li> <li><strong>A photodetector</strong> with a bunch of DSP processing built in</li> </ul> <p>The outputs from this device are just the value of the red channel and the value of the infrared channel. You stick your finger on top and it detects pulse rate using the red LED and blood oxygen levels using the infrared LED.</p> <p>When you power it up, you can easily see the red LED. If you cover it up and move the lights away, you can even see a slight glow from the infrared LED — the camera on the microscope picks it up nicely.</p> <p><img src="/assets/article_images/2026-03-15/leds.webp" alt="MAX30102 LEDs" /></p> <p><img src="/assets/article_images/2026-03-15/infra-red.webp" alt="MAX30102 Infra Red LED" /></p> <h1 id="wiring-it-up">Wiring It Up</h1> <p>The wiring is pretty simple — it’s just I2C plus power:</p> <div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">const</span> <span class="n">byte</span> <span class="n">oxiInt</span> <span class="o">=</span> <span class="mi">2</span><span class="p">;</span> <span class="c1">// pin connected to MAX30102 INT</span> <span class="k">const</span> <span class="kt">int</span> <span class="n">SENSOR_I2C_SDA_PIN</span> <span class="o">=</span> <span class="mi">5</span><span class="p">;</span> <span class="k">const</span> <span class="kt">int</span> <span class="n">SENSOR_I2C_SCL_PIN</span> <span class="o">=</span> <span class="mi">6</span><span class="p">;</span> </code></pre></div></div> <ul> <li><strong>Pin 6</strong> → SCL</li> <li><strong>Pin 5</strong> → SDA</li> <li><strong>Pin 2</strong> → Interrupt (tells us when data is ready)</li> <li><strong>5V</strong> → Vin (the board has its own 3.3V regulator)</li> <li><strong>GND</strong> → GND</li> </ul> <p><img src="/assets/article_images/2026-03-15/wiring.webp" alt="Wiring diagram" /></p> <p>A quick I2C scan confirms everything is connected properly — two devices show up: the I2C display and the heart rate monitor.</p> <h1 id="the-code">The Code</h1> <p>The main loop reads samples from the MAX30102 into a ring buffer, then runs the heart rate and SpO2 algorithms once the buffer is full:</p> <div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="nf">loop</span><span class="p">()</span> <span class="p">{</span> <span class="kt">float</span> <span class="n">n_spo2</span><span class="p">,</span> <span class="n">ratio</span><span class="p">,</span> <span class="n">correl</span><span class="p">;</span> <span class="kt">int8_t</span> <span class="n">ch_spo2_valid</span><span class="p">;</span> <span class="kt">int32_t</span> <span class="n">n_heart_rate</span><span class="p">;</span> <span class="kt">int8_t</span> <span class="n">ch_hr_valid</span><span class="p">;</span> <span class="c1">// Read UPDATE_STEP new samples into the ring buffer</span> <span class="k">for</span><span class="p">(</span><span class="kt">int32_t</span> <span class="n">n</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">n</span> <span class="o">&lt;</span> <span class="n">UPDATE_STEP</span><span class="p">;</span> <span class="o">++</span><span class="n">n</span><span class="p">)</span> <span class="p">{</span> <span class="n">readNextSampleIntoRing</span><span class="p">(</span><span class="n">data_ready_timeout_us</span><span class="p">);</span> <span class="p">}</span> <span class="c1">// Need a full buffer before we can calculate</span> <span class="k">if</span><span class="p">(</span><span class="n">sample_count</span> <span class="o">&lt;</span> <span class="n">BUFFER_SIZE</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span> <span class="n">buildOrderedWindow</span><span class="p">();</span> <span class="c1">// Calculate heart rate and SpO2</span> <span class="n">rf_heart_rate_and_oxygen_saturation</span><span class="p">(</span> <span class="n">ordered_ir_buffer</span><span class="p">,</span> <span class="n">BUFFER_SIZE</span><span class="p">,</span> <span class="n">ordered_red_buffer</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">n_spo2</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">ch_spo2_valid</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">n_heart_rate</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">ch_hr_valid</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">ratio</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">correl</span><span class="p">);</span> <span class="c1">// Median filter smooths out noisy readings</span> <span class="n">hr_filter</span><span class="p">.</span><span class="n">push</span><span class="p">(</span><span class="n">ch_hr_valid</span> <span class="o">?</span> <span class="n">n_heart_rate</span> <span class="o">:</span> <span class="n">INVALID_HR</span><span class="p">);</span> <span class="n">spo2_filter</span><span class="p">.</span><span class="n">push</span><span class="p">(</span><span class="n">ch_spo2_valid</span> <span class="o">?</span> <span class="n">n_spo2</span> <span class="o">:</span> <span class="n">INVALID_SPO2</span><span class="p">);</span> <span class="n">updateDisplay</span><span class="p">(</span><span class="n">filtered_heart_rate</span><span class="p">,</span> <span class="p">(</span><span class="kt">int32_t</span><span class="p">)</span><span class="n">filtered_spo2</span><span class="p">);</span> <span class="p">}</span> </code></pre></div></div> <p>The display code is pretty straightforward — just show the heart rate and oxygen percentage on the tiny OLED, or dashes if we don’t have a valid reading:</p> <div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">static</span> <span class="kt">void</span> <span class="nf">updateDisplay</span><span class="p">(</span><span class="kt">int32_t</span> <span class="n">hr</span><span class="p">,</span> <span class="kt">int32_t</span> <span class="n">spo2</span><span class="p">)</span> <span class="p">{</span> <span class="n">u8g2</span><span class="p">.</span><span class="n">clearBuffer</span><span class="p">();</span> <span class="n">u8g2</span><span class="p">.</span><span class="n">setFont</span><span class="p">(</span><span class="n">u8g2_font_10x20_mr</span><span class="p">);</span> <span class="n">u8g2</span><span class="p">.</span><span class="n">setCursor</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">19</span><span class="p">);</span> <span class="k">if</span> <span class="p">(</span><span class="n">hr</span> <span class="o">&gt;=</span> <span class="mi">30</span> <span class="o">&amp;&amp;</span> <span class="n">hr</span> <span class="o">&lt;=</span> <span class="mi">250</span><span class="p">)</span> <span class="p">{</span> <span class="n">u8g2</span><span class="p">.</span><span class="n">printf</span><span class="p">(</span><span class="s">"HR: %d"</span><span class="p">,</span> <span class="p">(</span><span class="kt">int</span><span class="p">)</span><span class="n">hr</span><span class="p">);</span> <span class="p">}</span> <span class="k">else</span> <span class="p">{</span> <span class="n">u8g2</span><span class="p">.</span><span class="n">print</span><span class="p">(</span><span class="s">"HR: --"</span><span class="p">);</span> <span class="p">}</span> <span class="n">u8g2</span><span class="p">.</span><span class="n">setCursor</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">39</span><span class="p">);</span> <span class="k">if</span> <span class="p">(</span><span class="n">spo2</span> <span class="o">&gt;=</span> <span class="mi">0</span> <span class="o">&amp;&amp;</span> <span class="n">spo2</span> <span class="o">&lt;=</span> <span class="mi">100</span><span class="p">)</span> <span class="p">{</span> <span class="n">u8g2</span><span class="p">.</span><span class="n">printf</span><span class="p">(</span><span class="s">"O2: %d%%"</span><span class="p">,</span> <span class="p">(</span><span class="kt">int</span><span class="p">)</span><span class="n">spo2</span><span class="p">);</span> <span class="p">}</span> <span class="k">else</span> <span class="p">{</span> <span class="n">u8g2</span><span class="p">.</span><span class="n">print</span><span class="p">(</span><span class="s">"O2: --"</span><span class="p">);</span> <span class="p">}</span> <span class="n">u8g2</span><span class="p">.</span><span class="n">sendBuffer</span><span class="p">();</span> <span class="p">}</span> </code></pre></div></div> <p>There’s also a nice touch — the on-board LED flashes in time with your heartbeat.</p> <h1 id="getting-readings">Getting Readings</h1> <p>With the sketch uploaded and the OLED display showing results, it’s time to test.</p> <p><img src="/assets/article_images/2026-03-15/heart-rate.webp" alt="Heart rate readings on the display" /></p> <p>It can be a bit finicky about finger placement — you need to keep it very still. But once it settles, it starts producing results: heart rate and SpO2 percentage.</p> <h1 id="comparing-to-a-commercial-device">Comparing to a Commercial Device</h1> <p>The real test — how does it compare to a proper pulse oximeter?</p> <p>With both running side by side:</p> <ul> <li>Heart rate: <strong>around 70 BPM</strong> on the ESP32-C3, matching the commercial monitor</li> <li>Blood oxygen: <strong>99%</strong> on both devices</li> </ul> <p><img src="/assets/article_images/2026-03-15/compare.webp" alt="Side-by-side comparison with commercial pulse oximeter" /></p> <p>Not bad at all. The main challenge is keeping your finger positioned accurately without squishing it on — it’s quite easy to lose the reading. But when you get a good contact, the results match up well.</p> <h1 id="wrapping-up">Wrapping Up</h1> <p>The full code is on <a href="https://github.com/atomic14/max30102-esp32c3-oled-oximeter">GitHub</a>. Grab yourself one of these ESP32-C3 modules, a MAX30102 sensor, and a little OLED display, and you can build something equivalent to a commercial pulse oximeter for very little money. It’s a fun project — why not try it out?</p> <lite-youtube videoid="0OV5aCOnXBA" playlabel="Cheap Heart Rate Monitor"></lite-youtube> Sun, 15 Mar 2026 00:00:00 +0000 https://www.atomic14.com/2026/03/15/cheap-heart-rate-monitor.html https://www.atomic14.com/2026/03/15/cheap-heart-rate-monitor.html I Made My ESP32-S3 Faster by Optimizing for SIZE — Not Speed <h1 id="optimizing-esp32-s3-performance-why-smaller-code-can-ran-faster">Optimizing ESP32-S3 Performance: Why Smaller Code Can Ran Faster</h1> <p>I’ve been tuning the performance of some code recently on an ESP32-S3 project,</p> <p>There was a slightly counter-intuitive result that might surprise some people: optimizing for code size produced faster runtime performance than optimizing for performance.</p> <p>There’s a full video here - but you can skim this post for most of the information.</p> <lite-youtube videoid="cqHH2NXcf5E" playlabel="Size is better than speed"></lite-youtube> <h1 id="the-benchmark">The Benchmark</h1> <p>This is just some code that I pulled out of my latest project. It’s not an official piece of benchmarking code. So your mileage will definitely vary and you shohld do your own tests on your own code.</p> <p>I kept my test case was deliberately simple and pulled out the most performance critical part of my code.</p> <p>The code, input data, and hardware stayed constant throughout. Only ESP-IDF configuration options were changed between runs.</p> <h1 id="baseline-results">Baseline Results</h1> <p>Starting from a stock ESP-IDF configuration (I used their <code class="language-plaintext highlighter-rouge">hello_world</code> starter app), the decode took roughly <strong>1.4 seconds</strong>.</p> <p>From there, I explored a few of the usual performance levers:</p> <ul> <li>CPU frequency (an obvious one!)</li> <li>Compiler optimization level</li> <li>Flash SPI mode</li> <li>Cache configuration</li> </ul> <h1 id="cpu-frequency-the-obvious-win">CPU Frequency: The Obvious Win</h1> <p>Unsurprisingly, increasing the CPU frequency from 160 MHz to 240 MHz produced an immediate improvement.</p> <p>The result was roughly the 1.5× speed-up thst you woulld hope for (it was actually around 1.46 - but close enough).</p> <h1 id="compiler-optimizations-size-vs-speed">Compiler Optimizations: Size vs Speed</h1> <p>ESP-IDF menu config offers two commonly used optimization modes:</p> <ul> <li><strong>Optimize for size (<code class="language-plaintext highlighter-rouge">-Os</code>)</strong></li> <li><strong>Optimize for performance (<code class="language-plaintext highlighter-rouge">-O2</code>)</strong></li> </ul> <p>Weirdly, in my first job (many years ago!), this was discussed in quite detail.</p> <p>You naively expect that <code class="language-plaintext highlighter-rouge">-O2</code> should be faster than <code class="language-plaintext highlighter-rouge">-Os</code>. In this case, it wasn’t.</p> <p>With this workload, <code class="language-plaintext highlighter-rouge">-Os</code> outperformed <code class="language-plaintext highlighter-rouge">-O2</code> quite significantly. They both beat the debug build - but <code class="language-plaintext highlighter-rouge">-Os</code> improved it the most.</p> <h1 id="why-would-smaller-code-be-faster">Why Would Smaller Code Be Faster?</h1> <p>On the ESP32-S3, application code normally runs from flash memory with an instruction cache (you can actually copy code into PSRAM if it’s available which can be faster than flash).</p> <p>Flash is pretty slow which means the CPU caches can have a significant impact.</p> <ul> <li>instruction cache size</li> <li>cache line fetches</li> <li>cache miss penalties</li> </ul> <p>Optimizing for performance often:</p> <ul> <li>increases inlining</li> <li>unrolls loops</li> <li>duplicates code paths</li> </ul> <p>All of this increases code size, which can lead to more instruction cache misses and more reads from the flash.</p> <p>Optimizing for size does the opposite: it produces tighter code that is more likely to stay resident in the cache, reducing stalls caused by flash fetches.</p> <p>In this workload, that effect outweighed the benefits of more aggressive instruction-level optimizations.</p> <h1 id="flash-mode-and-cache-configuration">Flash Mode and Cache Configuration</h1> <p>Because the GIF was embedded in flash and accessed frequently, flash bandwidth also mattered.</p> <p>Two changes helped further:</p> <ul> <li>Switching the flash SPI mode from DIO to QIO (chack that your module supports this!)</li> <li>Increasing instruction and data cache sizes to their maximum values</li> </ul> <p>These changes reduced the cost of cache refills and improved overall throughput.</p> <p>Interestingly, once the caches were enlarged, <code class="language-plaintext highlighter-rouge">-O2</code> did improve — but it still didn’t beat the <code class="language-plaintext highlighter-rouge">-Os</code> configuration with the same cache settings.</p> <h1 id="the-winning-configuration">The Winning Configuration</h1> <p>For this benchmark, the fastest setup was:</p> <ul> <li>CPU frequency: <strong>240 MHz</strong></li> <li>Compiler optimization: <strong>Optimize for size (<code class="language-plaintext highlighter-rouge">-Os</code>)</strong></li> <li>Instruction &amp; data cache: <strong>maximum</strong> (turn it up to 11!)</li> <li>Flash SPI mode: <strong>QIO</strong></li> </ul> <p>This combination produced the lowest overall decode time.</p> <h1 id="this-is-the-case-for-my-specific-workload">This is the case for my specific workload!</h1> <p>It’s important to be clear about the limits of this result.</p> <p>You may not see the same behaviour if:</p> <ul> <li>Your hot code runs entirely from the instruction cache or IRAM</li> <li>Your workload is dominated by tight math loops on small datasets</li> <li>You’re memory-bandwidth bound on internal RAM rather than flash</li> </ul> <p>In those cases, <code class="language-plaintext highlighter-rouge">-O2</code> or even more aggressive optimization may still win.</p> <h1 id="full-results-table">Full Results Table</h1> <table> <thead> <tr> <th>Build type</th> <th style="text-align: right">usecs</th> <th style="text-align: right">ms</th> <th style="text-align: right">% of baseline</th> </tr> </thead> <tbody> <tr> <td>Baseline</td> <td style="text-align: right">1425476</td> <td style="text-align: right">1425.48</td> <td style="text-align: right">100</td> </tr> <tr> <td>240MHz</td> <td style="text-align: right">965408</td> <td style="text-align: right">965.4</td> <td style="text-align: right">67.72</td> </tr> <tr> <td>240MHz + Os</td> <td style="text-align: right">872495</td> <td style="text-align: right">872.5</td> <td style="text-align: right">61.21</td> </tr> <tr> <td>240MHz + O2</td> <td style="text-align: right">962329</td> <td style="text-align: right">962.3</td> <td style="text-align: right">67.50</td> </tr> <tr> <td>240MHz + Os + QIO</td> <td style="text-align: right">861859</td> <td style="text-align: right">861.9</td> <td style="text-align: right">60.46</td> </tr> <tr> <td>240MHz + Os + QIO + caches</td> <td style="text-align: right">843286</td> <td style="text-align: right">843.3</td> <td style="text-align: right">59.16</td> </tr> <tr> <td>240MHz + O2 + QIO + caches</td> <td style="text-align: right">933093</td> <td style="text-align: right">933.1</td> <td style="text-align: right">65.46</td> </tr> </tbody> </table> <p><img src="/assets/article_images/2026-02-02/chart.webp" alt="Chart" /></p> <h1 id="conclusions">Conclusions</h1> <p>Performance is often limited by memory and caching, not raw CPU execution.</p> <p>Smaller code can mean fewer cache misses - and fewer cache misses can mean faster code.</p> <p>If you’re working on performance-critical ESP-IDF projects, it’s worth benchmarking <code class="language-plaintext highlighter-rouge">-Os</code> alongside <code class="language-plaintext highlighter-rouge">-O2</code>.</p> <p>If you’ve tried similar experiments on other ESP32 variants, I’d love to hear what you found.</p> <lite-youtube videoid="cqHH2NXcf5E" playlabel="Size is better than speed"></lite-youtube> Fri, 06 Mar 2026 00:00:00 +0000 https://www.atomic14.com/2026/03/06/size-better-than-speed.html https://www.atomic14.com/2026/03/06/size-better-than-speed.html Watching LLMs Think <p>Many years ago, when I was younger and thought a PhD sounded like a sensible life choice, I started one.</p> <p>Early on, a lecturer gave me a piece of advice that stuck:</p> <p>“Don’t watch the program run.”</p> <p>He meant the long jobs. Training a neural net. Running simulations. Anything where the terminal starts spitting out numbers and you just… sit there. Watching loss values tick down as if your attention somehow helps.</p> <p><img src="/assets/article_images/2026-02-19/training.webp" alt="Very Hypnotic" /></p> <p>It feels like work.</p> <p>It’s not.</p> <p>You can burn an entire afternoon that way and have nothing to show for it except slightly better metrics.</p> <p>His point was simple: start the job, then get out of the way. Make coffee. Write something. Read a paper. Talk to an actual human. Do work that requires a brain.</p> <p>Lately I’ve caught myself doing the same thing with coding agents.</p> <p>You kick one off and suddenly you’re staring at “Thinking…” like it’s a progress bar for your own productivity. Then the text starts streaming past and you read every line, as if supervising it in real time makes it better.</p> <p>It’s the exact same trap.</p> <p>The machine is busy. You don’t need to be.</p> <p>When you see “Thinking…”, that’s probably your cue to step away and do something only you can do. And if I’m honest, that usually isn’t more coding.</p> Thu, 19 Feb 2026 00:00:00 +0000 https://www.atomic14.com/2026/02/19/watching-llms-think.html https://www.atomic14.com/2026/02/19/watching-llms-think.html ESP32-C3 0.42 OLED <p>I’ve ended up with a bunch of these nice little <a href="https://s.click.aliexpress.com/e/_c3Es83Rt">ESP32-C3 modules</a>.</p> <p>I’m a bit late to the party with them, so other people have done a lot of heavy lifting working out how to drive the display. I followed this <a href="https://emalliab.wordpress.com/2025/02/12/esp32-c3-0-42-oled/">blog</a>.</p> <p>The only thing I didn’t quite like was the slight bodge in his drawing code where he used the <code class="language-plaintext highlighter-rouge">U8G2_SSD1306_128X64_NONAME_F_HW_I2C</code> class and then offset his code to work for 70x40 size display.</p> <p>There is now an appropriate constructor that seems to work without needing any funny offsets.</p> <p><img src="/assets/article_images/2026-02-16/display.webp" alt="Display Working" /></p> <div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#include</span> <span class="cpf">&lt;U8g2lib.h&gt;</span><span class="cp"> #include</span> <span class="cpf">&lt;Wire.h&gt;</span><span class="cp"> </span> <span class="cp">#include</span> <span class="cpf">&lt;U8g2lib.h&gt;</span><span class="cp"> #include</span> <span class="cpf">&lt;Wire.h&gt;</span><span class="cp"> </span> <span class="n">U8G2_SSD1306_72X40_ER_F_HW_I2C</span> <span class="nf">u8g2</span><span class="p">(</span><span class="n">U8G2_R0</span><span class="p">,</span> <span class="n">U8X8_PIN_NONE</span><span class="p">,</span> <span class="mi">6</span><span class="p">,</span> <span class="mi">5</span><span class="p">);</span> <span class="kt">int</span> <span class="n">width</span> <span class="o">=</span> <span class="mi">72</span><span class="p">;</span> <span class="kt">int</span> <span class="n">height</span> <span class="o">=</span> <span class="mi">40</span><span class="p">;</span> <span class="kt">void</span> <span class="nf">setup</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span> <span class="p">{</span> <span class="n">delay</span><span class="p">(</span><span class="mi">1000</span><span class="p">);</span> <span class="n">u8g2</span><span class="p">.</span><span class="n">begin</span><span class="p">();</span> <span class="n">u8g2</span><span class="p">.</span><span class="n">setContrast</span><span class="p">(</span><span class="mi">255</span><span class="p">);</span> <span class="c1">// set contrast to maximum </span> <span class="n">u8g2</span><span class="p">.</span><span class="n">setBusClock</span><span class="p">(</span><span class="mi">400000</span><span class="p">);</span> <span class="c1">//400kHz I2C </span> <span class="n">u8g2</span><span class="p">.</span><span class="n">setFont</span><span class="p">(</span><span class="n">u8g2_font_ncenB10_tr</span><span class="p">);</span> <span class="p">}</span> <span class="kt">void</span> <span class="n">loop</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span> <span class="p">{</span> <span class="n">u8g2</span><span class="p">.</span><span class="n">clearBuffer</span><span class="p">();</span> <span class="c1">// clear the internal memory</span> <span class="n">u8g2</span><span class="p">.</span><span class="n">drawFrame</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="n">width</span><span class="p">,</span> <span class="n">height</span><span class="p">);</span> <span class="c1">//draw a frame around the border</span> <span class="n">u8g2</span><span class="p">.</span><span class="n">setCursor</span><span class="p">(</span><span class="mi">15</span><span class="p">,</span> <span class="mi">25</span><span class="p">);</span> <span class="n">u8g2</span><span class="p">.</span><span class="n">printf</span><span class="p">(</span><span class="s">"%dx%d"</span><span class="p">,</span> <span class="n">width</span><span class="p">,</span> <span class="n">height</span><span class="p">);</span> <span class="n">u8g2</span><span class="p">.</span><span class="n">sendBuffer</span><span class="p">();</span> <span class="c1">// transfer internal memory to the display</span> <span class="p">}</span> </code></pre></div></div> Mon, 16 Feb 2026 00:00:00 +0000 https://www.atomic14.com/2026/02/16/esp32-c3-oled.html https://www.atomic14.com/2026/02/16/esp32-c3-oled.html Turning an ESP32 into a Thermal USB Webcam <p>In a previous <a href="/2026/02/01/pong-cam">project</a>, I made an ESP32 pretend to be a USB webcam.</p> <p>This time, I wanted to do it properly - turn it into a real USB webcam with a real camera.</p> <p>I also wanted to push things a bit further - I’ve got an <a href="/2026/01/25/i2c-thermal-camera-fix">I2C thermal camera module</a> - why not turn that into a USB camera?</p> <lite-youtube videoid="jyhVxC0ipE8" playlabel="ESP32-S3 WebCam"></lite-youtube> <p>The ESP32S3 has native USB support, which means it can present itself directly as a USB device - this opens up a whole world of possibilities.</p> <p>For USB video, I’m using <a href="https://components.espressif.com/components/espressif/usb_device_uvc/versions/1.2.0/readme">USB UVC (USB Video Class)</a>. I’m also sticking to MJPEG to make things simple.</p> <p>The nice thing about MJPEG is that it’s not a video codec in the traditional sense — it’s just a stream of individual JPEG images, sent one after another over USB.</p> <h1 id="simple-case-a-normal-camera-module">Simple case: a normal camera module</h1> <p>Many ESP32-compatible camera modules already output JPEG-compressed images.</p> <p>That means the “encoder” part of the webcam is basically free:</p> <p>Our code turns into this:</p> <ol> <li>Capture a JPEG frame from the camera</li> <li>Hand the JPEG bytes to the USB stack</li> <li>Repeat</li> </ol> <p>You really can’t get much simpler than that.</p> <p>You can use any S3 dev board - there are some nice ones that have built in cameras, but you could use a standard dev board and a camera module.</p> <p><img src="/assets/article_images/2026-02-09/camera-modules.webp" alt="Camera Modules" /></p> <p>What you can’t use is the old ESP-CAM boards - firstly these don’t have USB and secondly the standard ESP32 doesn’t have native USB support.</p> <h1 id="moving-to-thermal-a-3224-infrared-camera">Moving to thermal: a 32×24 infrared camera</h1> <p>The thermal camera is where things get interesting.</p> <p>I’m using an MLX90640. This module outputs a 32×24 pixel temperature image over I2C. That’s just 768 pixels total - really tiny and not very usable raw.</p> <p>But it is enough data to work with.</p> <p>Our new code flow is:</p> <ol> <li>Read the raw thermal image over I2C</li> <li>Convert temperatures to grayscale or false colour</li> <li>Scale the image up to 320×240</li> <li>JPEG encode it</li> <li>Stream it over USB using UVC</li> </ol> <p><img src="/assets/article_images/2026-02-09/ir-module.webp" alt="IR Module" /></p> <h1 id="i2c-debugging-and-a-reminder-to-myself">I2C debugging (and a reminder to myself)</h1> <p>Before any of it actually worked, I managed to lose a fair bit of time debugging what I thought was an I2C software problem.</p> <ul> <li>I scanned addresses.</li> <li>Checked pull-ups.</li> <li>Tried different voltages.</li> <li>Swapped code around.</li> </ul> <p>Eventually, I looked at the board under a microscope abd found that it was just a dodgy solder joint on the on-board 3.3 V regulator.</p> <p>You can see a detailed post on this <a href="/2026/01/25/i2c-thermal-camera-fix">here</a></p> <p><img src="/assets/article_images/2026-01-27/regulator.webp" alt="3V3 Regulator" /></p> <p>This particular thermal camera board includes:</p> <ul> <li>Built-in I2C pull-up resistors (2.2 kΩ to 3.3 V)</li> <li>An on-board 3.3 V regulator</li> </ul> <p>In theory, that means the board expects to be powered from 5 V, feeding the regulator.</p> <p>In practice, the regulator is a low-dropout device (around 120 mV dropout at 100 mA), so powering the board directly from 3.3 V worked just fine.</p> <p>Importantly, because the pull-ups are tied to the 3.3 V rail, there are no weird voltage levels on the I2C bus even when powered this way - which does make me wonder how well it would work with a real 5v logic device.</p> <h1 id="making-3224-pixels-usable">Making 32×24 pixels usable</h1> <p>Once the thermal data is coming in reliably, the next problem is obvious:</p> <p>32×24 pixels is tiny.</p> <p>The solution is to scale it up by a factor of 10× in each direction, producing a 320×240 image that fits nicely into a webcam frame.</p> <p>I implemented two scaling options:</p> <ul> <li>Nearest neighbour: very fast, but can look a bit blocky</li> <li>Bilinear scaling: smoother, still fast enough on the ESP32-S3</li> </ul> <p>Both give surprisingly good results, and once the image is scaled and colour-mapped, you’d never guess it started life at such a low resolution.</p> <p><img src="/assets/article_images/2026-02-09/scaling.webp" alt="Scaling methods compared" /></p> <h1 id="jpeg-encoding-and-usb-streaming">JPEG encoding and USB streaming</h1> <p>After scaling, the image is JPEG-encoded and sent over USB using the UVC stack.</p> <p>From the host computer’s point of view, this is just a normal webcam:</p> <ul> <li>Works in standard camera apps</li> <li>No custom drivers</li> <li>No special software</li> </ul> <p>Despite the low input resolution, the frame rate is perfectly usable, and the ESP32-S3 has more than enough performance to keep everything running smoothly.</p> <p>You can see it all in actiion here - it’s pretty cool (or hot!).</p> <lite-youtube videoid="jyhVxC0ipE8" playlabel="ESP32-S3 WebCam"></lite-youtube> Sun, 15 Feb 2026 00:00:00 +0000 https://www.atomic14.com/2026/02/15/diy-infrared-usb-camera.html https://www.atomic14.com/2026/02/15/diy-infrared-usb-camera.html Pong Cam - My ESP32S3 Thinks It's a WebCam! <h1 id="tldr">TL;DR</h1> <ul> <li>The ESP32-S3 (and other Espressif modules - at time of writing: H4, P4, S3, S3) can act as a <strong>USB webcam</strong> using the standard UVC protocol</li> <li>There is <strong>no camera</strong> connected — all frames are generated in software</li> <li>We start with a static JPEG, move to animated GIFs, and finish with <strong>Pong running in real time</strong></li> <li>Video is sent as <strong>MJPEG</strong> (a stream of JPEG images) over USB</li> <li>JPEG encoding on the ESP32-S3 is fast enough to make this practical</li> </ul> <p><em>In this project, we turn an ESP32-S3 into something your computer happily believes is a USB webcam — even though there’s no camera connected at all.</em></p> <lite-youtube videoid="zhTTmRQLNws" playlabel="Pong Cam"></lite-youtube> <p>For these set of projects, instead of streaming video from a sensor, the ESP32 generates frames in software, encodes them as JPEGs, and sends them over USB using the standard UVC (USB Video Class) protocol.</p> <p>We build this up in three stages:</p> <ol> <li>A static test card (boring but does show it working)</li> <li>Animated GIF playback using MJPEG (kind of fun)</li> <li>A real-time game of Pong streamed as live video (forget TFT and OLED displays - just use your computer!)</li> </ol> <p>You can find all the source code <a href="https://github.com/atomic14/esp32-usb-uvc-experiments">here</a>.</p> <h1 id="how-does-it-work">How does it work?</h1> <p>At a high level, a USB webcam is just a device that:</p> <ul> <li>Enumerates as a USB UVC device</li> <li>Negotiates resolution and frame rate with the host</li> <li>Sends a video stream to the host</li> </ul> <p>In our case:</p> <ul> <li>The <strong>ESP32-S3</strong> provides native USB support</li> <li>Espressif’s <a href="https://github.com/espressif/esp-iot-solution/tree/36d8130e8e880720108de2c31ce0779827b1bcd9/components/usb/usb_device_uvc"><strong>USB UVC device component</strong></a> handles enumeration and protocol details</li> <li>We provide the video data — whether that’s a static JPEG, decoded GIF frames, or a game framebuffer</li> </ul> <p>With Espressif’s <code class="language-plaintext highlighter-rouge">usb_device_uvc</code> component, we can do either <strong>MJPEG</strong> or <strong>H264</strong>. The underlying libary, TinyUSB, does have support for more formats.</p> <p>MJPEG (Motion JPEG) is exactly what it sounds like: a sequence of individual JPEG images sent one after another. There’s no inter-frame compression — every frame stands alone.</p> <p>The nice thing is, our computer doesn’t really care about the hardware - if it presents itself as a USB UVC device then our computer will see it as a web cam.</p> <p>I did have some weird issues getting <strong>Isochronous</strong> mode working on the UVC component - I got very random frame stalls and glitches. This is likely to be somethign that I am doing in the code.</p> <p>For all the demos I switched over to <strong>Bulk</strong> mode (this is set in <code class="language-plaintext highlighter-rouge">menuconfig</code>).</p> <h1 id="demo-1-static-test-card">Demo 1: Static Test Card</h1> <p>The simplest possible (albeit most boring) webcam is one that just shows a static image.</p> <p>For the first demo, we embed a single JPEG — a classic BBC test card — directly into the firmware. Whenever the USB host requests a frame, we return the same JPEG data.</p> <p>This proves:</p> <ul> <li>USB enumeration works</li> <li>The host accepts the device as a valid webcam</li> <li>MJPEG streaming is functional</li> </ul> <h2 id="implementation-notes">Implementation Notes</h2> <p>We’re using the Espressif IDF for this project. This uses CMake and it’s pretty easy to embed binary data into our firmware.</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>idf_component_register(SRCS "main.cpp" "uvc_streamer.cpp" PRIV_REQUIRES spi_flash INCLUDE_DIRS "" EMBED_FILES "test_card.jpeg") </code></pre></div></div> <p>And then you can reference it in code:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">extern</span> <span class="k">const</span> <span class="kt">unsigned</span> <span class="kt">char</span> <span class="n">jpeg_start</span><span class="p">[]</span> <span class="n">asm</span><span class="p">(</span><span class="s">"_binary_test_card_jpeg_start"</span><span class="p">);</span> <span class="k">extern</span> <span class="k">const</span> <span class="kt">unsigned</span> <span class="kt">char</span> <span class="n">jpeg_end</span><span class="p">[]</span> <span class="n">asm</span><span class="p">(</span><span class="s">"_binary_test_card_jpeg_end"</span><span class="p">);</span> <span class="k">const</span> <span class="kt">size_t</span> <span class="n">jpeg_data_len</span> <span class="o">=</span> <span class="n">jpeg_end</span> <span class="o">-</span> <span class="n">jpeg_start</span><span class="p">;</span> <span class="k">const</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="n">jpeg_data</span> <span class="o">=</span> <span class="n">jpeg_start</span><span class="p">;</span> </code></pre></div></div> <p>Setting up the uvc component is as simple as providing it with a set of callbacks:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">uvc_device_config_t</span> <span class="n">config</span> <span class="o">=</span> <span class="p">{</span> <span class="p">.</span><span class="n">uvc_buffer</span> <span class="o">=</span> <span class="n">uvc_buffer_</span><span class="p">,</span> <span class="c1">// pointer to a buffer - this should be big enough for one of your JPEG frames</span> <span class="p">.</span><span class="n">uvc_buffer_size</span> <span class="o">=</span> <span class="n">jpeg_data_len_</span><span class="p">,</span> <span class="c1">// the length of the buffer</span> <span class="p">.</span><span class="n">start_cb</span> <span class="o">=</span> <span class="n">camera_start_cb</span><span class="p">,</span> <span class="c1">// called when things start up</span> <span class="p">.</span><span class="n">fb_get_cb</span> <span class="o">=</span> <span class="n">camera_fb_get_cb</span><span class="p">,</span> <span class="c1">// request for a new frame of data</span> <span class="p">.</span><span class="n">fb_return_cb</span> <span class="o">=</span> <span class="n">camera_fb_return_cb</span><span class="p">,</span> <span class="c1">// the frame has been sent</span> <span class="p">.</span><span class="n">stop_cb</span> <span class="o">=</span> <span class="n">camera_stop_cb</span><span class="p">,</span> <span class="c1">// called when things stop</span> <span class="p">.</span><span class="n">cb_ctx</span> <span class="o">=</span> <span class="n">nullptr</span><span class="p">,</span> <span class="c1">// passed into all the functions</span> <span class="p">};</span> </code></pre></div></div> <p>The crucial callback to implements is <code class="language-plaintext highlighter-rouge">camera_fb_get_cb</code>. This returns a populated framebuffer that is sent over the wire to our PC.</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">uvc_fb_t</span> <span class="o">*</span><span class="n">UvcStreamer</span><span class="o">::</span><span class="n">camera_fb_get_cb</span><span class="p">(</span><span class="kt">void</span> <span class="o">*</span><span class="n">cb_ctx</span><span class="p">)</span> <span class="p">{</span> <span class="kt">uint64_t</span> <span class="n">now_us</span> <span class="o">=</span> <span class="n">esp_timer_get_time</span><span class="p">();</span> <span class="n">memset</span><span class="p">(</span><span class="o">&amp;</span><span class="n">fb_</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">fb_</span><span class="p">));</span> <span class="n">fb_</span><span class="p">.</span><span class="n">buf</span> <span class="o">=</span> <span class="n">const_cast</span><span class="o">&lt;</span><span class="kt">uint8_t</span> <span class="o">*&gt;</span><span class="p">(</span><span class="n">jpeg_data_</span><span class="p">);</span> <span class="n">fb_</span><span class="p">.</span><span class="n">len</span> <span class="o">=</span> <span class="n">jpeg_data_len_</span><span class="p">;</span> <span class="n">fb_</span><span class="p">.</span><span class="n">width</span> <span class="o">=</span> <span class="n">kFrameWidth</span><span class="p">;</span> <span class="n">fb_</span><span class="p">.</span><span class="n">height</span> <span class="o">=</span> <span class="n">kFrameHeight</span><span class="p">;</span> <span class="n">fb_</span><span class="p">.</span><span class="n">format</span> <span class="o">=</span> <span class="n">UVC_FORMAT_JPEG</span><span class="p">;</span> <span class="n">fb_</span><span class="p">.</span><span class="n">timestamp</span><span class="p">.</span><span class="n">tv_sec</span> <span class="o">=</span> <span class="n">now_us</span> <span class="o">/</span> <span class="mi">1000000ULL</span><span class="p">;</span> <span class="n">fb_</span><span class="p">.</span><span class="n">timestamp</span><span class="p">.</span><span class="n">tv_usec</span> <span class="o">=</span> <span class="n">now_us</span> <span class="o">%</span> <span class="mi">1000000ULL</span><span class="p">;</span> <span class="k">return</span> <span class="o">&amp;</span><span class="n">fb_</span><span class="p">;</span> <span class="p">}</span> </code></pre></div></div> <p>At this point, the webcam is extremely boring - it doesn’t actually move. But it does work!</p> <p><img src="/assets/article_images/2026-02-01/test-card.webp" alt="Webcam preview showing BBC test card" /></p> <h1 id="demo-2-animated-gifs">Demo 2: Animated GIFs</h1> <p>A static image is fine, but webcams are meant to move.</p> <p>For this demo:</p> <ul> <li>An animated GIF is embedded into the firmware</li> <li>Each GIF frame is decoded and then re-encoded as a JPEG at boot time</li> <li>The JPEG frames are sent over USB at the correct times</li> </ul> <p>To get the gif down to a sensible size to fit in the flash I used <a href="https://ezgif.com/">ezgif</a> to resize and optimize it to 320x240.</p> <h2 id="gif-decoding">GIF Decoding</h2> <p>We use Larry Bank’s excellent <a href="https://github.com/bitbank2/AnimatedGIF"><strong>AnimatedGIF</strong></a> library to extract frames and timing information .</p> <p>Even though this is a highly optimised library, decoding gifs is surprisingly intensive - with our 320x240 images it takes on average just under <strong>33ms</strong> per frame.</p> <h2 id="jpeg-encoding">JPEG Encoding</h2> <p>Each decoded frame is then encoded as a JPEG. We use the Espressif <a href="https://components.espressif.com/components/espressif/esp_new_jpeg/versions/1.0.0/readme">esp_new_jpeg</a> component for this. According to the docs this should be able to encode a 320x240 images at 40fps.</p> <p>In my tests we got around 23ms per frame - which is pretty impressive and would be 45fps!</p> <p><img src="/assets/article_images/2026-02-01/batman.webp" alt="Batman!" /></p> <h1 id="demo-3-pong-as-a-webcam">Demo 3: Pong as a Webcam</h1> <p>So, can we use our “webcam” for something more fun? How about as a real time display of a game?</p> <p>For something real time and interactive like a game we need to be getting close to at least 30 FPS.</p> <p>At 30 FPS we have a budget of 33ms per frame. Given our JPEG encoding takes around 23ms we only have 10ms per frame for everything else!</p> <h2 id="game-loop-constraints">Game Loop Constraints</h2> <p>Each frame must:</p> <ol> <li>Update game logic</li> <li>Render the framebuffer</li> <li>Encode the frame as JPEG</li> <li>Send it over USB</li> </ol> <p>Pseudo code for this is below:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// simplified main loop</span> <span class="k">while</span> <span class="p">(</span><span class="nb">true</span><span class="p">)</span> <span class="p">{</span> <span class="n">wait_for_host_frame_request</span><span class="p">();</span> <span class="n">update_game</span><span class="p">();</span> <span class="n">render_frame</span><span class="p">();</span> <span class="n">jpeg_encode</span><span class="p">();</span> <span class="n">send_jpeg</span><span class="p">();</span> <span class="p">}</span> </code></pre></div></div> <p>With careful tuning, this just about works — and the result is a fully playable game of Pong streamed through a standard webcam interface. The key detail is that the <strong>host paces the loop</strong>: the ESP32 renders/encodes a new frame when the UVC stack asks for one.</p> <p>In my initial tests I got just under 29FPS. After some recent optimisations (I got the JPEG encoding down to around 21ms) I hit a solid 30 fps! Not a bad result.</p> <p><img src="/assets/article_images/2026-02-01/pong.webp" alt="Pong Cam" /></p> <h1 id="whats-next">What’s Next</h1> <p>In a follow-up project, we’ll replace the generated frames with a real camera sensor - it should be pretty straightforward.</p> <lite-youtube videoid="zhTTmRQLNws" playlabel="Pong Cam"></lite-youtube> Sun, 01 Feb 2026 00:00:00 +0000 https://www.atomic14.com/2026/02/01/pong-cam.html https://www.atomic14.com/2026/02/01/pong-cam.html