The Kit I Soldered Badly
esp8266prometheusembeddedside-project

The Kit I Soldered Badly

I botched the soldering on my first air quality kit, bought another one, and ran a 406 line Arduino file on it for years. Fixing the first kit is what finally pushed me to rewrite the thing.

·10 min read

The Kit I Soldered Badly

I have two AirGradient air quality kits. I messed up the soldering on the first one, so for a long time only the second one actually worked. It ran a single Arduino file, 406 lines, and all I really asked of it was to get its numbers into Prometheus.

Since September both boards run the same firmware. It's around 5,100 lines now, spread over 62 files, with 87 tests, and none of the library I started with. I didn't plan any of that. It just sort of happened, one decision at a time.

The first kit

If you haven't seen them, AirGradient sells their sensors as kits you put together yourself. You get a Wemos D1 mini, a PCB, a bag of sensors, a small OLED screen, and you bring the soldering iron. Mine was the v1, I think.

I never flashed their firmware on it. From the start I wanted my own program, because everything else at home already goes through Prometheus and Grafana (my smart plugs too) and I wanted the air quality on the same dashboards. So I wrote one, on top of the AirGradient Arduino library.

Then a few of my solder joints turned out to be bad. I didn't have the patience to track them down, so I just ordered the newer kit. If I remember right it was a v3, and AirGradient was still working on it at the time. My program moved over to that one.

406 lines

It was one file, the AirGradient library doing the sensor work, and my code mostly gluing things together. The part I actually cared about was the exporter. It looked like this (I cut it after the second metric, you get the idea):

String generateMetrics() {
  String message = "";
  String idString = "{id=\"" + String(deviceId) + "\",mac=\"" + WiFi.macAddress().c_str() + "\"}";

  message += "# HELP tvoc TVOC value, in ppm\n";
  message += "# TYPE tvoc gauge\n";
  message += "tvoc";
  message += idString;
  message += String(TVOC);
  message += "\n";

  message += "# HELP co2 CO2 value, in ppm\n";
  // ...and so on for every reading

And honestly? It worked fine. Prometheus scraped it, Grafana drew nice lines, and over the years I bolted on a barometric sensor and reworked what the 128x64 screen showed. The oldest copy I still have is from October 2023.

Reading it again now is a bit funny though. It's very much a program written for one device by the one person who'd ever touch it. The WiFi name and password were constants in the code, so changing my password meant reflashing. If the network didn't come up, setupWifi() just sat there printing "Trying to connect..." on the screen forever. The time was timeClient.setTimeOffset(3600 * 2), which is UTC+2. Correct in summer. In winter I'd go and edit it. The metrics page was built as one big String on a chip that has maybe 40 KB of heap. A sensor that hadn't answered yet reported 0, which on a CO2 graph looks like the cleanest air you've ever breathed.

My favourite: look at the help line above. TVOC "in ppm". The SGP30 gives you ppb. That was wrong on every single scrape, for years, and I never noticed.

None of it mattered, really. One box, one room, one network, and I knew all its quirks by heart.

Ten months of not much

Much later I decided to fix the first kit. The plan was simple: resolder, flash the same file, done. Except the first kit isn't the same hardware. Smaller screen (64x48), no SGP30, no barometer. So I'd need a second copy of the file with bits removed, and then every fix would have to be done twice. Not great.

That's when I thought, ok, let's do this properly with PlatformIO and split it into actual modules. First commit was October 3, 2025. A month later I had WiFi credentials stored in EEPROM and a small config page. And then I didn't touch it for ten months.

I picked it back up on September 6, 2026 and did 22 commits that day. Tagged 1.0.0 two days later.

Getting rid of the library

I hadn't planned to drop the AirGradient library. But once I started reading its code to understand what my sensors were actually doing, I didn't really want to keep it.

The CO2 sensor, a SenseAir S8, talks Modbus RTU, and every reply ends with a CRC16. The library found the reply by looking for a start byte in the buffer and never checked the CRC. So a garbled frame would happily come out as a CO2 reading. The new driver checks the CRC first, and I test it against the example frames in SenseAir's own docs.

The particle sensor, a PMS5003, sends 32 byte frames with a checksum. I parse them byte by byte now, it recovers if it gets out of sync, and it averages all the good frames over a sampling window instead of taking one. The fan and the laser are what wear out on that sensor, so it runs in cycles: 30 seconds to warm up (the datasheet asks for that), 10 seconds of measuring, then 5 minutes off. That works out to about 1,030 hours of laser per year instead of 8,760.

The SGP30 was the embarrassing one. Its baseline algorithm wants a reading every second. My old loop read it every 2.5 seconds. And at boot, the old code called SGP.GenericReset(), which is an I2C "general call" reset. It doesn't only reset the SGP30, it resets anything on the bus that listens to it, the screen included. Oops.

WiFiManager and NTPClient went away too. The ESP8266 core has SNTP built in, and if you give it a POSIX timezone string (CET-1CEST,M3.5.0,M10.5.0/3 for me) it handles summer time by itself. No more editing an offset twice a year.

One firmware for both boards

Making the same build run on both boards forced me to deal with every assumption the old file made.

The firmware doesn't know which sensors it has anymore. At boot it pokes each I2C address, and whatever doesn't answer just disappears: no metrics for it, no card in the web UI. That's how the small board runs the exact same image without knowing it's missing two sensors. The screen is the one thing I can't detect, so that's a setting.

No more fake zeros either. If a sensor has nothing recent, the metric is simply not there. That creates another problem though, because a missing metric could mean the sensor died, or the whole box dropped off the WiFi. So there are two extra series per sensor, air_quality_sensor_up and air_quality_sensor_detected, that are always present. You can alert on their value instead of fighting with absent(), and you can tell "never plugged in" from "stopped answering".

All the old constants are now in a JSON file on the flash. It's written to a temp file and then renamed over the real one, so pulling the plug in the middle of a save can't leave you with half a config. With no WiFi set up, the board starts its own access point with a captive portal, you connect with your phone, pick your network from a scan and you're done. Updates go over the network, either straight from PlatformIO or by uploading a file in the browser.

The web UI of the sensor, showing CO2, PM2.5, TVOC, pressure, battery, temperature and humidity cards with a two hour trend line under each

The status page. It's served by the board itself with nothing loaded from outside, so it works even from the setup access point.

The stuff you only find on real hardware

Everything that doesn't touch the hardware has tests that run on my laptop in about five seconds: the frame parser, the CRC, the AQI table, the pressure maths, the battery curve. The rest, I had to find on the actual boards.

Sometimes after a reset nothing on the I2C bus would answer at all. Turns out if a sensor gets interrupted halfway through sending a byte, it keeps holding the data line low, waiting for clock pulses that never come, and the whole bus is stuck until it lets go. The fix is a bit old school: before Wire.begin(), wiggle the clock line by hand, up to nine pulses, then send a STOP.

Another one: after a warm reset the board would sometimes go into setup mode even though my WiFi was fine. Connecting plus getting an address from DHCP was regularly taking more than 20 seconds, and 20 seconds was my timeout. Bumped it to 30.

Then Grafana started showing a reboot every seven weeks or so, when nothing had rebooted. millis() wraps around to zero after 49.7 days, and my uptime was just millis() / 1000. Uptime now counts the wraps.

I also added a Wemos battery shield to the bigger board, which was its own adventure. The battery voltage is read on A0 through a resistor on the shield, and mine has a 100 kOhm one where the usual shield has 130 kOhm. That changes the full scale from 4.5 V to 4.2 V, so my first battery gauge was just wrong until I figured that out. Battery life is around two hours, and I can't do much about it: the SGP30 heats a little plate all the time, 48 mA on its own, and you can't put it to sleep without losing its calibration. The scary part is the peaks. When the CO2 sensor takes a measurement while the radio is sending, the battery delivers about 1.17 A through the boost converter. The cell (610 mAh) is rated for 2 C, so 1.22 A. Not a lot of margin there.

Last one, and that's what 1.4.0 fixes: with a weak signal, around -70 dBm, the board would miss scrapes every now and then because the radio naps between beacons. There's now a setting to keep it awake. It's off by default because it costs about 60 mA, which you feel on battery.

So

The single file wasn't bad. It did what I needed for years. Hardcoded password, hardcoded timezone, fake zeros, all fine as long as there was exactly one box. The second board is what made all of it show up at once.

What bugs me a bit more is the small stuff that was wrong the whole time. The ppm/ppb thing, the SGP30 read at the wrong rate, a reset that hit the entire bus. Nothing ever looked broken, so nothing ever told me. I only found them because I was rewriting the drivers from the datasheets and had to actually read every line I was replacing.

If you have an AirGradient DIY kit and a Prometheus somewhere, it should work on yours too. Code is on GitHub, MIT licensed.