The Sensors Aren’t Alright

Projects
Electronics
Air Quality
Author

Jean-François Im

Published

April 30, 2026

A few weeks ago, I automated my air purifiers in the house using Home Assistant, MQTT, and a Python controller to turn the air quality data from the sensors that I have into a speed control for the air purifiers.

One thing that I noticed is that the air purifiers in one part of the house tended to run faster than in a different part of the house. I originally assumed it was because the kitchen and dining areas had more particles in suspension due from the cooking, but that should have been handled by the air purifiers eventually.

I then decided to open one of the PMSA003 sensors to clean it up. It turns out that in practice, due to the design of the sensor, there isn’t really a whole lot to clean up inside and it was actually pretty clean, since the air flow is such that it passes through the sensing cavity, far from the laser diode and the photoreceptor.

Since I couldn’t find instructions anywhere on the internet as to how to open a PMSA003, you’ll first need to remove the two small screws holding the fan. Then, you can pry open the sheet metal lid and bottom by inserting a very thin flat heat screwdriver between the middle plastic lip that separates the upper and lower parts of the sensor’s sheet metal housing.

Running the screwdriver along the edge of the sheet metal will allow you to fold it outwards slightly, thus releasing that side from the clips in the plastic housing. Inside, there’s a small L shaped PCB, three springs (one of which is slightly longer than the others), and a few screws holding the whole thing together.

When putting back the sensor together, fold the sheet metal back into its normal unbent shape using needlenose pliers, then push it back into its regular position to close the sensor housing. You may need to tape the edges of the sensor housing to prevent air leaks, if the sheet metal isn’t bent back perfectly.

The only thing there is to clean is the little fan, which can be cleaned up with a soft bristle brush, but that’s also on the exit path so that doesn’t really leak particles into the sensing cavity. So after spending far too long on cleaning it up, the difference before and after is a sensor that performs exactly the same as before but looks markedly worse with sheet metal that’s bent slightly out of shape and tape to seal it off.

However, just claiming that there is no change without evidence make it a rather dubious unsubstantiated opinion of which the internet is full of. But since we have the ability to make statistical inference of questionable pertinence on the internet, let’s do so.

So the first question is whether or not the error rate on the sensors is different before and after opening it. If there are no differences between the sensors, we should have a similar error rate before and after:

Code
library(tidyverse)
library(glue)

day_before_yesterday <-
tibble::tribble(
                  ~hour,    ~macAddress, ~bad, ~total,           ~pct_bad,
  "2026-04-28 00:00:00", "840d8ea81a38",  87L,  3005L, 0.0289517470881864,
  "2026-04-28 00:00:00", "dc4f2260c2a7", 170L,  3004L, 0.0565912117177097,
  "2026-04-28 01:00:00", "840d8ea81a38", 101L,  3004L, 0.0336218375499334,
  "2026-04-28 01:00:00", "dc4f2260c2a7", 175L,  3005L, 0.0582362728785358,
  "2026-04-28 02:00:00", "840d8ea81a38",  95L,  3004L,  0.031624500665779,
  "2026-04-28 02:00:00", "dc4f2260c2a7", 158L,  3004L, 0.0525965379494008,
  "2026-04-28 03:00:00", "840d8ea81a38",  89L,  3004L, 0.0296271637816245,
  "2026-04-28 03:00:00", "dc4f2260c2a7", 192L,  3004L, 0.0639147802929427,
  "2026-04-28 04:00:00", "840d8ea81a38", 101L,  3004L, 0.0336218375499334,
  "2026-04-28 04:00:00", "dc4f2260c2a7", 176L,  3004L, 0.0585885486018642,
  "2026-04-28 05:00:00", "840d8ea81a38", 109L,  3005L, 0.0362728785357737,
  "2026-04-28 05:00:00", "dc4f2260c2a7", 161L,  3004L,  0.053595206391478,
  "2026-04-28 06:00:00", "840d8ea81a38", 105L,  3004L, 0.0349533954727031,
  "2026-04-28 06:00:00", "dc4f2260c2a7", 180L,  3004L, 0.0599201065246338,
  "2026-04-28 07:00:00", "840d8ea81a38",  99L,  3004L, 0.0329560585885486,
  "2026-04-28 07:00:00", "dc4f2260c2a7", 169L,  3005L, 0.0562396006655574,
  "2026-04-28 08:00:00", "840d8ea81a38",  97L,  3004L, 0.0322902796271638,
  "2026-04-28 08:00:00", "dc4f2260c2a7", 155L,  3004L, 0.0515978695073236,
  "2026-04-28 09:00:00", "840d8ea81a38",  89L,  3005L, 0.0296173044925125,
  "2026-04-28 09:00:00", "dc4f2260c2a7", 184L,  3004L, 0.0612516644474035,
  "2026-04-28 10:00:00", "840d8ea81a38", 109L,  3005L, 0.0362728785357737,
  "2026-04-28 10:00:00", "dc4f2260c2a7", 178L,  3005L,  0.059234608985025,
  "2026-04-28 11:00:00", "840d8ea81a38",  83L,  3004L,   0.02762982689747,
  "2026-04-28 11:00:00", "dc4f2260c2a7", 173L,  3004L,  0.057589880159787,
  "2026-04-28 12:00:00", "840d8ea81a38", 101L,  3004L, 0.0336218375499334,
  "2026-04-28 12:00:00", "dc4f2260c2a7", 205L,  3004L, 0.0682423435419441,
  "2026-04-28 13:00:00", "840d8ea81a38", 100L,  3004L,  0.033288948069241,
  "2026-04-28 13:00:00", "dc4f2260c2a7", 191L,  3004L, 0.0635818908122503,
  "2026-04-28 14:00:00", "840d8ea81a38",  95L,  3005L, 0.0316139767054908,
  "2026-04-28 14:00:00", "dc4f2260c2a7", 165L,  3005L, 0.0549084858569052,
  "2026-04-28 15:00:00", "840d8ea81a38",  96L,  3004L, 0.0319573901464714,
  "2026-04-28 15:00:00", "dc4f2260c2a7", 151L,  3004L, 0.0502663115845539,
  "2026-04-28 16:00:00", "840d8ea81a38",  91L,  3004L, 0.0302929427430093,
  "2026-04-28 16:00:00", "dc4f2260c2a7", 172L,  3004L, 0.0572569906790945,
  "2026-04-28 17:00:00", "840d8ea81a38",  99L,  3005L, 0.0329450915141431,
  "2026-04-28 17:00:00", "dc4f2260c2a7", 172L,  3004L, 0.0572569906790945,
  "2026-04-28 18:00:00", "840d8ea81a38", 104L,  3004L, 0.0346205059920107,
  "2026-04-28 18:00:00", "dc4f2260c2a7", 167L,  3005L, 0.0555740432612313,
  "2026-04-28 19:00:00", "840d8ea81a38",  87L,  3004L, 0.0289613848202397,
  "2026-04-28 19:00:00", "dc4f2260c2a7", 204L,  3004L, 0.0679094540612517,
  "2026-04-28 20:00:00", "840d8ea81a38",  99L,  2996L, 0.0330440587449933,
  "2026-04-28 20:00:00", "dc4f2260c2a7", 206L,  2997L, 0.0687354020687354,
  "2026-04-28 21:00:00", "840d8ea81a38",  95L,  3004L,  0.031624500665779,
  "2026-04-28 21:00:00", "dc4f2260c2a7", 216L,  3004L, 0.0719041278295606,
  "2026-04-28 22:00:00", "840d8ea81a38", 100L,  3004L,  0.033288948069241,
  "2026-04-28 22:00:00", "dc4f2260c2a7", 204L,  3004L, 0.0679094540612517,
  "2026-04-28 23:00:00", "840d8ea81a38", 101L,  3005L, 0.0336106489184692,
  "2026-04-28 23:00:00", "dc4f2260c2a7", 204L,  3004L, 0.0679094540612517
  )

today <- tibble::tribble(
                  ~hour,    ~macAddress, ~bad, ~total,           ~pct_bad,
  "2026-04-30 00:00:00", "840d8ea81a38",  87L,  3004L, 0.0289613848202397,
  "2026-04-30 00:00:00", "dc4f2260c2a7", 192L,  3004L, 0.0639147802929427,
  "2026-04-30 01:00:00", "840d8ea81a38",  91L,  3005L, 0.0302828618968386,
  "2026-04-30 01:00:00", "dc4f2260c2a7", 173L,  3004L,  0.057589880159787,
  "2026-04-30 02:00:00", "840d8ea81a38", 102L,  3004L, 0.0339547270306258,
  "2026-04-30 02:00:00", "dc4f2260c2a7", 170L,  3004L, 0.0565912117177097,
  "2026-04-30 03:00:00", "840d8ea81a38",  94L,  2988L, 0.0314591700133869,
  "2026-04-30 03:00:00", "dc4f2260c2a7", 182L,  2990L, 0.0608695652173913,
  "2026-04-30 04:00:00", "840d8ea81a38",  17L,   498L,  0.034136546184739,
  "2026-04-30 04:00:00", "dc4f2260c2a7",  31L,   497L,  0.062374245472837,
  "2026-04-30 05:00:00", "840d8ea81a38",  26L,   895L, 0.0290502793296089,
  "2026-04-30 05:00:00", "dc4f2260c2a7",  70L,   895L, 0.0782122905027933,
  "2026-04-30 06:00:00", "840d8ea81a38",  33L,  1194L, 0.0276381909547739,
  "2026-04-30 06:00:00", "dc4f2260c2a7", 193L,  2987L, 0.0646133244057583,
  "2026-04-30 07:00:00", "840d8ea81a38",  80L,  2094L, 0.0382043935052531,
  "2026-04-30 07:00:00", "dc4f2260c2a7",  51L,   702L, 0.0726495726495727,
  "2026-04-30 08:00:00", "840d8ea81a38",  95L,  3005L, 0.0316139767054908,
  "2026-04-30 08:00:00", "dc4f2260c2a7", 151L,  2262L, 0.0667550839964633,
  "2026-04-30 09:00:00", "840d8ea81a38",  92L,  2926L, 0.0314422419685578,
  "2026-04-30 09:00:00", "dc4f2260c2a7", 211L,  2983L, 0.0707341602413677,
  "2026-04-30 10:00:00", "840d8ea81a38",  98L,  3004L, 0.0326231691078562,
  "2026-04-30 10:00:00", "dc4f2260c2a7", 199L,  3005L, 0.0662229617304493,
  "2026-04-30 11:00:00", "840d8ea81a38",  98L,  3005L,   0.03261231281198,
  "2026-04-30 11:00:00", "dc4f2260c2a7", 218L,  3005L, 0.0725457570715474,
  "2026-04-30 12:00:00", "840d8ea81a38",  93L,  3004L, 0.0309587217043941,
  "2026-04-30 12:00:00", "dc4f2260c2a7", 217L,  3004L,  0.072237017310253,
  "2026-04-30 13:00:00", "840d8ea81a38",  91L,  3005L, 0.0302828618968386,
  "2026-04-30 13:00:00", "dc4f2260c2a7", 210L,  3004L, 0.0699067909454061,
  "2026-04-30 14:00:00", "840d8ea81a38",  87L,  3004L, 0.0289613848202397,
  "2026-04-30 14:00:00", "dc4f2260c2a7", 214L,  3004L, 0.0712383488681758,
  "2026-04-30 15:00:00", "840d8ea81a38",  97L,  3004L, 0.0322902796271638,
  "2026-04-30 15:00:00", "dc4f2260c2a7", 211L,  3004L, 0.0702396804260985,
  "2026-04-30 16:00:00", "840d8ea81a38",  92L,  3004L, 0.0306258322237017,
  "2026-04-30 16:00:00", "dc4f2260c2a7", 223L,  3005L, 0.0742096505823627,
  "2026-04-30 17:00:00", "840d8ea81a38", 100L,  3004L,  0.033288948069241,
  "2026-04-30 17:00:00", "dc4f2260c2a7", 205L,  3004L, 0.0682423435419441,
  "2026-04-30 18:00:00", "840d8ea81a38",  74L,  2182L, 0.0339138405132906,
  "2026-04-30 18:00:00", "dc4f2260c2a7", 427L,  2982L,  0.143192488262911,
  "2026-04-30 19:00:00", "840d8ea81a38", 100L,  3004L,  0.033288948069241,
  "2026-04-30 19:00:00", "dc4f2260c2a7", 139L,  3004L,  0.046271637816245,
  "2026-04-30 20:00:00", "840d8ea81a38",  86L,  2877L, 0.0298922488703511,
  "2026-04-30 20:00:00", "dc4f2260c2a7", 218L,  2860L, 0.0762237762237762,
  "2026-04-30 21:00:00", "840d8ea81a38",  95L,  2982L, 0.0318578135479544,
  "2026-04-30 21:00:00", "dc4f2260c2a7", 166L,  2982L, 0.0556673373574782,
  "2026-04-30 22:00:00", "840d8ea81a38", 105L,  3005L, 0.0349417637271215,
  "2026-04-30 22:00:00", "dc4f2260c2a7", 220L,  3005L, 0.0732113144758735,
  "2026-04-30 23:00:00", "840d8ea81a38",  98L,  2968L, 0.0330188679245283,
  "2026-04-30 23:00:00", "dc4f2260c2a7", 170L,  2877L, 0.0590893291623219
  )

for (sensor in c("dc4f2260c2a7", "840d8ea81a38")) {
  print(glue::glue("Sensor {sensor} error rate different?"))
  today_sensor <- today %>% filter(macAddress == sensor)
  day_before_yesterday_sensor <- day_before_yesterday %>% filter(macAddress == sensor)
  print(prop.test(
    x=c(sum(day_before_yesterday_sensor$bad), sum(today_sensor$bad)),
    n=c(sum(day_before_yesterday_sensor$total), sum(today_sensor$total))
  ))
}
Sensor dc4f2260c2a7 error rate different?

    2-sample test for equality of proportions with continuity correction

data:  c(sum(day_before_yesterday_sensor$bad), sum(today_sensor$bad)) out of c(sum(day_before_yesterday_sensor$total), sum(today_sensor$total))
X-squared = 51.523, df = 1, p-value = 7.076e-13
alternative hypothesis: two.sided
95 percent confidence interval:
 -0.012230667 -0.006951284
sample estimates:
    prop 1     prop 2 
0.06003274 0.06962371 

Sensor 840d8ea81a38 error rate different?

    2-sample test for equality of proportions with continuity correction

data:  c(sum(day_before_yesterday_sensor$bad), sum(today_sensor$bad)) out of c(sum(day_before_yesterday_sensor$total), sum(today_sensor$total))
X-squared = 0.20103, df = 1, p-value = 0.6539
alternative hypothesis: two.sided
95 percent confidence interval:
 -0.001449097  0.002338800
sample estimates:
    prop 1     prop 2 
0.03234621 0.03190136 

And what do you know, it’s actually worse off, going from 6% to 7% of readings from the cleaned sensor being marked as erroneous, so if anything, opening it was a bad idea.

Technically it could be another reason like ESD discharge during the disassembly process, a poorly reconnected sensor or disturbance of the board, or simply an unfortunate coincidence of a cheap AliExpress sensor.

Ignoring the error rate, we can also look at the actual measurements from the sensor. I placed the two sensors as close as possible, approximately 3mm apart, and got Claude to analyze the CSV data from the sensor and generate a line plot of the different measurements from the sensors, broken down by sensor MAC address, faceted by measurement, with two relevant events added to the timeline — cooking lunch and switching both sensors to use a single 9V power source — to see if they had any effects.

Sensor measurement plot

Sensor measurement plot

What the graph shows is that the other sensor consistently undercounts particles, especially at the 1μm size, that it isn’t just a lower sensitivity at low particle counts, nor a power issue despite the 5V rail not looking so hot, since both sensors basically see the same voltage at the PMSA003 Vin pin.

5V rail voltage

5V rail voltage

My hypothesis is that the fan is actually starting to fail, hence the higher reading failure rate. When disassembling the particle sensor, I noticed that the fan connector appears to have three pins, indicating that the fan speed could be measured by the sensor. If that’s the case and the fan is slowing down below nominal speed and thus drawing in less air through the sensor cavity, that would align with the lower readings, especially at larger particle sizes.

Honestly for an AliExpress sensor that’s probably a factory reject that fell out the back of a truck, it worked out pretty well, and I can just get a new one for twenty something bucks. Chances thrown, nothing’s free.

So I guess that sensor is kind of shot and needs a replacement, unfortunately.

On the upside, that had me researching what the current air quality sensors are, and I noticed that Sensirion has come up with their SEN66 combo sensor that has a laser particle sensor, a CO₂ sensor that’s likely the photoacoustic NDIR SCD43, relative humidity and temperature sensors, metal oxide sensors to estimate VOC and NOx1, and a fan to circulate air over all of those sensors. Considering that basically measures almost everything you’d want to measure for indoors air quality2 and it speaks I²C, that’s pretty much a slam dunk as far as indoors air quality measurement is concerned. Considering a SCD43 is about twenty bucks, a comparable SPS30 is about thirty bucks, getting the whole package in the SEN66 for less than sixty bucks is pretty decent.

So it looks like I’ll be retiring those sensors that are grown up but whose lives are worn, and look towards a new future that’s so bright with the new SEN66.

Footnotes

  1. The VOC and NOx values are estimates rather than true values, but that’s common to all inexpensive MOX sensors. In practice, the important part is the directionality of the estimate, not the actual absolute value, since if it’s high you’d open the window whether the concentration of VOCs is 300ppb or 538.53ppb.↩︎

  2. Maybe other than formaldehyde, radon, carbon monoxide, and ozone.↩︎